Uniswap V2 Code Explained

·

14 min read

If you goto Uniswap's Github profile you can see that there are two repos associated with V2: V2 Core and V2 Periphery. V2 Core is the implementation of the protocol and Periphery has a set of contracts to help us interact with the protocol. We will be going through the contracts associated with V2 Core. This is the link to the repo https://github.com/Uniswap/v2-core/tree/master/contracts

There are 3 contracts: UniswapV2Factory.sol , UniswapV2ERC20.sol , UniswapV2Pair.sol . Forget about UniswapV2ERC20.sol. The UniswapV2Pair contract represents a pool for a pair of ERC20 tokens. So if there is a pool to swap USDC and ETH, that pool is this contract. The UniswapV2Factory contract manages the creation and bookkeeping of the pair contracts. Let us start with UniswapV2Factory.

function createPair(address tokenA, address tokenB) external returns (address pair) {
        require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
        (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
        require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
        require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS'); // single check is sufficient
        bytes memory bytecode = type(UniswapV2Pair).creationCode;
        bytes32 salt = keccak256(abi.encodePacked(token0, token1));
        assembly {
            pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
        }
        IUniswapV2Pair(pair).initialize(token0, token1);
        getPair[token0][token1] = pair;
        getPair[token1][token0] = pair; // populate mapping in the reverse direction
        allPairs.push(pair);
        emit PairCreated(token0, token1, pair, allPairs.length);
    }

This is the most important function in the entirety of Uniswap V2. Because this is how pools are created. Whenever a person wants to create a new pool, they invoke this function by passing in the address of the tokens. We can see that in the second line, the tokens are sorted. token0 represents the token with the smaller address and token1 represents the token with the higher address. We will see how this is useful a little later.

The getPair[token0][token1] retrieves the address of the pair contract of token0 and token1. If this returns 0, it means that the pair contract has not yet been created. getPair is a public mapping where we will be storing the pair contract addresses of all the token pairs.

mapping(address => mapping(address => address)) public getPair;

Once we have confirmed that a pool doesn't exist for the pair of tokens, we will move on to creating the pool. To create pools, Uniswap has decided to use the create2 opcode. This allows computing the pool address of a token pair without doing a lookup in the Factory Contract making it more gas efficient for on-chain contracts and removing the chain interaction for off-chain parties.

        bytes memory bytecode = type(UniswapV2Pair).creationCode;
        bytes32 salt = keccak256(abi.encodePacked(token0, token1));
        assembly {
            pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
        }
        IUniswapV2Pair(pair).initialize(token0, token1);

One thing to notice here is that they have chosen to initialize the pool contract using an initialize function rather than using the constructor. This is done because the create2 address is dependent on keccak256(abi.encodePacked (creationCode,constructorArgs)). Hence if constructorArgs were used, an on-chain contract would have to store the entire creationCode of the pair contract in-order to compute the address of a pair. If we are not using constructor arguments, this part would equate to keccak256(creationCode) which is a 32bytes constant and the contracts would just have to store this.

        getPair[token0][token1] = pair;
        getPair[token1][token0] = pair; // populate mapping in the reverse direction
        allPairs.push(pair);
        emit PairCreated(token0, token1, pair, allPairs.length);

After the new pool has been created, it is populated in the mapping. Instead of just populating in 1 direction ie. getPair[token0][token1], the decision is made to populate in both directions as this will eliminate the need to sort tokens when querying the pair address. Since querying a pair address will be done throughout the lifetime of the contract, it is beneficial to optimize for gas there rather than the one-time creation.

    address public feeTo;
    address public feeToSetter;
    function setFeeTo(address _feeTo) external {
        require(msg.sender == feeToSetter, 'UniswapV2: FORBIDDEN');
        feeTo = _feeTo;
    }

    function setFeeToSetter(address _feeToSetter) external {
        require(msg.sender == feeToSetter, 'UniswapV2: FORBIDDEN');
        feeToSetter = _feeToSetter;
    }

As mentioned in the whitepaper, Uniswap V2 introduces a switchable protocol fee. The address that will be receiving the fee is stored in the feeTo variable of the factory contract. The pool contracts query the factory to find out if the feeTo is enabled.

That is it for the Factory Contract. Now we will see the actual pool contract which manages the swaps and liquidity.

We will start off with the liquidity part. The idea is that the liquidity providers can provide assets for swapping and earn a commission fee on every swap proportional to their share of the pool. To keep track of the amount of liquidity provided by an LP, we would be minting them LP tokens which would be an ERC20 token. Hence the pool contract would need to have an ERC20 implementation for the LP tokens. This part is handled by UniswapV2ERC20.sol. Uniswap implements the ERC2612 standard which includes signed permits for tokens transfers in addition to the regular ERC20 methods. Signed permits enable the authorization for token transfers to be signed messages instead of using the msg.sender mechanism.

Now we will see the actual core of the protocol. The place where adding/burning of liquidity and swaps occur: UniswapV2Pair.sol.

We will first look at the adding liquidity part. To add liquidity, one must transfer the tokens to the pool contract and then invoke the mint function. One must do this in a single transaction as else the funds could be claimed by others. Hence the interaction with the mint and most of the other methods that we will see later must be done via a contract. Uniswap provides a set of periphery contracts for the same.

function mint(address to) external lock returns (uint liquidity) {
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        uint balance0 = IERC20(token0).balanceOf(address(this));
        uint balance1 = IERC20(token1).balanceOf(address(this));
        uint amount0 = balance0.sub(_reserve0);
        uint amount1 = balance1.sub(_reserve1);

        bool feeOn = _mintFee(_reserve0, _reserve1);
        uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
        if (_totalSupply == 0) {
            liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
           _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
        } else {
            liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
        }
        require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED');
        _mint(to, liquidity);

        _update(balance0, balance1, _reserve0, _reserve1);
        if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
        emit Mint(msg.sender, amount0, amount1);
    }

The reserve variables store the current token0 and token1 amounts of the pool. Since these would be used more than once in this function, they are cached to memory in the first line. This saves us the huge gas cost associated with reading from storage. Then to get the amount of tokens that have been transferred, we subtract the reserves from the current token balance's of the pool.

     (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        uint balance0 = IERC20(token0).balanceOf(address(this));
        uint balance1 = IERC20(token1).balanceOf(address(this));
        uint amount0 = balance0.sub(_reserve0);
        uint amount1 = balance1.sub(_reserve1);

If the protocol fee is enabled, the amount of LP tokens corresponding to the accumulated protocol fees is minted in the _mintFee function. It returns a bool which denotes whether the protocol fee is on or off. We will take a look at the _mintFee function.

    function _mintFee(uint112 _reserve0, uint112 _reserve1) private returns (bool feeOn) {
        address feeTo = IUniswapV2Factory(factory).feeTo();
        feeOn = feeTo != address(0);
        uint _kLast = kLast; // gas savings
        if (feeOn) {
            if (_kLast != 0) {
                uint rootK = Math.sqrt(uint(_reserve0).mul(_reserve1));
                uint rootKLast = Math.sqrt(_kLast);
                if (rootK > rootKLast) {
                    uint numerator = totalSupply.mul(rootK.sub(rootKLast));
                    uint denominator = rootK.mul(5).add(rootKLast);
                    uint liquidity = numerator / denominator;
                    if (liquidity > 0) _mint(feeTo, liquidity);
                }
            }
        } else if (_kLast != 0) {
            kLast = 0;
        }
    }

If the protocol wants to receive a portion of the fee, then there must be some address that should be rewarded with the fees. This address is stored in the feeTo variable of the Factory contract. Such a setup allows Uniswap to switch the protocol fee across all pools by just updating the feeTo variable in the Factory contract. The kLast variable stores the product of reserves at the time of the last fee collection. If kLast is 0, it would mean that the protocol fee was previously disabled. If the protocol fee is on and kLast is not equal to 0, LP tokens corresponding to the amount of fee accumulated is minted to the feeTo address. The calculation of the LP token amount follows the equation provided in the whitepaper.

Coming back to the mint function, we will now calculate the LP token amount that should be minted. There are 2 cases: initial liquidity and non-initial liquidity. If it is the initial liquidity then the liquidity provider is free to choose the ratio in which to add the assets. The amount of LP tokens that he receives will be equal to sqrt(token0amount * token1amount) - 1000. 1000 LP tokens are minted to address 0. This is done to avoid a possible attack that disallows small liquidity providers from joining the pool. If liquidity is already present, then we expect the LP to provide the tokens in the same ratio as the reserves. If the ratio is off, then we consider the lower amount asset and mint corresponding amount of LP tokens to the user. We then update the reserves and also the kLast (latest fee collected k).

uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
        if (_totalSupply == 0) {
            liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
           _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
        } else {
            liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
        }
        require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED');
        _mint(to, liquidity);
        _update(balance0, balance1, _reserve0, _reserve1);
        if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date

Now we will see the liquidity burning part. Here LP's burn the liquidity tokens to retrieve the token assets from the pool. Each LP is given tokens proportional to the share of the LP tokens.

function burn(address to) external lock returns (uint amount0, uint amount1) {
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        address _token0 = token0;                                // gas savings
        address _token1 = token1;                                // gas savings
        uint balance0 = IERC20(_token0).balanceOf(address(this));
        uint balance1 = IERC20(_token1).balanceOf(address(this));
        uint liquidity = balanceOf[address(this)];

        bool feeOn = _mintFee(_reserve0, _reserve1);
        uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
        amount0 = liquidity.mul(balance0) / _totalSupply; // using balances ensures pro-rata distribution
        amount1 = liquidity.mul(balance1) / _totalSupply; // using balances ensures pro-rata distribution
        require(amount0 > 0 && amount1 > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED');
        _burn(address(this), liquidity);
        _safeTransfer(_token0, to, amount0);
        _safeTransfer(_token1, to, amount1);
        balance0 = IERC20(_token0).balanceOf(address(this));
        balance1 = IERC20(_token1).balanceOf(address(this));

        _update(balance0, balance1, _reserve0, _reserve1);
        if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
        emit Burn(msg.sender, amount0, amount1, to);
    }

Here we see a pattern similar to the mint function. Instead of burning a specified amount of liquidity of msg.sender, the contract burns the liquidity tokens allocated to it. Hence an LP would have to transfer the LP tokens to the pool address before calling the burn function. in the same transaction. In the initial lines, storage values are cached to memory to save gas. Then the protocol fee is handled similar to the mint function. The amount of tokens(amount0 and amount1) to give out to the LP, is proportional to the LP token amount being burned. Following a successful burn of the LP tokens, the tokens are transferred to the caller and the reserves and kLast are updated.

Now we will see the heart of a dex. The swap functionality:

    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
        require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');

        uint balance0;
        uint balance1;
        { // scope for _token{0,1}, avoids stack too deep errors
        address _token0 = token0;
        address _token1 = token1;
        require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
        if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
        if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
        if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
        balance0 = IERC20(_token0).balanceOf(address(this));
        balance1 = IERC20(_token1).balanceOf(address(this));
        }
        uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
        uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
        require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');
        { // scope for reserve{0,1}Adjusted, avoids stack too deep errors
        uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
        uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
        require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
        }

        _update(balance0, balance1, _reserve0, _reserve1);
        emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
    }

If a user wants to swap x amount of tokenA to tokenB, one approach would be to first verify that x amount of tokenA has been allocated to this contract and then transfer the corresponding amount of tokenB using the constant product formula. Uniswap has decided flip this. Instead of checking that x amount of tokenA has been allocated to this contract, we first transfer the required amount of tokenB to the user and then check if the required amount of tokenA has been deposited into this contract's balance. This enables flash swaps since the user is not required to pay the token amount upfront. Now that we are familiar with the mechanism of the swap, we will see the code.

The amount0Out and amount1Out parameters specify the number of tokens that a user expects to receive from a swap. Since usually trades swap one asset to another (eg : eth to usdc), one of these params would usually be zero. After the balance0 and balance1 variables are declared, we see code that is enclosed inside {}. This creates a block-scope and as mentioned in the comments is done to avoid stack too deep errors. Since the EVM can access only the topmost 16 elements of the stack this function would throw this error if we don't include the block scope due to the large number of local variables. If you see, the usage of _token0 and _token1 variables is just to transfer the tokens and fetch the balance. Hence we could pop these variables off the stack using a block scope. With that optimization done, let us see more code. The output token amounts are transferred to the parameter address to. Then if the length of the data param is > 0, we invoke the uniswapV2Call method of the to address passing along the details of the trade and the data param. This is usually useful in case of flash swaps. The address to would be a contract that
transfers the necessary amounts back after performing certain operations.

        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');

        uint balance0;
        uint balance1;
        { // scope for _token{0,1}, avoids stack too deep errors
        address _token0 = token0;
        address _token1 = token1;
        require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
        if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
        if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
        if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
        balance0 = IERC20(_token0).balanceOf(address(this));
        balance1 = IERC20(_token1).balanceOf(address(this));
        }

Once the call has returned, we could check for the equivalence of the constant product formula using the current balances and the initial reserves. But here we have not considered the fees that must be paid to the liquidity providers. To obtain this fees, the amounts transferred 'in' are calculated as the positive difference of the current balance and the initial reserve. For such an implementation it is crucial to include a reentrancy guard as else the contract could be rekt by an attacker. This is added with the modifier lock as seen in the function definition. After calculating the amountsIn, we decrease the fees from the current balance to obtain the adjusted balance. If the product of the adjusted balances is less than the product of initial reserves, we revert else the swap is considered successful and we update the reserves.

        uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
        uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
        require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');
        { // scope for reserve{0,1}Adjusted, avoids stack too deep errors
        uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
        uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
        require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
        }

        _update(balance0, balance1, _reserve0, _reserve1);
        emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);

Now we will look at the _update function which we had been using all along to update the reserves. It does more than just update the reserves.

function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
        require(balance0 <= uint112(-1) && balance1 <= uint112(-1), 'UniswapV2: OVERFLOW');
        uint32 blockTimestamp = uint32(block.timestamp % 2**32);
        uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired
        if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
            // * never overflows, and + overflow is desired
            price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
            price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
        }
        reserve0 = uint112(balance0);
        reserve1 = uint112(balance1);
        blockTimestampLast = blockTimestamp;
        emit Sync(reserve0, reserve1);
    }

The params are the current balances and the currently stored reserves. The price0CumulativeLast stores the cumulative price of token0 in token1 with 112bit precision. To update the cumulative price, we add to it the time elapsed * price. After updating the cumulativePrice's, we update the reserves and latest block timestamp. It should be noted that the cumulative price will only be updated for the first transaction in a block. The cumulativePrice variables act as an on-chain oracle for the token pair.

Apart from these there are two more functions: sync() and skim()

   function skim(address to) external lock {
        address _token0 = token0; // gas savings
        address _token1 = token1; // gas savings
        _safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
        _safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
    }

    // force reserves to match balances
    function sync() external lock {
        _update(IERC20(token0).balanceOf(address(this)), IERC20(token1).balanceOf(address(this)), reserve0, reserve1);
    }

Both are used when the current balance of the pool is different from the reserves. sync() updates the current reserves to match the current balances whereas skim() allows a user to take out unaccounted tokens from a pool.