DEV Community

Francisldn
Francisldn

Posted on • Updated on

5 Tips & Tricks for DeFi Developers from the UniswapV2 Contracts Review

Uniswap announced their V2 launch on Ethereum mainnet on 18 May 2020, followed by the V3 launch on 5 May 2021. With a total value locked (TVL)of USD 7.4 billion (as of 21 Jan 2022), Uniswap V2 accounted for slightly more than half of the total Uniswap TVL (USD 3.6 billion), with an average daily trading volume of around USD 500 million. Clearly, Uniswap V2 remains a highly popular decentralised exchange (DEX) among DeFi users. 

Having recently reviewed the Uniswap V2 contracts, I found the contracts full of gems which are worth sharing with fellow DeFi smart contract developers. 

Brief Overview of Contracts

Uniswap V2 contracts are organised into V2 Core and V2 Periphery. V2 Core consists of the essential V2 smart contracts, including UniswapV2Pair, UniswapV2Factory and UniswapV2ERC20. V2 Periphery consists of a set of helper contracts or functions which include UniswapV2Migrator, UniswapV2Router and a number of libraries.

UniswapV2 Core

  • UniswapV2Pair - responsible for the maintenance of liquidity pool and token swapping functionality. 
  • UniswapV2Factory -  responsible for the creation of token pair liquidity pools and the setting of liquidity provider fee. 
  • UniswapV2ERC20 -  implements the ERC20 token interface but with an additional feature, this is something which I will explore a bit more later. 

UniswapV2 Periphery

  • UniswapV2Router01 and 02 - acts as a wrapper for V2Pair, handling liquidity addition and removal operations and token swapping functionalities. 
  • UniswapV2Migrator - provides liquidity migration service from Uniswap V1 to V2.

Review of Source Code

Below are some of the interesting tips and tricks implemented in UniswapV2.

1. ERC-20-Permit - a gas-efficient version of ERC-20

With a standard ERC-20 token, a tokenholder needs to execute approve transaction prior to executing transferFrom. The approve function must be performed by an Externally-Owned Wallet (EOA) and this is meant to protect user's wallet from being drained by a malicious contract. But this also leads to degradation of user experience, as it means that a user would need to execute 2 transactions: approve and transferFrom to transfer a token, which is not gas-efficient. So can we implement a more gas-efficient version of ERC20 token while retain the security for user's funds?

Solution: With the addition of permit function, Uniswap implements offline signature for the approval of token transfer, which is gas-less. The signature can then be used for on-chain execution of transferFrom function (or any other functions). The permit function is also used in the V2 Router contract for user to withdraw liquidity from a liquidity pool (eg.removeLiquidityWithPermit function). 

To prevent replay attack using the same signature multiple times (given it is done off-chain), a valid signature would consist of a few safeguards such as a clear definition of scope for the signature as defined in DOMAIN_SEPARATOR and PERMIT_TYPEHASH with non-reusable nonce which increments with every signature. The signature uses EIP-712 standard for signing and the ERC-20 implementation of permit extension follows EIP-2612 standard.

Smart contract developers who are interested in implementing or learning more about ERC-20-Permit may refer to the resources below:

2. CREATE2 - creation of pair addresses

There are 2 ways to create a new contract on EVM-compatible chain - CREATE and CREATE2. In the UniswapV2Pair contract, CREATE2 opcode (in assembly) is used to create a token pair liquidity pool address, which is a change from the V1 contract (where CREATE is used). 

CREATE opcode is used to compute deployed contract address like so:

keccak256(senderAddress, nonce)
Enter fullscreen mode Exit fullscreen mode

In Solidity, one can also create a new contract through new keyword, as below. Instantiating a new contract in this way uses the CREATE opcode, and contract address cannot be pre-determined in a deterministic manner (due to the use of nonce).

pair = new UniswapV2Pair()
Enter fullscreen mode Exit fullscreen mode

CREATE2 opcode computes pre-determined contract address like so:

keccak256(0xFF, senderAddress, salt, bytecode)
Enter fullscreen mode Exit fullscreen mode
  • 0xFF is a constant to prevent collision with the CREATE function
  • senderAddress - sender's own address
  • salt - an arbitrary value provided by the sender
  • bytecode - contract's bytecode to be deployed

The advantage of CREATE2 vs CREATE is that with CREATE2, contract address can be pre-computed off-chain (therefore gas-less) and one can then send ETH to the pre-computed contract address even before the contract is deployed on-chain. One such use case is that a developer may distribute a pre-computed contract address to a user in advance without having to deploy the contract, and the contract can then be initialised when a user starts sending funds to it, thus allowing for gas fee saving.

To learn more about deploying contract using CREATE2, below are some resources for your reference:

3. Time-Weighted Average Price update

In the UniswapV2Pair contract, token prices are updated based on time-weighted average prices (TWAP) mechanism. The way it works is that each transacted price is weighted by the time that has passed since the last block in which it was updated and the historical prices are then accumulated. This increases the difficulty and reduces the incentive to manipulate prices on Uniswap, especially for tokens with high trading volume. This is an improvement from Uniswap V1. The diagram below gives a good overview of the TWAP price mechanism (courtesy of Uniswap).
Image description

Given the improvement in price update mechanism in V2, which is more resistant to price manipulation, Uniswap V2 can therefore serve as an on-chain price oracle for other DApps. Uniswap has actually created the UniswapV2OracleLibrary exactly for such purpose.

Implications
As the token prices are not updated based on the actual reserve balance in the liquidity pool, there will exist discrepancies between the token balance calculated based on TWAP mechanism and the actual reserve balance. There are also tokens with deflationary or inflationary implementations which could lead to discrepancies in token balances. Tokens with balances greater than 2112 will also lead to overflow of the storage slots for reserves, which could lead to failed trades. For all the cases mentioned above (not exhaustive), users can withdraw excess token balances or reserves through skim and sync functions.

4. Fixed Point Binary Library (UQ112x112.sol)

library UQ112x112 {
    uint224 constant Q112 = 2**112;

    // encode a uint112 as a UQ112x112
    function encode(uint112 y) internal pure returns (uint224 z) {
        z = uint224(y) * Q112; // never overflows
    }

    // divide a UQ112x112 by a uint112, returning a UQ112x112
    function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) {
        z = x / uint224(y);
    }
}
Enter fullscreen mode Exit fullscreen mode

Problem: Solidity lacks support for non-integer numeric types (such as fractions)

Solution: To overcome the problem above, Uniswap V2 uses the above library to handle fixed point binary numbers for updating prices. With fixed point binary numbers, there is a trade-off between range and precision, the higher the range of numbers, the lower the precision. In the case of Uniswap, the fixed point binary numbers are implemented as uint224, where the first 112 bits represent integers and the last 112 bits represent decimals. This means the numbers would have a range of [0, 2112-1] and a precision of 1112.

Readers who would like to learn more about fixed point binary numbers may refer to the excellent explanation in the video below:

5. Lock

uint private unlocked = 1;

    modifier lock() {
        require(unlocked == 1, 'UniswapV2: LOCKED');
        unlocked = 0;
        _;
        unlocked = 1;
    }
Enter fullscreen mode Exit fullscreen mode

Uniswap uses the lock modifier as a measure to prevent reentrancy attack. The most high profile reentrancy attack occurred in 2016 where the attacker drained 3.6 million ether from The DAO.

The lock modifier prevents reentrancy attack by setting the initial value of unlocked state variable to 1. Once a function with lock modifier is executed, the unlocked state variable value will be set to 0. To reenter the contract, the unlocked state variable is required to be == 1, thus preventing reentrancy. The unlocked state variable value will return to 1 upon successful execution of the function or failed execution (reversion to original state). Uniswap applies the lock modifier to all the UniswapV2Pair functions that perform external calls, such as mint, burn, swap and skim.

Developers may also refer to the Openzeppelin contract suite for a similar implementation of reentrancy guard.

Further resources:

If you like the content, please hit the like button so that it can reach more people.

Discussion (0)