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
constantstate variables are transpiled intostatic constproperties of sCrypt; - Solidity's
immutablestate variables are transpiled intoconstproperties 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
HashedMapis not the originalkeyandvaluevalues in themappingdata 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
HashedMapto 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
valvalue and anidxvalue (the index ofkeyafter all key hashes are sorted) in the input parameters of the function ;Verify that this
valis indeed the value corresponding tokey;Replace any read operation with the new
valparameter;If there is any write operation, the update to
HashedMapneeds 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:
keycannot be reassigned within a function, otherwise the result is undefined;
a = m[key];
key = xxx;
m[key] = b;
mappingof 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 ininternalorexternalmode;
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 modifierreceive/fallbackfunctions- some built-in methods and properties, such as
block.*/tx.*/ ... - inter-contract calls
- event definitions and
emit()function calls assemblystatement
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