OneSwap Series 12 - Application of Common Solidity Patterns in OneSwap

OneSwap
11 min readNov 6, 2020

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 Vollandsummarizes 14 commonly used 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:

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 theOneSwapBlackListabstract 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 the OneSwapGov contract can set its feeTo and feeBPS 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-modifierfeature provided by the Solidity language. Here, we take the OneSwapBlackListabstract 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:

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 disassemblethe 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 CALLinstruction 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-1884increases the gas consumption of the SLOADinstruction 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 OneSwapRoutercontract 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 OneSwapGovcontract 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 OneSwapGovcontract:

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 OneSwapPaircontract 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 OneSwapFactorycontract, 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 OneSwapPairis 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 theOneSwapPaircontract, and theOneSwapPairProxycontract is only responsible for forwarding. TheOneSwapPaircontract only need to be deployed once, making the higher gas consumption tolerable. OneSwapPairProxyis only responsible for forwarding, thus simplifying the logic and reducing the deployment cost. And once a bug is found in the OneSwapPaircontract, 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 OneSwapPairProxycontract 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:

  1. The state variables of the contract are stored in Storage, and the reading and writing of Storage (SLOAD and SSTORE instructions) is very gas-consuming.
  2. The Solidity compiler will try to encapsulate adjacent state variables into one slot (256 bits), but will not rearrange state variables.
  3. 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 OneSwapPaircontract 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 the view 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, theOneSwapPaircontract 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 fromIdand maxCountparameters. 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.

--

--

OneSwap

A fully decentralized exchange protocol on Smart Contract, with permission-free token listing and automated market making.