DEX: Router

The router contract in a decentralized exchange (DEX) built on Automated Market Makers (AMMs) is a critical component that facilitates user interactions with various liquidity pools. The router contract serves as a middle layer, managing complex actions such as token swaps, adding liquidity, and removing liquidity by interacting with both the factory and pair contracts.

Let’s dive into the details of the router contract’s functionality and how to test it.

Router Contract

In an AMM-based DEX like Uniswap, the router contract abstracts away many of the complexities involved in interacting with the pair contracts (the actual liquidity pools). The router provides users with a simpler interface for:

  • Swapping tokens: The router handles swapping by locating the correct pool for a given token pair and executing the swap at the current price.

  • Adding liquidity: The router allows users to provide liquidity in equal values for a token pair. It calculates the optimal amounts needed based on pool ratios.

  • Removing liquidity: The router lets users withdraw their share of a liquidity pool along with any accrued trading fees.

For instance, Uniswap’s router contract commonly uses functions like swapExactTokensForTokens(), addLiquidity(), and removeLiquidity() to enable these operations.

Router Contract Example (Pseudo Solidity Code)

Here’s a simplified example of what a router contract might look like:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./Factory.sol";
import "./Pair.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract Router {
    address public factory;

    constructor(address _factory) {
        factory = _factory;
    }

    // Swap function: Swaps an exact amount of one token for another
    function swapExactTokensForTokens(
        address tokenA,
        address tokenB,
        uint amountIn,
        uint amountOutMin
    ) external {
        address pairAddress = Factory(factory).getPair(tokenA, tokenB);
        require(pairAddress != address(0), "Pair does not exist");

        IERC20(tokenA).transferFrom(msg.sender, pairAddress, amountIn);
        uint amountOut = Pair(pairAddress).swap(tokenA, tokenB, amountIn);
        require(amountOut >= amountOutMin, "Insufficient output amount");

        IERC20(tokenB).transfer(msg.sender, amountOut);
    }

    // Adding liquidity function: Deposits tokens into the liquidity pool
    function addLiquidity(
        address tokenA,
        address tokenB,
        uint amountA,
        uint amountB
    ) external {
        address pairAddress = Factory(factory).getPair(tokenA, tokenB);
        require(pairAddress != address(0), "Pair does not exist");

        IERC20(tokenA).transferFrom(msg.sender, pairAddress, amountA);
        IERC20(tokenB).transferFrom(msg.sender, pairAddress, amountB);

        Pair(pairAddress).mint(msg.sender);  // Mint LP tokens for liquidity provider
    }

    // Removing liquidity function: Withdraws tokens from the liquidity pool
    function removeLiquidity(
        address tokenA,
        address tokenB
    ) external {
        address pairAddress = Factory(factory).getPair(tokenA, tokenB);
        require(pairAddress != address(0), "Pair does not exist");

        uint liquidity = Pair(pairAddress).balanceOf(msg.sender);
        Pair(pairAddress).burn(msg.sender);  // Burn LP tokens to remove liquidity

        // Transfer tokens back to user
        uint amountA = IERC20(tokenA).balanceOf(pairAddress);
        uint amountB = IERC20(tokenB).balanceOf(pairAddress);

        IERC20(tokenA).transfer(msg.sender, amountA);
        IERC20(tokenB).transfer(msg.sender, amountB);
    }
}

Explanation of Key Functions

  • swapExactTokensForTokens(): Swaps a specific amount of tokenA for tokenB using the pair pool. It checks if the amountOut is at least the amountOutMin to avoid slippage losses.

  • addLiquidity(): Adds liquidity by transferring tokenA and tokenB from the user to the pool, then mints LP tokens to the user as a reward.

  • removeLiquidity(): Removes liquidity by burning the user’s LP tokens and returning the underlying tokens to them.

Testing the Router Contract

Testing the router contract is essential to ensure it handles swaps and liquidity management accurately. You can use Hardhat for this purpose.

Step 1: Install Dependencies and Set Up Project

Ensure you have Node.js and npm installed. Set up Hardhat if you haven’t already:

npm install --save-dev hardhat

Step 2: Configure Your Router Contract and Tests

Create the router contract under contracts/Router.sol and add tests in test/Router.test.js.

Step 3: Write Unit Tests for the Router Contract

Here’s an example of tests for the router contract using Hardhat:

const { expect } = require("chai");

describe("Router Contract", function () {
    let Factory, factory, Pair, pair, Router, router;
    let owner, addr1, tokenA, tokenB;

    beforeEach(async function () {
        [owner, addr1, tokenA, tokenB] = await ethers.getSigners();

        Factory = await ethers.getContractFactory("Factory");
        factory = await Factory.deploy();
        await factory.deployed();

        Router = await ethers.getContractFactory("Router");
        router = await Router.deploy(factory.address);
        await router.deployed();

        await factory.createPair(tokenA.address, tokenB.address);
        const pairAddress = await factory.getPair(tokenA.address, tokenB.address);
        Pair = await ethers.getContractFactory("Pair");
        pair = Pair.attach(pairAddress);
    });

    it("Should swap tokens", async function () {
        await router.addLiquidity(tokenA.address, tokenB.address, 1000, 1000);

        await tokenA.approve(router.address, 500);
        await router.swapExactTokensForTokens(tokenA.address, tokenB.address, 500, 450);

        const tokenBBalance = await tokenB.balanceOf(owner.address);
        expect(tokenBBalance).to.be.at.least(450);
    });

    it("Should add liquidity", async function () {
        await tokenA.approve(router.address, 1000);
        await tokenB.approve(router.address, 1000);
        await router.addLiquidity(tokenA.address, tokenB.address, 1000, 1000);

        const pairBalance = await pair.balanceOf(owner.address);
        expect(pairBalance).to.be.greaterThan(0);
    });

    it("Should remove liquidity", async function () {
        await tokenA.approve(router.address, 1000);
        await tokenB.approve(router.address, 1000);
        await router.addLiquidity(tokenA.address, tokenB.address, 1000, 1000);

        await router.removeLiquidity(tokenA.address, tokenB.address);

        const tokenABalance = await tokenA.balanceOf(owner.address);
        const tokenBBalance = await tokenB.balanceOf(owner.address);

        expect(tokenABalance).to.be.greaterThan(0);
        expect(tokenBBalance).to.be.greaterThan(0);
    });
});

Explanation of Tests

  • Swapping Tokens: This test ensures swapExactTokensForTokens() works correctly, performing a swap and confirming the output meets the amountOutMin.

  • Adding Liquidity: Tests the addLiquidity() function, verifying that LP tokens are issued to the user after providing liquidity.

  • Removing Liquidity: Ensures removeLiquidity() returns the tokens to the user’s account, demonstrating that they can withdraw their liquidity successfully.

Running Tests

  1. Compile Contracts:

    npx hardhat compile
  2. Run Tests:

    npx hardhat test

Additional Test Scenarios

To enhance test coverage:

  • Slippage Check: Verify that swaps revert if the output is less than amountOutMin.

  • Gas Efficiency: Analyze gas usage to optimize functions for real-world deployments.

  • Event Emission: Confirm that events are correctly emitted for tracking transactions and pool modifications.

Testing your router contract thoroughly ensures smooth interactions with the liquidity pools, covering both liquidity management and token swaps accurately.

Last updated