Writing a Uniswap Router Wrapper: Swapper.sol, Step by Step

What is Swapper.sol?

Swapper.sol is a smart contract that lets users swap one ERC-20 token for another using a decentralized exchange (DEX) like Uniswap. Think of it as a digital currency exchange booth: you give it one token, and it gives you another, all automatically and securely.

How Do Token Swaps Work?

On Uniswap, swapping tokens is like trading money at an airport currency exchange. You hand over dollars and get euros, based on the current rate. In Uniswap, you trade one token for another, and the protocol calculates how much you’ll get using its automated market maker (AMM) system.

  • No order books: Uniswap doesn’t use traditional buy/sell orders. Instead, it uses liquidity pools smart contracts holding two tokens, which set prices using a mathematical formula.

  • Routers: The Uniswap router is like the cashier at the exchange booth. It finds the best way to swap your tokens, sometimes using multiple steps (e.g., swapping ETH to WETH, then WETH to another token).

Why Use a Smart Contract for Swaps?

  • Automation: No need for a middleman; the contract handles everything.

  • Safety: The contract checks all inputs and only swaps if conditions are met.

  • Transparency: Every swap is recorded on the blockchain.

  • Efficiency: The router finds the best path for your swap, sometimes using multiple pools to get the best rate.

Imagine John wants to swap 0.04 ETH for VISA tokens using Uniswap:

  1. John’s wallet sends 0.04 ETH to the Swapper contract.

  2. The contract approves the Uniswap router to use John’s ETH.

  3. The router swaps ETH for WETH, then WETH for VISA tokens.

  4. John receives VISA tokens in his wallet.

  5. The contract emits an event, like a receipt, showing the details of the swap.

Swapper.sol is a secure, automated way to swap tokens using Uniswap. It checks inputs, handles approvals, calls the router, and records the swap—all in one function. This makes token trading easy, safe, and transparent for users.

Code Walkthrough: Swapper.sol

Let’s break down the Swapper.sol contract, part by part, explaining each term and how it helps us achieve our goal of safe, automated token swaps. We’ll use real-life analogies to make each concept clear.

1. License and Pragma

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

👉The license line tells everyone how they can use your code (like a "public domain" sign on a park). The pragma line sets the Solidity version, ensuring your code runs with the right compiler features and security checks.

This line prevents accidental use with an incompatible compiler, which could break your contract or introduce bugs.

2. Interfaces: IERC20 and IUniswapV2RouterLike

interface IERC20 {
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
    function allowance(address owner, address spender) external view returns (uint256);
    function decimals() external view returns (uint8);
}

interface IUniswapV2RouterLike {
    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory amounts);
}

👉 IERC20 is the "ATM interface" for tokens: lets you check balances, transfer tokens, and approve others to spend your tokens.

IUniswapV2RouterLike is the "currency exchange counter" interface: lets you swap tokens using Uniswap’s router.

These interfaces let your contract talk to other contracts (tokens and Uniswap) in a standardized way, just like a universal plug lets you use any device in any country.

3.Router Address (Immutable)

IUniswapV2RouterLike public immutable router;

constructor(address _router) {
    require(_router != address(0), "router=0");
    router = IUniswapV2RouterLike(_router);
}

👉 The contract stores the address of the Uniswap router when it’s created. immutable means it can’t be changed after deployment.

The constructor checks that the address isn’t zero (which would be like pointing to a non-existent cashier).

Ensures the contract always knows where to send swap requests, and prevents accidental or malicious changes to the router address.

Like setting the location of your currency exchange booth once and locking it in place.

4.Custom Errors and Events

error InvalidAmount();
error AmountOutTooLow();
error DeadlineExpired();
error TransferFromFailed();

event SwapExecuted(
    address indexed user,
    address indexed tokenIn,
    address indexed tokenOut,
    uint256 amountIn,
    uint256 amountOutMin,
    uint256 deadline
);

👉 Errors are like warning signs: they stop the swap if something goes wrong (e.g., wrong amount, expired deadline).

Events are like receipts: they record every swap for transparency and tracking.

Errors save gas and make debugging easier. Events let users and apps track swaps on the blockchain.

Errors are like a cashier refusing a transaction if you hand over monopoly money. Events are the printed receipt you get after a successful exchange.

5.The Swap Function: swapExactTokensForTokens

function swapExactTokensForTokens(
    address tokenIn,
    address tokenOut,
    uint256 amountIn,
    uint256 amountOutMin,
    uint256 deadline
) external returns (uint256 amountOut) {
    if (amountIn == 0) revert InvalidAmount();
    if (block.timestamp > deadline) revert DeadlineExpired();

    if (!IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn)) {
        revert TransferFromFailed();
    }

    uint256 allowance = IERC20(tokenIn).allowance(address(this), address(router));
    if (allowance < amountIn) {
        IERC20(tokenIn).approve(address(router), type(uint256).max);
    }

    address[] memory path = new addressUnsupported embed;
    path[0] = tokenIn;
    path[1] = tokenOut;

    uint[] memory amounts = router.swapExactTokensForTokens(
        amountIn,
        amountOutMin,
        path,
        msg.sender,
        deadline
    );

    amountOut = amounts[amounts.length - 1];
    if (amountOut < amountOutMin) revert AmountOutTooLow();

    emit SwapExecuted(msg.sender, tokenIn, tokenOut, amountIn, amountOutMin, deadline);
}

👉 Input Checks:
Makes sure the user isn’t trying to swap zero tokens (like refusing to exchange zero dollars).
Checks that the swap deadline hasn’t passed (protects against old transactions).

Token Transfer:
The contract pulls tokens from the user. The user must approve this contract to spend their tokens first (like handing your cash to the exchange booth).

Approve Router:
The contract gives the router permission to spend the tokens, if it hasn’t already (like telling the cashier they can use your money for the exchange).

Set Swap Path:
Defines which tokens to swap (from tokenIn to tokenOut). For direct swaps, it’s a two-token path.

Call Router:
Asks the router to perform the swap (the actual exchange happens here).

Check Output:
Makes sure the user gets at least the minimum amount they expected (protects against price slippage, like refusing a bad exchange rate).

Emit Event:
Records the swap on the blockchain for transparency and tracking (like a digital receipt).

Analogy:
This function is like a step-by-step process at a currency exchange booth: you hand over your money, the cashier checks it, approves the transaction, finds the best rate, gives you your new currency, and prints a receipt.

Each part works together to make swaps simple and safe. Interfaces are the shared “language” the contract uses to talk to tokens and the Uniswap router, so transfers and swaps just work. The stored router address is a fixed, trusted destination for all swap calls, preventing mistakes. Errors act like guardrails, stopping bad inputs, expired deadlines, or poor rates before any tokens move. Events are on-chain receipts that record what happened so it’s easy to track and verify. Finally, the swap function ties everything together by collecting inputs, managing approvals, calling the router, checking results, and logging the swap, giving a smooth end-to-end experience.

Complete code:

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

interface IERC20 {
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
    function allowance(address owner, address spender) external view returns (uint256);
    function decimals() external view returns (uint8);
}

interface IUniswapV2RouterLike {
    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory amounts);
}

contract Swapper {
    error InvalidAmount();
    error AmountOutTooLow();
    error DeadlineExpired();
    error TransferFromFailed();

    event SwapExecuted(
        address indexed user,
        address indexed tokenIn,
        address indexed tokenOut,
        uint256 amountIn,
        uint256 amountOutMin,
        uint256 deadline
    );

    IUniswapV2RouterLike public immutable router;

    constructor(address _router) {
        require(_router != address(0), "router=0");
        router = IUniswapV2RouterLike(_router);
    }

    function swapExactTokensForTokens(
        address tokenIn,
        address tokenOut,
        uint256 amountIn,
        uint256 amountOutMin,
        uint256 deadline
    ) external returns (uint256 amountOut) {
        if (amountIn == 0) revert InvalidAmount();
        if (block.timestamp > deadline) revert DeadlineExpired();

        if (!IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn)) {
            revert TransferFromFailed();
        }

        uint256 allowance = IERC20(tokenIn).allowance(address(this), address(router));
        if (allowance < amountIn) {
            IERC20(tokenIn).approve(address(router), type(uint256).max);
        }

        address[] memory path = new addressUnsupported embed;
        path[0] = tokenIn;
        path[1] = tokenOut;

        uint[] memory amounts = router.swapExactTokensForTokens(
            amountIn,
            amountOutMin,
            path,
            msg.sender,
            deadline
        );

        amountOut = amounts[amounts.length - 1];
        if (amountOut < amountOutMin) revert AmountOutTooLow();

        emit SwapExecuted(msg.sender, tokenIn, tokenOut, amountIn, amountOutMin, deadline);
    }
}