OneSwap Series 12 - Application of Common Solidity Patterns in OneSwap

in oneswap •  4 years ago 

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:

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 functionLow-level instructionWhether to provide exception informationWhether to refund the remaining gas
assert(condition)0xFE(Invalid)NoNo
require(condition, msg?)0xFD(REVERT)YesYes
revert(msg?)0xFD(REVERT)YesYes

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 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-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 UsageAmount of Gas ForwardedException 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:

  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 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:

  1. 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.
  2. 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.
  3. 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.

Authors get paid when people like you upvote their post.
If you enjoyed what you read here, create your account today and start earning FREE STEEM!