In the process of program development, we are often faced with the following requirements:
- Static configuration: The source code should be developed to be versatile for different purposes through simple configuration.
- Dynamic configuration: During the program operation, change the behavior of the program by modifying some preferences
This article offers suggestions on how to meet the static and dynamic configuration requirements when developing smart contracts using the Solidity language.
Static configuration through constants
Among the static configuration methods well known to programmers, the macro in C/C++ is the oldest. The preprocessor reads the source code with macro definitions and converts it into source code without macro definitions. Because of the lack of type information, macros are inherently inadequate, and thus may bring along security risks when used to define literal values and implement conditional compilation. As an alternative, constants should be used to realize literal values, and constants and if-else for effects similar to conditional compilation.
After learning the lessons of C/C++, many programming languages, such as Java, C# and Golang, no longer support macros and preprocessors. The Solidity language does not support macros or preprocessors, either (https://github.com/ethereum/solidity/issues/10). Although a third-party preprocessor can be used to support macros, such as https://github.com/merklejerk/solpp, that is a rare case in practice.
Solidity provides the constant keyword to define constants
known at compile time (hereinafter referred to as constants). The use of constants does not involve storage operations at all. In the source code of OneSwap, the constant constant is frequently used for configuration. For example, in OneSwapPair.sol:
string private constant _NAME = "OneSwap-Share";
uint8 private constant _DECIMALS = 18;
In the code, the name of the ERC20 token is set by the constant_NAME
, and the effective number of decimal places after the decimal point is set by_DECIMALS
. As long as these constants are modified, the code can be applied to different occasions.
Static configuration through persistent storage
Configuration through constants requires modifying the source code and recompiling to get the new bytecode before deployment. Sometimes we find this method not flexible, and hope to decide the value of the configuration at the time of deployment. This can be achieved by assigning values to persistent storage in the constructor. If the value of "constant" can be determined only when the contract is deployed (for example, passed in through the constructor), it can only be achieved through ordinary state variables before Solidity v0.6.5. Below is a code in OneSwapToken.sol:
mapping (address => uint256) private _balances;
uint256 private _totalSupply;
string private _name;
string private _symbol;
uint8 private immutable _decimals;
constructor (string memory name, string memory symbol, uint256 supply, uint8 decimals) public OneSwapBlackList() {
_name = name;
_symbol = symbol;
_decimals = decimals;
_totalSupply = supply;
_balances[msg.sender] = supply;
}
The state variables_name
,_symbol
, and_decimals
here are all assigned values only once in the constructor, and they are read-only during subsequent operations.
Static configuration through Immutable
As we know, the reading and writing of the contract state, that is, the reading and writing of storage, consumes a lot of gas. Therefore, when writing a contract, we should do everything possible to reduce (preferably to eliminate) storage reading and writing. State variables that are written only once are too wasteful of gas. The state variable immutable
newly added by Solidity in v0.6.5 optimizes the condition that "a constant, once assigned in the constructor, is read-only during the subsequent operation". By modifying the bytecode during contract runtime when deploying the contract, the "constant" can be determined until the contract is deployed. For the convenience of discussion, we call the state variable immutable
as an invariable. In the examples listed above, _decimals
is an invariable.
The underlying implementation mechanism of the three static configuration methods
Constants and ordinary state variables are easier to understand. Now we will compare constants, invariables and ordinary state variables through a case study to discuss the realization principle of invariables.
The invariables are easy to use: just add the keyword immutable
when defining a state variable, and assign value to it in the constructor. Note that you can only and must assign a value to an invariable when the contract is constructed, and only its value can be read when the contract is executed. At present, invariables can only be used to define value types, not structures or other types. For example, in the above example, _name
and_symbol
cannot be defined immutable
. However, this restriction may be released in future versions of Solidity. Now we use a simple example contract to analyze the principle of invariables. The complete code is as follows:
pragma solidity =0.6.12;
contract ImmutableDemo {
uint256 private constant _f0 = 0x1234;
uint256 private immutable _f1 = 0x5678;
uint256 private immutable _f2;
uint256 private _f3;
constructor(uint256 f2) public {
_f2 = f2 & 0xABCD;
_f3 = 0xEFEF;
}
function addF0(uint256 x) external pure returns (uint256) {
return (x + _f0) & 0xFFFFFF00;
}
function addF1(uint256 x) external view returns (uint256) {
return (x + _f1) & 0xFFFFFF01;
}
function addF2(uint256 x) external view returns (uint256) {
return (x + _f2) & 0xFFFFFF02;
}
function addF3(uint256 x) external view returns (uint256) {
return (x + _f3) & 0xFFFFFF03;
}
}
The contract defines a total of 4 variables. Among them, _f0
is a known constant at compile time, _f1
and _f2
are invariables, and _f3
is an ordinary state variable. The value of _f1
is determined at compile time, and the value of _f2
is calculated through the parameters passed to the constructor when the contract is created. The value of _f3
is initialized when the contract is created. In addition to the constructor, the contract also defines 4 external methods. To facilitate the observation of the compiled bytecode of the contract, we used some special constants in these methods and performed simple calculations. In addition, the logic of these methods is very simple.
Compile the above contract using solc (v0.6.12), and we can get the contract bytecode. The complete contract bytecode is also called the creation bytecode of the contract. It is mainly divided into two parts. The first half is the bytecode executed when the contract is deployed, and the second half is the runtime bytecode of the contract. The constructor of the contract will be compiled to the first half and executed when the contract is deployed for such operations as contract state initialization. After the bytecode is created, the runtime bytecode of the contract will be returned.
Note that for the contract creation bytecode, the runtime bytecode is just normal data. The contract creation bytecode can return the runtime bytecode intact (directly on the chain), or it can be modified arbitrarily and then returned (for example, by injecting the actual value of the invariable. See below for details). For more information on the contract bytecode, please refer to this article, and for more information on contract deployment, please refer to this article.
The compiled bytecode (creation bytecode) of our sample contract totals 673 (0x2A1) bytes, as shown below:
0x60c060405261567860809081525034801561001957600080fd5b506040516102a13803806102a18339818101604052602081101561003c57600080fd5b810190808051906020019092919050505061abcd811660a0818152505061efef6000819055505060805160a05161021b610086600039806101a8525080610176525061021b6000f3fe608060405234801561001057600080fd5b506004361061004c5760003560e01c8063091ecb4e146100515780632183283914610093578063648dfa46146100d55780637217d1a014610117575b600080fd5b61007d6004803603602081101561006757600080fd5b8101908080359060200190929190505050610159565b6040518082815260200191505060405180910390f35b6100bf600480360360208110156100a957600080fd5b810190808035906020019092919050505061016d565b6040518082815260200191505060405180910390f35b610101600480360360208110156100eb57600080fd5b810190808035906020019092919050505061019f565b6040518082815260200191505060405180910390f35b6101436004803603602081101561012d57600080fd5b81019080803590602001909291905050506101d1565b6040518082815260200191505060405180910390f35b600063ffffff006112348301169050919050565b600063ffffff017f00000000000000000000000000000000000000000000000000000000000000008301169050919050565b600063ffffff027f00000000000000000000000000000000000000000000000000000000000000008301169050919050565b600063ffffff03600054830116905091905056fea2646970667358221220f2e74694c1ee966e6985b7d31032343ce592dc88a10f9359fa6a42fdbeff1cbe64736f6c634300060c0033
The runtime code of the contract starts from the 134th (0x86) byte and totals 539 (0x21B) bytes, as shown below:
0x608060405234801561001057600080fd5b506004361061004c5760003560e01c8063091ecb4e146100515780632183283914610093578063648dfa46146100d55780637217d1a014610117575b600080fd5b61007d6004803603602081101561006757600080fd5b8101908080359060200190929190505050610159565b6040518082815260200191505060405180910390f35b6100bf600480360360208110156100a957600080fd5b810190808035906020019092919050505061016d565b6040518082815260200191505060405180910390f35b610101600480360360208110156100eb57600080fd5b810190808035906020019092919050505061019f565b6040518082815260200191505060405180910390f35b6101436004803603602081101561012d57600080fd5b81019080803590602001909291905050506101d1565b6040518082815260200191505060405180910390f35b600063ffffff006112348301169050919050565b600063ffffff017f00000000000000000000000000000000000000000000000000000000000000008301169050919050565b600063ffffff027f00000000000000000000000000000000000000000000000000000000000000008301169050919050565b600063ffffff03600054830116905091905056fea2646970667358221220f2e74694c1ee966e6985b7d31032343ce592dc88a10f9359fa6a42fdbeff1cbe64736f6c634300060c0033
Below we will carefully analyze these two bytecodes to see how the invariables work.
Gas consumption of different configurations
Let's first look at the runtime bytecode of the contract. For the convenience of observation, the assembly code of the contract will be shown below. Long as the assembly code of the contract is, we can be sure that the logic of the constructor is not in the runtime bytecode according to the special constant value in the source code, and thus can easily find the assembly code of the four external methods. Most of the assembly code is omitted here, but only the important parts of the 4 external methods:
...
[345] JUMPDEST
[346] PUSH1 0x00
[348] PUSH4 0xffffff00
[353] PUSH2 0x1234
[356] DUP4
[357] ADD
[358] AND
...
[365] JUMPDEST
[366] PUSH1 0x00
[368] PUSH4 0xffffff01
[373] PUSH32 0x0000000000000000000000000000000000000000000000000000000000000000
[406] DUP4
[407] ADD
[408] AND
...
[415] JUMPDEST
[416] PUSH1 0x00
[418] PUSH4 0xffffff02
[423] PUSH32 0x0000000000000000000000000000000000000000000000000000000000000000
[456] DUP4
[457] ADD
[458] AND
...
[465] JUMPDEST
[466] PUSH1 0x00
[468] PUSH4 0xffffff03
[473] PUSH1 0x00
[475] SLOAD
[476] DUP4
[477] ADD
[478] AND
...
Obviously,
- PC (Programm Counter) is the assembly code of the
addF0()
function starting from 345, and the compile-time constants are compiled into PUSH2 instructions (PC is 353). - The PC from 365 is the assembly code of the
addF1()
function. The invariable_f1
is compiled into PUSH32 instructions (PC is 373), and the immediate value is 0. - The PC from 415 is the assembly code of the
addF2()
function. The invariable_f2
is also compiled into PUSH32 instructions (PC is 423), and the immediate value is 0. - PC from 465 is the assembly code of the
addF3()
function, and ordinary state variables are compiled into SLOAD instruction (PC is 475).
It can be seen that in the runtime bytecode of the contract, the invariable is compiled into a PUSH instruction (the specific instruction depends on the number of bytes occupied by the invariable). But the immediate value of the instruction, which is 0, only serves as a placeholder. The creation bytecode of the contract must properly handle the immediate value of the PUSH instruction corresponding to each invariant, and replace it with the actual value, which will be further analyzed below.
Like constants, invariables are compiled into PUSH instructions, so they both consume the same gas, much lower than the gas consumed by ordinary state variables (SLOAD consumes 800 gas).
Implementation of immutable variables: Modify bytecode on the chain
Compared to the runtime bytecode of the contract, creation bytecode is a bit more complicated, so we need a form that facilitates observation. With the online disassembler provided by https://www.trustlook.com/services/smart.html, we can disassemble the bytecode into a relatively easy-to-understand pattern. The following is the disassembly result of the contract creation bytecode:
contract disassembler {
function main() public return () {
mstore(0x40,0xC0);
mstore(0x80,0x5678);
var0 = msg.value;
require(!msg.value);
var1 = (code.size - 0x2A1);
callcodecopy(0xC0,0x2A1,(code.size - 0x2A1));
mstore(0x40,((code.size - 0x2A1) + 0xC0));
require((0x20 < (code.size - 0x2A1)));
temp0 = (0xC0 + var1);
temp1 = mload(0xC0);
mstore(0xA0,(temp1 & 0xABCD));
sstore(0x0,0xEFEF);
temp3 = mload(0x80);
temp2 = mload(0xA0);
callcodecopy(0x0,0x86,0x21B);
mstore(0x1A8,temp2);
mstore(0x176,temp3);
RETURN(0x0,0x21B);
}
}
Compared with the bytecode and assembly code, it is already an improvement. Still hard to understand? Don't worry. We will add comments to the disassembly code above, remove the irrelevant code, rename some variables, and explain each line of code in detail.
function main() public return () {
mstore(0x40,0xC0); // m[0x40] = 0xC0
mstore(0x80,0x5678); // m[0x80] = 0x5678
require(!msg.value);
Line 1 above: The compiler reserves 0xC0
bytes of memory, and records this number in memory 0x40
. Line 2: Record 0x5678
in memory 0x80
. Line 3 has nothing to do with the discussion in this article and can be ignored.
argsLen = (code.size - 0x2A1);
callcodecopy(0xC0,0x2A1,argsLen); // m[0xC0] = args
mstore(0x40,(argsLen + 0xC0)); // m[0x40] = 0xC0 + argsLen
require((0x20 < argsLen));
Line 1: When the contract is deployed, the parameters passed to the constructor (after ABI encoding) will be spliced behind the bytecode. We can obtain the total length of the code after adding the parameter with the CODESIZE
instruction, and, after subtracting the real code length 0x2A1
, get the length of the parameter. Line 2: Copy the incoming parameters to 0xC0
in the memory. Line 3: Update the number of memory usage. Line 4: Ensure the length of the incoming parameter is sufficient.
f2 = mload(0xC0); // f2 = m[0xC0]
mstore(0xA0,(f2 & 0xABCD)); // m[0xA0] = f2 & 0xABCD
sstore(0x0,0xEFEF); // _f3 = 0xEFEF
Line 1: Load the parameter f2
that has been loaded into the memory onto the operand stack. Line 2: Perform a bitwise AND operation between f2
and 0xABCD
, and then record it in memory 0xA0
. Line 3: Initialize the state variable_f3
.
_f1 = mload(0x80); // _f1 = m[0x80] = 0x5678
_f2 = mload(0xA0); // _f2 = m[0xA0] = f2 & 0xABCD
callcodecopy(0x0,0x86,0x21B); // m[0x00:] = code[0x86: 0x86+0x21B]
mstore(0x1A8,_f2); // m[0x01A8] = _f2
mstore(0x176,_f1); // m[0x0176] = _f1
RETURN(0x0,0x21B);
}
The first and second lines above load the values at memory 0x80
and 0xA0
onto the operand stack. These two temporary variables record the actual values of _f1
and _f2
. Then the value previously placed in memory is completely useless, so on the third line all the runtime bytecodes are copied to memory 0. On Lines 4 and 5, the immediate placeholders of PUSH instructions of two invariables in the runtime bytecode (located at 0x0176
and 0x01A8
in the runtime bytecode, respectively) are replaced with actual values. At this point, the runtime bytecode is ready, and then returns on Line 6 through the RETURN instruction. The following is the complete disassembly code after being adjusted with comments:
contract disassembler {
function main() public return () {
mstore(0x40,0xC0); // m[0x40] = 0xC0
mstore(0x80,0x5678); // m[0x80] = 0x5678
require(!msg.value);
argsLen = (code.size - 0x2A1);
callcodecopy(0xC0,0x2A1,argsLen); // m[0xC0] = args
mstore(0x40,(argsLen + 0xC0)); // m[0x40] = 0xC0 + argsLen
require((0x20 < argsLen));
f2 = mload(0xC0); // f2 = m[0xC0]
mstore(0xA0,(f2 & 0xABCD)); // m[0xA0] = f2 & 0xABCD
sstore(0x0,0xEFEF); // _f3 = 0xEFEF
_f1 = mload(0x80); // _f1 = m[0x80] = 0x5678
_f2 = mload(0xA0); // _f2 = m[0xA0] = f2 & 0xABCD
callcodecopy(0x0,0x86,0x21B); // m[0x00:] = code[0x86: 0x86+0x21B]
mstore(0x1A8,_f2); // m[0x01A8] = _f2
mstore(0x176,_f1); // m[0x0176] = _f1
RETURN(0x0,0x21B);
}
}
Combining proxy mode and static configuration
As mentioned earlier, the value of the invariable is determined when the contract is constructed and injected into the runtime bytecode of the contract, so the gas consumed by the invariable reading during the contract runtime is the same as that consumed by the constant. OneSwap mainly includes 7 contracts, most of which use invariables. Take OneSwapPairProxy
as an example:
contract OneSwapPairProxy {
uint internal _unlocked;
uint internal immutable _immuFactory;
uint internal immutable _immuMoneyToken;
uint internal immutable _immuStockToken;
uint internal immutable _immuOnes;
uint internal immutable _immuOther;
constructor(address stockToken, address moneyToken, bool isOnlySwap, uint64 stockUnit, uint64 priceMul, uint64 priceDiv, address ones) public {
_immuFactory = uint(msg.sender);
_immuMoneyToken = uint(moneyToken);
_immuStockToken = uint(stockToken);
_immuOnes = uint(ones);
uint temp = isOnlySwap ? 1 : 0;
temp = (temp<<64) | stockUnit;
temp = (temp<<64) | priceMul;
temp = (temp<<64) | priceDiv;
_immuOther = temp;
_unlocked = 1;
}
receive() external payable { }
fallback() payable external { /* code omitted */ }
}
Similar to Uniswap, OneSwap's Pair contract is created by the Factory contract. The difference is that OneSwap adopts a proxy model in order to minimize the gas consumption when a Pair is created. Considering the complex logic of Pair and the gas-consuming deployment, OneSwap puts this part of the logic in the OneSwapPair
contract which needs to be deployed only once. The Factory deploys the OneSwapPairProxy
contract each time, which forwards all actual operations to OneSwapPair
for processing. With the proxy mode, OneSwap also supports the upgrade of the Pair logic. In addition, to benefit from both the invariable and proxy mode at the same time, the OneSwap project has also explored the "Immutable Forwading" mode, which we will specifically introduce in a follow-up article.
Dynamic configuration of the contract
Constants must be valued when the contract is compiled, and invariables must be valued when the contract is constructed. That is static configuration. Common state variables can be modified after contract deployment, which is dynamic configuration. It is advised to limit the modification of important configurations to privileged accounts or governance contracts only. For example, the maintenance of the mainstream token list of the BuyBack contract follows this mode, and the list can only be configured by the ONES issuer:
contract OneSwapBuyback is IOneSwapBuyback {
mapping (address => bool) private _mainTokens;
address[] private _mainTokenArr;
... // Other codes omitted
function addMainToken(address token) external override {
require(msg.sender == IOneSwapToken(ones).owner());
if (!_mainTokens[token]) {
_mainTokens[token] = true;
_mainTokenArr.push(token);
}
}
function removeMainToken(address token) external override {
require(msg.sender == IOneSwapToken(ones).owner());
if (_mainTokens[token]) {
_mainTokens[token] = false;
uint256 lastIdx = _mainTokenArr.length - 1;
for (uint256 i = 0; i < lastIdx; i++) {
if (_mainTokenArr[i] == token) {
_mainTokenArr[i] = _mainTokenArr[lastIdx];
break;
}
}
_mainTokenArr.pop();
}
}
}
The feeBPS
and pairLogic
variables of the OneSwapFactory contract are configured through the OneSwapGov contract. The feeBPS
variable is read by the OneSwapPair contract to control the fee rate. The pairLogic
variable is read by the OneSwapProxy contract. This variable and dynamic configuration is a major contributor to the negligible gas consumed by the Pair deployment and upgradeable Pair logic of the OneSwap project.
Conclusion
This article summarizes the two configuration methods of smart contracts: static configuration and dynamic configuration, and introduces several protection modes of dynamic configuration: privilege modification mode and governance mode. Static configuration can be realized through constants and invariables, and dynamic configuration through ordinary state variables. It also goes deep into the implementation of invariables at the EVM bytecode level.
Before Solidity v0.6.5, the constant value that could be determined when the contract was constructed could only be expressed by the state variable of the contract. Writing state variables during contract creation requires considerable Gas, and reading state variables during contract execution also consumes a lot of Gas (compared to PUSH instructions of the constant). The immutable state variable introduced in Solidity v0.6.5 effectively solves this problem by modifying the contract runtime bytecode (replacing placeholders) when the contract is deployed. In OneSwap, we used immutable state variables as much as possible, and also explored the "Immutable Forwading" mode, which is one of the reasons for the low Gas consumption in OneSwap. In subsequent articles, we will introduce more details on the implementation of the OneSwap project.