The Solidity language is easy to learn, and so is to write Ethereum smart contracts using Solidity. But it is very difficult to write smart contracts that are completely free of security risks. To help Solidity programmers write more robust smart contracts, Franz Volland summarizes [14 commonly used Solidity patterns](https://fravoll.github.io/solidity- patterns/). The OneSwap project has fully drawn on these design patterns in its development, and also created several new patterns. This article will introduce some of these patterns summarized by Franz Volland and their specific applications in OneSwap. The following is a list of these 14 patterns:
- Behavioral Patterns
- Security Patterns
- Upgradeability Patterns
- Economic Patterns
The OneSwap project does not use all the patterns. The following only introduces those directly used in OneSwap.
Behavioral Patterns
Guard Check
When writing a contract, we should apply the Guard Check pattern to check various parameters such as user input parameters, return values of external contracts, overflow of various calculations, internal states and invariants of the contract. Once the check fails, the entire transaction is reverted. The Solidity language provides three built-in functions to help us with these checks: assert()
, require()
, and revert()
. For detailed usage of these three functions, you can refer to Solidity Documentation or previous articles of this series. This article will not introduce them in detail. The following table summarizes these three exception propagation functions:
Exception propagation function | Low-level instruction | Whether to provide exception information | Whether to refund the remaining gas |
---|---|---|---|
assert(condition) | 0xFE (Invalid) | No | No |
require(condition, msg?) | 0xFD (REVERT) | Yes | Yes |
revert(msg?) | 0xFD (REVERT) | Yes | Yes |
The OneSwap project uses the require()
function extensively for checking and the assert()
function in a few places. These checks can be seen everywhere. The following is an example of the changeOwner()
function of theOneSwapBlackList
abstract contract to show the usage of require()
function:
function changeOwner(address ownerToSet) public override onlyOwner {
require(ownerToSet != address(0), "OneSwapToken: INVALID_OWNER_ADDRESS");
require(ownerToSet != _owner, "OneSwapToken: NEW_OWNER_IS_THE_SAME_AS_CURRENT_OWNER");
require(ownerToSet != _newOwner, "OneSwapToken: NEW_OWNER_IS_THE_SAME_AS_CURRENT_NEW_OWNER");
_newOwner = ownerToSet;
}
Security Patterns
Access Restriction
OneSwap is mainly composed of 7 contracts, 4 of which apply the Access Restriction pattern to restrict the privileged operations of the contract:
OneSwapBlackList
abstract contract: Only the contract owner can manage the blacklist or transfer the ownership.OneSwapFactory
contract: Only theOneSwapGov
contract can set itsfeeTo
andfeeBPS
fields.OneSwapGov
contract: Only the owner of ONES (OneSwap governance token) can submit non-text proposals.OneSwapBuyback
contract: Only the owner of ONES can manage the list of mainstream tokens.
If there are only a few privileged operations (for example, one or two functions), then just using the require()
function described earlier to check is fine. If there are many privileged operations, it is more convenient to use the function-modifier feature provided by the Solidity language. Here, we take the OneSwapBlackList
abstract contract for another example. Below is the definition of the onlyOwner()
modifier:
modifier onlyOwner() {
require(msg.sender == _owner, "OneSwapToken: MSG_SENDER_IS_NOT_OWNER");
_;
}
changeOwner()
, addBlackLists()
, and removeBlackLists()
functions must be operated by the owner, so just add the modifier defined above. Take the addBlackLists()
function as an example:
function addBlackLists(address[] calldata _evilUser) public override onlyOwner {
for (uint i = 0; i < _evilUser.length; i++) {
_isBlackListed[_evilUser[i]] = true;
}
emit AddedBlackLists(_evilUser);
}
Checks Effects Interactions
We all know that reentrancy attack is one of the most horrible threats to smart contracts. At present, it has caused serious losses to many well-known projects with vulnerabilities in the code. Applying the Checks-Effects-Interactions pattern, we can protect our smart contracts from this attack. Simply put, when you need to interact with an untrusted external contract, you need to take three steps: first, check the states, then update the states, and finally call external contracts to interact. Let's look at the unlock()
function of the LockSend
contract in OneSwap. The following is the code of the function:
function unlock(address from, address to, address token, uint32 unlockTime) public override afterUnlockTime(unlockTime) {
bytes32 key = _getLockedSendKey(from, to, token, unlockTime);
uint amount = lockSendInfos[key];
require(amount != 0, "LockSend: UNLOCK_AMOUNT_SHOULD_BE_NONZERO");
delete lockSendInfos[key];
_safeTransfer(token, to, amount);
emit Unlock(from, to, token, amount, unlockTime);
}
First, the afterUnlockTime
modifier and the require()
function ensure that the unlock time and amount are valid. Then, modify the state (delete the entire locked transfer information). Finally, call the _safeTransfer()
function to transfer ERC20 tokens.
Secure Ether Transfer
If you want to transfer Ether to Address A in Contract B, you can call one of the three built-in functions: send()
, transfer()
, and call()
. In the case of the first two functions, the address must be payable, and there is no such restriction in the third. Solidity programmers must know the usage, implementation, and advantages and disadvantages of these three transfer methods, so that we can choose the best approach for Ether transfer in specific scenarios. The following table summarizes the usage of these three transfer methods, the number of gas forwarded, and exception propagation:
Function Usage | Amount of Gas Forwarded | Exception Propagation |
---|---|---|
payableAddr.send(amt) | 2300 (not adjustable) | returns false on failure |
payableAddr.transfer(amt) | 2300 (not adjustable) | reverts on failure |
addr.call{value: amt, gas: gasLimit}(payload) | adjustable (all remaining gas by default) | returns success condition and return data |
To thoroughly understand the differences between these three transfer methods, let's write a simple smart contract to see how these three built-in functions are implemented:
pragma solidity =0.6.12;
contract TransferDemo {
function testSend(address payable addr) external {
addr.send(0x1234);
}
function testTransfer(address payable addr) external {
addr.transfer(0x5678);
}
function testCall(address addr) external {
addr.call{value: 0xABCD}("");
}
}
Compile the above contract, and then disassemble the generated contract runtime bytecode. For clarity, only the disassembled results of the three key test functions are given below:
function testSend( uint256 arg0) public return () {
var7 = uint160(arg0).call.gas(((0x1234 == 0) * 0x8FC)).value(0x1234)(0x80, 0x0);
return();
}
function testTransfer( address arg0) public return () {
var7 = uint160(arg0).call.gas(((0x5678 == 0) * 0x8FC)).value(0x5678)(0x80, 0x0);
if (var7) {
return();
} else {
returndatacopy(0x0, 0x0, returndatasize);
revert(0x0, returndatasize);
}
}
function testCall( uint256 arg0) public return () {
var7 = uint160(arg0).call.gas(0xEFFF).value(0xABCD)(0x80, 0x0);
if ((returndatasize == 0x0)) {
return();
} else {
mstore(0x40, (0x80 + ((returndatasize + 0x3F) & ~0x1F)));
mstore(0x80, returndatasize);
returndatacopy(0xA0, 0x0, returndatasize);
return();
}
}
We can see that these three transfer methods are all implemented using the CALL
instruction provided by EVM. For the send()
and transfer()
functions, the compiler fixedly forwards 2300 (0x9FC) gas for us, and the call()
function allows us to specify the amount of gas to be forwarded. For the transfer function, the compiler helps us check the return value and automatically call the revert()
function. The other two functions require us to check and process the return value on our own.
Note that because Ethereum has been adjusting the gas consumption of some EVM instructions (for example, EIP-1884 increases the gas consumption of the SLOAD
instruction from 200 gas to 800 gas), at present 2,300 gas is already insufficient. Following the suggestions of this article, only the call()
function is used in the OneSwap project to transfer Ether and control the amount of gas forwarded when necessary. Taking the OneSwapRouter
contract as an example, the Ether transfer logic is encapsulated in the _safeTransferETH()
function. The code is as follows:
function _safeTransferETH(address to, uint value) internal {
(bool success,) = to.call{value:value}(new bytes(0));
require(success, "TransferHelper: ETH_TRANSFER_FAILED");
}
Pull over Push
This pattern was designed for the transfer of Ether, but it can also apply to ERC20 tokens. For example, the OneSwapGov
contract of the OneSwap project requires proposal initiators and voters to deposit a certain amount of OneSwap governance token ONES. If the proposal needs to refund these ONES to them immediately after the ballots are counted, it is likely that the counting will fail due to too many voters (because it takes a lot of gas to refund the ONES). The Pull over Push pattern is a fast answer for this problem: After the ballouts of the proposals are counted, depositors need to retrieve the ONES by themselves. In addition, under this pattern, the deposit information only needs to be stored by mapping. There is no need to consider iteration, and the code is simplified. Given below is the code of the withdrawOnes()
function of the OneSwapGov
contract:
function withdrawOnes(uint112 amt) external override {
VoterInfo memory voter = _voters[msg.sender];
require(_proposalType == 0 || voter.votedProposal < _proposalID, "OneSwapGov: IN_VOTING");
require(amt > 0 && amt <= voter.depositedAmt, "OneSwapGov: INVALID_WITHDRAW_AMOUNT");
_totalDeposit -= amt;
voter.depositedAmt -= amt;
if (voter.depositedAmt == 0) {
delete _voters[msg.sender];
} else {
_voters[msg.sender] = voter;
}
IERC20(ones).transfer(msg.sender, amt);
}
Upgradeability Patterns
Proxy Delegate
We can solve any problem by introducing an extra level of indirection.
The core logic of the OneSwap project is in the OneSwapPair
contract which is complicated and thus gives rise to two problems. First, the contract code is complex, so the bytecode after compilation is large, and creating a contract consumes more gas. Since OneSwap needs to deploy a separate Pair contract for each trading pair through the OneSwapFactory
contract, creating a trading pair could cost a lot. Second, the more complex the contract code, the greater the possibility of bugs. If a bug is found after OneSwapPair
is deployed, it needs to be able to upgrade. The proxy pattern (introducing the middle level) solves both of these problems. The following is the relationship between the three contracts of OneSwapFactory
, OneSwapPairProxy
, and OneSwapPair
:
+----------------+ create +------------------+ forward to +-------------+
| OneSwapFactory | -------> | OneSwapPairProxy | -----------> | OneSwapPair |
+----------------+ +------------------+ +-------------+
Note that the real pair logic is in theOneSwapPair
contract, and theOneSwapPairProxy
contract is only responsible for forwarding. TheOneSwapPair
contract only need to be deployed once, making the higher gas consumption tolerable. OneSwapPairProxy
is only responsible for forwarding, thus simplifying the logic and reducing the deployment cost. And once a bug is found in the OneSwapPair
contract, just fix the problem, redeploy a new version of the Pair contract, and call the setPairLogic()
function of the Factory contract to update the pair logic. The complete code of the OneSwapPairProxy
contract is shown as below:
contract OneSwapPairProxy {
uint[10] internal _unusedVars;
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 { }
// solhint-disable-next-line no-complex-fallback
fallback() payable external {
uint factory = _immuFactory;
uint moneyToken = _immuMoneyToken;
uint stockToken = _immuStockToken;
uint ones = _immuOnes;
uint other = _immuOther;
address impl = IOneSwapFactory(address(_immuFactory)).pairLogic();
// solhint-disable-next-line no-inline-assembly
assembly {
let ptr := mload(0x40)
let size := calldatasize()
calldatacopy(ptr, 0, size)
let end := add(ptr, size)
// append immutable variables to the end of calldata
mstore(end, factory)
end := add(end, 32)
mstore(end, moneyToken)
end := add(end, 32)
mstore(end, stockToken)
end := add(end, 32)
mstore(end, ones)
end := add(end, 32)
mstore(end, other)
size := add(size, 160)
let result := delegatecall(gas(), impl, ptr, size, 0, 0)
size := returndatasize()
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
}
}
It is worth noting that in order to minimize the overall gas consumption, OneSwap extensively uses immutable state variables. If the Pair contract uses storage variables to replace immutable variables just to apply the proxy pattern, more gas will be consumed. To have both the proxy pattern and immutable state variables, OneSwap pioneered the "Immutable Forwarding" pattern, which in part explains the complexity of the fallback()
function as described above. We introduced the principle of immutable state variables in previous articles and in the next article, we will describe the implementation details of the "Immutable Forwarding" pattern.
Economic Patterns
Tight Variable Packing
We have discussed the implementation principle of state variables in Solidity contracts in previous articles, and, according to the articles, we know:
- The state variables of the contract are stored in Storage, and the reading and writing of Storage (
SLOAD
andSSTORE
instructions) is very gas-consuming. - The Solidity compiler will try to encapsulate adjacent state variables into one slot (256 bits), but will not rearrange state variables.
- Contract programmers need to carefully arrange the order of state variables to help Solidity optimize storage.
Every Solidity programmer needs to think in the Tight Variable Packing pattern. The OneSwap project has done everything possible to minimize gas consumption, and even optimizes gas consumption manually. Take the OneSwapPair
contract as an example. The entire order information is encapsulated into a uint256 integer:
contract OneSwapPair is OneSwapPool, IOneSwapPair {
// the orderbooks. Gas is saved when using array to store them instead of mapping
uint[1<<22] private _sellOrders;
uint[1<<22] private _buyOrders;
... // Other code ommitted
}
Except the above example, there are many other examples of such usage in the OneSwap project to optimize storage.
Memory Array Building
We have mentioned it many times: Reading and writing Storage is very gas-consuming, so such operations need to be eliminated. By applying the Memory Array Building pattern, we can aggregate and obtain the contract status from the chain in an economical (0 gas consumed) way. In short, this pattern uses the following techniques:
- Choose an iterable data structure to store data, such as an array. For more information about the Solidity data structures, please refer to other articles in this series.
- Define a function marked with the
view
modifier to read data. Since theview
function is read-only and does not modify any state, it consumes no gas at all. - Construct the data to be returned in memory.
Using such techniques, theOneSwapPair
contract of the OneSwap project returns order book data, with the logic in the getOrderList()
function. However, considering the large size of the order book, this function also supports paging, and the range can be specified by the fromId
and maxCount
parameters. Here is the code of the getOrderList()
function:
// Get the orderbook's content, starting from id, to get no more than maxCount orders
function getOrderList(bool isBuy, uint32 id, uint32 maxCount) external override view returns (uint[] memory) {
if(id == 0) {
id = isBuy ? uint32(_bookedStockAndMoneyAndFirstBuyID>>224)
: uint32(_reserveStockAndMoneyAndFirstSellID>>224);
}
uint[1<<22] storage orderbook;
orderbook = isBuy ? _buyOrders : _sellOrders;
//record block height at the first entry
uint order = (block.number<<24) | id;
uint addrOrig; // start of returned data
uint addrLen; // the slice's length is written at this address
uint addrStart; // the address of the first entry of returned slice
uint addrEnd; // ending address to write the next order
uint count = 0; // the slice's length
// solhint-disable-next-line no-inline-assembly
assembly {
addrOrig := mload(0x40) // There is a “free memory pointer” at address 0x40 in memory
mstore(addrOrig, 32) //the meaningful data start after offset 32
}
addrLen = addrOrig + 32;
addrStart = addrLen + 32;
addrEnd = addrStart;
while(count < maxCount) {
// solhint-disable-next-line no-inline-assembly
assembly {
mstore(addrEnd, order) //write the order
}
addrEnd += 32;
count++;
if(id == 0) {break;}
order = orderbook[id];
require(order!=0, "OneSwap: INCONSISTENT_BOOK");
id = uint32(order&_MAX_ID);
}
// solhint-disable-next-line no-inline-assembly
assembly {
mstore(addrLen, count) // record the returned slice's length
let byteCount := sub(addrEnd, addrOrig)
return(addrOrig, byteCount)
}
}
Summary
It is easy to write smart contracts, but not to write one that is both "gas-efficient" and "bug-free". The 14 Solidity design patterns summarized by Franz Volland can help us write better Ethereum smart contracts. OneSwap has benefited a lot by directly applying 8 of these patterns. What's more, it also created several new design patterns based on its functionality and the latest features of Solidity.