Uniswap v2 Deconstructed: How It Works

Uniswap v2 Deconstructed: How It Works

Table of contents

What is Uniswap?

Uniswap is a permissionless decentralised exchange built on the Ethereum blockchain which allows for the exchange between two crypto assets(ERC20 tokens), in simpler terms it allows users to exchange a token that they possess with a token that they desire across different pools. At the time of writing this article, there are 3 versions of Uniswap but I will be going over Uniswap V2 first to understand the fundamental components of the ecosystem before delving into V3 which came in place to improve on what was possible in V2.

To achieve permissionless exchange without the need for any central authority, Uniswap makes use of an Automated market maker(AMM). While there are different models used to achieve an automated market-making system, the one used by Uniswap is called constant function market-making (CFMM). What this means is that for a given pair of assets, a constant has to be maintained in order to facilitate exchange and this is denoted mathematically as

X * Y = k.

Where X and y are the assets and K is the constant factor.

There are 2 actors needed to achieve this AMM model

1. Liquidity Providers(LP) To enable trade in a decentralised manner, the pool needs to have an adequate amount of tokens (liquidity)in it.

Liquidity providers supply two tokens to the pool(preferably in equal amounts) and LP tokens are minted for them to represent their stake in the pool.

This minted token can be burnt when they want their assets back.LPs are rewarded with trading fees based on their share in the pool.

2. Traders

These are users that come to the platform to trade an asset they have in exchange for another token.

On the fundamental level actions that can be carried out on Uniswap are classified into 3

  • Perform a swap between two different assets

  • Supply Liquidity to a pair contract and get LP tokens minted for you.

  • Burn the LP tokens and get the assets you supplied back

You can have access to the full code here So let’s go through the V2 Pair contract For a user to perform a swap the pool for that pair must have been deployed already. For each pool, there is a constant which is owned by address(0).

uint256 constant MINIMUM_LIQUIDITY = 1000;

This is to prevent cases of division by zero.

// called once by the factory at time of deployment
function initialize(address _token0, address _token1) external {
require(msg.sender == factory, 'UniswapV2: FORBIDDEN'); // sufficient check
token0 = _token0;
token1 = _token1;
}

The parameters for the pool which is the token pair should be set here and should only be called by the factory

Another thing to look at is the mint function which is usually called when liquidity is supplied to the pool

function mint(address to) public returns (uint256 liquidity) {
        (uint112 reserve0_, uint112 reserve1_, ) = getReserves();
        uint256 balance0 = IERC20(token0).balanceOf(address(this));
        uint256 balance1 = IERC20(token1).balanceOf(address(this));
        uint256 amount0 = balance0 - reserve0_;
        uint256 amount1 = balance1 - reserve1_;

        if (totalSupply == 0) {
            liquidity = Math.sqrt(amount0 * amount1)-MINIMUM_LIQUIDITY;
            _mint(address(0), MINIMUM_LIQUIDITY);
        } else {
            liquidity = Math.min(
                (amount0 * totalSupply) / reserve0_,
                (amount1 * totalSupply) / reserve1_
            );
        }

        if (liquidity <= 0) revert InsufficientLiquidityMinted();

        _mint(to, liquidity);

        _update(balance0, balance1, reserve0_, reserve1_);

        emit Mint(to, amount0, amount1);
    }

Burn Function: is called when liquidity is removed and the assets provided has to be refunded to the LP while removing the LP tokens from circulation through burning.

function burn(address to)
        public
        returns (uint256 amount0, uint256 amount1)
    {
        uint256 balance0 = IERC20(token0).balanceOf(address(this));
        uint256 balance1 = IERC20(token1).balanceOf(address(this));
        uint256 liquidity = balanceOf[address(this)];

        amount0 = (liquidity * balance0) / totalSupply;
        amount1 = (liquidity * balance1) / totalSupply;

        if (amount0 == 0 || amount1 == 0) revert InsufficientLiquidityBurned();

        _burn(address(this), liquidity);

        _safeTransfer(token0, to, amount0);
        _safeTransfer(token1, to, amount1);

        balance0 = IERC20(token0).balanceOf(address(this));
        balance1 = IERC20(token1).balanceOf(address(this));

        (uint112 reserve0_, uint112 reserve1_, ) = getReserves();
        _update(balance0, balance1, reserve0_, reserve1_);

        emit Burn(msg.sender, amount0, amount1, to);
    }

Swap: This is what handles the exchange of tokens majorly. Note: This function should be called from the periphery contract

function swap(
        uint256 amount0Out,
        uint256 amount1Out,
        address to,
        bytes calldata data
    ) public nonReentrant {
        if (amount0Out == 0 && amount1Out == 0)
            revert InsufficientOutputAmount();

        (uint112 reserve0_, uint112 reserve1_, ) = getReserves();

        if (amount0Out > reserve0_ || amount1Out > reserve1_)
            revert InsufficientLiquidity();

        if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
        if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);
        if (data.length > 0)
            IZuniswapV2Callee(to).zuniswapV2Call(
                msg.sender,
                amount0Out,
                amount1Out,
                data
            );

        uint256 balance0 = IERC20(token0).balanceOf(address(this));
        uint256 balance1 = IERC20(token1).balanceOf(address(this));

        uint256 amount0In = balance0 > reserve0 - amount0Out
            ? balance0 - (reserve0 - amount0Out)
            : 0;
        uint256 amount1In = balance1 > reserve1 - amount1Out
            ? balance1 - (reserve1 - amount1Out)
            : 0;

        if (amount0In == 0 && amount1In == 0) revert InsufficientInputAmount();

        // Adjusted = balance before swap - swap fee; fee stays in the contract
        uint256 balance0Adjusted = (balance0 * 1000) - (amount0In * 3);
        uint256 balance1Adjusted = (balance1 * 1000) - (amount1In * 3);

        if (
            balance0Adjusted * balance1Adjusted <
            uint256(reserve0_) * uint256(reserve1_) * (1000**2)
        ) revert InvalidK();

        _update(balance0, balance1, reserve0_, reserve1_);

        emit Swap(msg.sender, amount0Out, amount1Out, to);
    }

Sync /Skim

Used to update balances and reserves of the pool in case of deviations based on activities happening inside the pool

function sync() public {
        (uint112 reserve0_, uint112 reserve1_, ) = getReserves();
        _update(
            IERC20(token0).balanceOf(address(this)),
            IERC20(token1).balanceOf(address(this)),
            reserve0_,
            reserve1_
        );
    }

UniswapV2 Factory: This holds the record of all the pools that have been created, it is deployed once and it’s used to deploy new pools deterministically using create2

function createPair(address tokenA, address tokenB)
        public
        returns (address pair)
    {
        if (tokenA == tokenB) revert IdenticalAddresses();

        (address token0, address token1) = tokenA < tokenB
            ? (tokenA, tokenB)
            : (tokenB, tokenA);

        if (token0 == address(0)) revert ZeroAddress();

        if (pairs[token0][token1] != address(0)) revert PairExists();

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

        IZuniswapV2Pair(pair).initialize(token0, token1);

        pairs[token0][token1] = pair;
        pairs[token1][token0] = pair;
        allPairs.push(pair);

        emit PairCreated(token0, token1, pair, allPairs.length);
    }
}

Uniswap V2ERC20 .sol extends the basic ERC20 contract with the addition of permit functionality which helps facilitate meta-transactions.

function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
        require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
        bytes32 digest = keccak256(
            abi.encodePacked(
                '\\x19\\x01',
                DOMAIN_SEPARATOR,
                keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
            )
        );
        address recoveredAddress = ecrecover(digest, v, r, s);
        require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
        _approve(owner, spender, value);
    }

Uniswap Router: this is a critical component of the Uniswap DEX, providing a secure and efficient way for users to access the platform and trade assets. The router contract acts as the central hub for the Uniswap platform, facilitating trades, managing liquidity, and distributing fees in a transparent and decentralized manner.

Conclusion: Uniswap V2 is a major upgrade to the popular decentralized exchange, offering a more robust, secure, and user-friendly platform for trading Ethereum-based assets. Whether you are a seasoned trader or a beginner, Uniswap V2 provides a platform for fast and efficient trades without the need for intermediaries.