Introduction to Solidity -> sCrypt Transpiler
sol2scrypt
is a transpiler that converts Solidity code into equivalent sCrypt code, making it easier for Solidity developers to migrate code and quickly learn sCrypt.
Before introducing the innerworkings of the transpiler, let us review the main differences between Ethereum's account model and Bitcoin's UTXO model:
The account model of Ethereum maintains a separate state for each contract and updates it through contract calls. The advantage of this is that a globally unique address can be used for fast lookup of a contract and it is closer to the model of traditional databases. But its biggest disadvantage is contracts can only be processed sequentially, hindering performance.
The UTXO model used by Bitcoin maintains a set of UTXOs for each contract and uses the aggregated set to represent the state of the contract. The advantage of this is that transactions can be processed as independently as possible, maximizing parallelization. The downside is that the inability to use a single fixed address for addressing makes writing certain contracts more difficult.
The sol2scrypt
transpiler aims to be an automatic conversion tool from Solidity to sCrypt smart contracts. It provides a good starting point for developers who are less familiar with smart contracts based on the UTXO model. On the one hand, it allows developers to intuitively see different implementations of the same business logic in two languages. On the other hand, it saves them from writing equivalent sCrypt contracts from scratch.
Transpiling Principles
At high level, we use a single UTXO to represent a snapshot of an Ethereum contract and perform the equivalent conversion of the contract code on this premise. We take the different life cycles of the contract as observation points and briefly introduce their mapping principle.
Contract Deployment
We use a single UTXO to store the initial state of a contract instance in the Ethereum Virtual Machine (EVM).
Contract Call
We use Stateful Smart Contracts to map function calls that change a contract's state. Whenever the state of the contract needs to be changed, a new transaction is generated that spends the current UTXO of the contract and generates another UTXO with the new state.
Contract Destruction
The destruction of the contract instance of Ethereum needs to call the selfdestruct
method to mark its internal state as invalid. On Bitcoin we only need to spend the current UTXO of the contract and no longer generate a new contract UTXO, instead.
The above is the basic principle of the entire transpilation. It should be noted that it is the most straightforward way to transpile. We only use a single UTXO to represent a single contract. In fact, for a specific Solidity contract, there may be other transpiling methods that are more complex but can improve parallel performance, but this may demand more manual intervensions by developers.
Syntax Transpiling
Let's take a look at syntax transpiling details.
Contract State Variables
Since Solidity's contract state variables carry the contract state and they may change as the contract is called, they will all be transpiled into sCrypt's stateful contract properties.
contract A {
int x;
bool y;
uint z;
...
}
The transpiling result is:
contract A {
@state
int x;
@state
bool y;
@state
int z;
}
There are two special cases:
- Solidity's
constant
state variables are transpiled intostatic const
properties of sCrypt; - Solidity's
immutable
state variables are transpiled intoconst
properties of sCrypt;
For example, for Solidiy code:
contract A {
uint constant x = 1;
uint immutable y;
...
}
The transpiling result is:
contract A {
static const int x = 1;
const int y;
}
Type of Data
1. Basic Data Types
Solidity's basic data types include bool
/ (int
& uint
) / (bytes
& string
) / address
. These basic types will be directly transpiled into bool
/int
/bytes
/PubKeyHash
type.
2. Struct Type
Solidity's struct
will be transpiled into sCrypt struct
and each member contained in it will be transpiled into the corresponding sCrypt type. However, if a structure contains properties of contract type, it cannot be directly transpiled currently.
3. Array Type
Solidiy's array type is also directly transpiled into sCrypt arrays, with a limitation: dynamic-length arrays cannot be transpiled, because sCrypt's native arrays must be of fixed-length.
4. Mapping Type
The mapping
type in Solidiy is also a popular data structure. It will be transpiled into the HashedMap
data type of sCrypt, but special care must be taken in this transpilation process.
First of all, Solidiy's mapping
is a regular hash table implementation. It supports the use of basic types as keys, arbitrary types as values, and supports operations such as addition, deletion, modification, and search. But the HashedMap
in sCrypt is not such an ordinary hash table implementation and has several very unique characteristics:
What is stored in
HashedMap
is not the originalkey
andvalue
values in themapping
data structure, but their hashes. It can be roughly regarded as the hash of the entiremapping
. Or to put it another way, it is a proof that ensures the key-value pair exists in themapping
.When a program needs to use one of the key-value pairs, it first needs to pass in the original values as external arguments and uses
HashedMap
to verify their authenticity. If the verification passes, the passed arguments are legitmate and can be used safely; otherwise, it indicates the key-value pair is invalid and the program should not use them.
The main reason why HashedMap
is designed this way is due to the limit of the script size and thus the lack of unbounded loops. For more explantion, please refer to this article.
This verification-based model is a distinguishing feature of Bitcoin smart contracts compared to those on other blockchains.
Suppose we already have an instance mapping(uint256 => uint256) m;
, access to this instance will be transformed as follows:
Append the orignal
val
value and anidx
value (the index ofkey
after all key hashes are sorted) in the input parameters of the function ;Verify that this
val
is indeed the value corresponding tokey
;Replace any read operation with the new
val
parameter;If there is any write operation, the update to
HashedMap
needs to be appended;
For example, for the following Solidity code:
mapping(uint256 => uint256) m;
function xxx(...) {
...
a = m[key];
m[key] = 1;
...
}
The transpiling result is:
@state
HashedMap<int, int> m;
function xxx(... int val, int idx) { // parameters injection
...
require((!this.m.has(key, idx) && val == 0) || this.m.canGet(key, val, idx)); // validation
a = val; // for read case, replace `m[key]` with 'val'
val = 1; // for write case, also replace `m[key]` with 'val'
require(this.m.set(key, val, idx)); // update
...
}
In addition, when dealing with nested mapping
types, such as mapping (address => mapping (address => uint))
, the nested keys are defined as an sCrypt struct, used as the key type of the HashedMap
. for example:
mapping (address => mapping (address => uint)) nm;
...
x = nm[a][b];
The transpiling result is:
struct MapKeyST0 {
PubKeyHash key0;
PubKeyHash key1;
}
HashedMap nm;
...
require((!this.nm.has({a, b}, idx) && val == 0) || this.nm.canGet({a, b}, val, idx));
x = val;
It is important to note that there are currently some restrictions on the conversion of mapping
, including:
key
cannot be reassigned within a function, otherwise the result is undefined;
a = m[key];
key = xxx;
m[key] = b;
mapping
of nested types cannot be partially read and written, e.g.nm[a] = anotherMapping;
Operators
Most of Solidity's operators can be directly transpiled into the same operator in sCrypt, such as +
, -
, *
, and /
. There is only one exception, the exponentiation operator **
is not currently supported.
In addition, care must be taken when transpiling bitwise operators. Integers in sCrypt are encoded in little endian, and negative numbers are not encoded in signed-magnitude, not Solidity's two's complement. Although the transpiled expressions look the same, the evaluation result may not be the same.
Conditional Statements
if
/ else
statements are directly transpiled verbatim.
Loop Statements
Solidity supports three common loop statements, namely for
, while
, and do ... while
. They are all implemented via jump instructions internally. However, since there is no jump instruction in the Bitcoin virtual machine (BVM), the loop statements cannot be directly implemented. sCrypt's loop construct is implemented as the loop
statement, whose number of loops has to be known at compile time.
When transpling Solidiy's loop statements, we uniformly map them to sCrypt's loop
. Since the specific number of loops is closely related to the business logic, the transpiler cannot always automatically give a reasonable estimate, a placeholder variable such as __LoopCount__0
is used instead. This requires developers to manually modify the transpiled sCrypt code and replace the placeholder. Otherwise the transpiled sCrypt contract cannot be compiled. The number of loops can generally be deduced from the gas limit.
for
Statement
For example, for the following Solidity code for
loop:
uint sum = 0;
for(uint i=0; i<n; i++) {
sum += i;
}
The transpiling result is:
int sum = 0;
int i = 0;
loop (__LoopCount__0) {
if (i < n) {
sum += i;
i++;
}
}
while
Statement
Applying a similar principle, Solidity's while
statement can be handled:
uint sum = 0;
int i = 0;
while(i < 10) {
sum += i;
i++;
}
The transpiling result is:
int sum = 0;
int i = 0;
loop (__LoopCount__0) {
if (i < 10) {
sum += i;
i++;
}
}
do ... while
Statement
do ... while
is slightly different in that its loop body is executed at least once, so for the following Solidity code:
do {
sum += i;
i++;
} while (i < 100);
The transpiling result is:
{
sum += i;
i++;
}
loop (__LoopCount__0) {
if (i < 100) {
sum += i;
i++;
}
}
break
Statement
As we just mentioned, there is no direct jump in sCrypt, it seems impossible to transplie the break
or continue
statements common in loops at first glance. But in fact, we can still combine the conditional statements if
and else
to implement these two logics, with the help of an additional boolean flag.
For example, for the following Solidity code:
for(uint i=0; i<n; i++) {
sum += i;
if(sum > 10)
break;
}
The transpiling result is:
bool loopBreakFlag0 = false;
int i = 0;
loop (__LoopCount__0) {
if (!loopBreakFlag0 && i < n) {
sum += i;
if (sum > 10)
loopBreakFlag0 = true;
i++;
}
}
continue
Statement
Tranpiling continue
is similar to how break
is handled, the only notable difference is that continue
only skips the following statements within the current loop iteration, while break
skips all remaining loop iterations afterwards.
Let us look at this example:
do {
sum += i;
if (sum < 20)
continue;
i++;
} while (i < 100);
The transpiling result is:
{
sum += i;
i++;
}
loop (__LoopCount__0) {
if (i < 100) {
bool loopContinueFlag0 = false;
sum += i;
if (sum < 20) {
{
loopContinueFlag0 = true;
}
}
if (!loopContinueFlag0) {
i++;
}
}
}
Function
Functions act as the interface to interacting with smart contracts. Due to the differences between EVM and BVM, the transpilation will not be as intuitive and easy to understand as some of the syntaxs introduced earlier. We will cover the relevant transpilation principles in detail.
The first thing to note is that Solidity functions have 4 types of visibility:
private
: can only be called by the contract itself;internal
: can only be called by this contract and its derived contracts;external
: can only be called by sending a message from outside (including sending a transaction);public
: can be called either ininternal
orexternal
mode;
Depending on whether they can be called by sending a transaction, they can be divided into two categories: external
and public
are allowed, while private
and internal
are not. According to this standard, we transplate the former into the public
function of sCrypt, which is the external interface of the contract, and the latter into the private
and default
functions, which are the specific implementation inside the contract. For details, see:
Solidity | sCrypt |
---|---|
private |
private |
internal |
default |
external |
public |
public |
public |
private
/ internal
functions
Since these two types of functions are only called inside a contract, considering the complexity of the transpiler implementation, it is agreed that they must satisfy a constraint: that is, the number of parameters cannot be changed when transpling the function body, which will lead to changes in the function signature. An example of function transpilation:
function f2(uint a, uint b) internal pure returns (uint) {
return a + b;
}
The transpiling result is:
static function f2(int a, int b) : int {
return a + b;
}
return
statement
Note here: Special handling may be required for return
statements in private
/ internal
functions. Specifically:
- No return value
Since Solidity allows function without return value but sCrypt does not, it will be transpilated into returning a default bool
value in this case. For example, the following Solidity code:
function a() private {
return;
}
The transpiling result is:
private function a() : bool {
return true;
}
- Return in the middle of a function
As mentioned earlier, internally BVM does not have jump instructions, thus sCrypt does not support returning in the middle of a function. If this happens in Solidity code, special handling is required. It works similarly to the previous break
/ continue
, adding a boolean flag and if
/ else
for equivalent transformation. For example, for Solidity code:
function get(uint amount) internal view returns (uint) {
if (amount > 0)
return amount;
return 0;
}
The transpiling result is:
function get(int amount) : int {
int ret = 0;
bool returned = false;
if (amount > 0) {
{
ret = amount;
returned = true;
}
}
return returned ? ret : 0;
}
public
/ external
functions
Both types of functions can be called by sending a transaction, so we transplie them into sCrypt's public
function. There are two special cases:
- return value
The public
function of sCrypt actually implicitly returns a boolean type, indicating whether a contract function call succeeds or not. When Solidity's public
/ external
function has a return value, it is necessary to make a modification. That is, add a parameter of the original return value type, and verify that the passed value is equal to the original return value. Take this example:
function get() external pure returns (uint) {
return 1 + 1;
}
The transpiling result is:
public function get(int retVal) {
require(1 + 1 == retVal);
}
Here we emphasize again: sCrypt's public
function is to verify whether the spending condition is satisfied, not to return a certain result through direct computation.
- propagate states
Another one that is not intuitively easy to understand is the propagation of the state change of a contract. sCrypt's stateful contract is based on the UTXO model. Every time its public
function is called, we need to ensure that the state is correctly passed to the new contract UTXO. This is why public
functions transpilated from Solidity will always end with a require(this.propagateState()));
statement. Plus, a common SigHashPreimage
type parameter (i.e., transaction preimage) needs to be added to the parameter list of the function.
function set(uint x) external {
storedData = x;
}
The transpiling result is:
public function set(int x, SigHashPreimage txPreimage) {
this.storedData = x;
require(this.propagateState(txPreimage));
}
Built-in object properties: msg.sender
and msg.value
Two of the most commonly used built-in object properties in Solidity are: msg.sender
and msg.value
. The former returns the address of the current function caller, and the latter returns the amount of ether (in wei
) carried in the transaction.
For msg.sender
, we map it to the Bitcoin address of the caller of the public function. We add the corresponding PubKey
type and Sig
type parameters in the public function and add the following statements to the function, which make sure the contract is called by the said caller by verifying his signature:
PubKeyHash msgSender = hash160(pubKey);
require(checkSig(sig, pubKey));
For msg.value
, we map it to the amount of Bitcoin (in satoshi
) deposited to the contract UTXO through this call. We add an int
type parameter msgValue
to the public method, requiring the caller to actively pass in its value as an argument. At the same time, through the OP_PUSH_TX technique, we can know the original balance of the contract. The new balance after the contract is called should be: SigHash.value(txPreimage) + msgValue
. Finally, use the aforementioned propagateState
method inside the function to verify that the balance in the new contract UTXO does indeed contain the expected increment.
public function xxx(... int msgValue, SigHashPreimage txPreimage) {
...
int contractBalance = SigHash.value(txPreimage) + msgValue; // add `msgValue` to contract balance
require(msgValue >= 0); // basic validation
...
require(this.propagateState(txPreimage, contractBalance)); // check the new balance is real
}
There is a special case worth noting here. If Solidity accesses msg.value
in the constructor, it will automatically add an initBalance
property to the contract during transpilation, which would be initialized as msg.value
in the constructor. In addition, because the constructor of sCrypt cannot verify whether the value passed in as an argument is forged or not, it is necessary to verify that the initial value is correct in the first call of public methods. For example, for Solidity code:
constructor (...) {
a = msg.value;
...
}
The transpiling result is:
const int initBalance;
constructor(... int msgValue) {
this.a = msgValue;
...
this.initBalance = msgValue;
}
public function xxx(... SigHashPreimage txPreimage) {
require(this.checkInitBalance(txPreimage));
...
}
function checkInitBalance(SigHashPreimage txPreimage) : bool {
return !Tx.isFirstCall(txPreimage) || SigHash.value(txPreimage) == this.initBalance;
}
Limitations
Due to the fundamental difference between the account model and the UTXO model, the transpiler has certain limitations and cannot achieve 100% automatic conversion rate for arbitrary Solidity contracts. At present, there are centain Solidity grammars that cannot be transpiled, including, but are not limited to, the following:
- destructuring assignment
- enum
- interface
- inheritance
- exception handling
try
/catch
modifier
receive
/fallback
functions- some built-in methods and properties, such as
block.*
/tx.*
/ ... - inter-contract calls
- event definitions and
emit()
function calls assembly
statement
Summary
As we mentioned at the beginning, the main function of this transpiler is to help developers quickly migrate from Solidity contracts to sCrypt contracts, so that they can use the Bitcoin blockchain to build Web3 applications with a more efficient and economic network. If you have any questions or suggestions, please join our sCrypt slack discussion group or Github repository