OneSwap Series 12 - Application of Common Solidity Patterns in OneSwap

Image for post
Image for post

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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.

Written by

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

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store