All articles

Inside DEX (AMM) Explained: How Liquidity Pools Enable Token Swaps

Decentralized Exchanges (DEXes) have transformed cryptocurrency trading by enabling peer-to-peer transactions directly from user wallets. This eliminates intermediaries, offering greater security and

Etienne Maway

Etienne Maway

January 10, 2025 · 8 min read

Originally published on Medium. Reading here — you can also view the original.
Inside DEX (AMM) Explained: How Liquidity Pools Enable Token Swaps

Decentralized Exchanges (DEXes) have transformed cryptocurrency trading by enabling peer-to-peer transactions directly from user wallets. This eliminates intermediaries, offering greater security and autonomy compared to centralized exchanges.

At the core of DEXes are liquidity pools, which replace traditional order books. These pools rely on liquidity providers (LPs) who deposit token pairs like ETH and an ERC-20 token into a smart contract. In return, LPs receive LP tokens, representing their share of the pool and entitling them to a proportion of trading fees.

This article explores how Exchange smart contracts power liquidity pools in DEXes, focusing on token pairing, LP token minting, and automated market-making. We’ll also clarify common misconceptions and showcase their flexibility in DEX operations.

What is an Exchange Smart Contract?

An Exchange Smart Contract is a blockchain-based program that manages a liquidity pool for a specific token pair, such as ETH/MyToken. It automates liquidity provision, token swaps, and price adjustments.

Purpose of an Exchange Contract

  1. Facilitate Liquidity Provision:
    Users deposit equal values of two tokens into the pool, managed by the contract.
  2. Enable Token Swaps:
    The contract allows seamless token swaps, adjusting prices using formulas like x * y = k.
  3. Mint LP Tokens:
    LPs receive tokens that represent their pool ownership, which can later be redeemed for their share.
  4. Determine Prices Dynamically:
    Prices are adjusted based on token reserves, reflecting supply and demand.

Key Features

  1. LP Tokens: Represent pool ownership and are minted for LPs as they contribute liquidity.
  2. Token Pairing: Works with a specific token pair (e.g., ETH/MyToken), requiring separate contracts for each pair.
  3. Automated Market-Making (AMM): Ensures constant liquidity and dynamic price adjustments via algorithms like the constant product formula.
  4. Fee Distribution: Distributes trading fees to LPs as rewards, proportional to their share.

Technologies and Tools Used

Building an Exchange smart contract requires a robust development stack. We made use of:

  1. Foundry : A comprehensive framework for Ethereum smart contract development, offering tools for writing, testing, and deploying Solidity code.
  2. Solidity: The primary language for writing Ethereum smart contracts. It powers the core logic of the Exchange contract.
  3. OpenZeppelin Contracts: OpenZeppelin provides reusable, secure, and audited contract libraries.
  • Why OpenZeppelin?
  • Reduces development time.
  • Ensures contract security through pre-audited code.

Foundry Setup

Start off by creating a new folder on your computer — I named mine dex-app.

Open up a Terminal pointing to the dex-app folder and run the following commands:

code
forge init ./

We also need to install OpenZeppelin’s contracts as we will use it to build out the ERC-20 Token. Run the following commands:

code
forge install OpenZeppelin/openzeppelin-contracts

To configure remappings in your project and make sure they’re picked up when compiling your code, run the following command:

code
forge remappings > remappings.txt

Environment Variables

Start off by going to the .env file inside the dex-app folder and add the following placeholder lines:

code
PRIVATE_KEY="..."<br>INFURA_RPC_URL="..."<br>ETHERSCAN_API_KEY="..."

For the PRIVATE_KEY variable, export it from MetaMask. Again, make sure to use an account that only has testnet funds in it, no mainnet funds, to not risk accidentally leaking a private key with real money in it.

For the INFURA_RPC_URL, create an account at Infura or any provider of your choice, if you don't have one already. Create an endpoint, select Ethereum, and Sepolia testnet. Replace the value of the INFUR_RPC_URL variable in the .env file with the HTTP Provider link you copied.

Lastly, we need an Etherscan API Key to have our contract verified on Etherscan. You can get an Etherscan API Key by creating an account on https://etherscan.io if you don’t have one already. Replace the value of ETHERSCAN_API_KEY in your .env file.

Making an ERC-20 Token

Now, let’s start off by creating a really simple ERC-20 Token that will be used to create the trading pool on our exchange. We will just use our custom token.

Create a new file named MyToken.sol under foundry-app/src and write the following code there:

code
// SPDX-License-Identifier: MIT<br>pragma solidity ^0.8.25;<br>import "@openzeppelin/contracts/token/ERC20/ERC20.sol";<br><br>contract MyToken is ERC20 {<br>    // Initialize contract with 1 million tokens minted to the creator of the contract<br>    constructor() ERC20("Token", "TKN") {<br>        _mint(msg.sender, 10_000_000 * 10 ** decimals());<br>    }<br>}

This contract is a very basic ERC-20 that just mints 10 million tokens to the deployer address.

Setting Up the Exchange Contract

code
// SPDX-License-Identifier: MIT<br>pragma solidity ^0.8.25;<br><br>import "@openzeppelin/contracts/token/ERC20/ERC20.sol";<br><br>contract Exchange is ERC20 {<br>  <br>  address public tokenAddress;<br><br>// Exchange is inheriting ERC20, because our exchange itself is an ERC-20 contract<br>// as it is responsible for minting and issuing LP Tokens<br>constructor(address token) ERC20("LP Token", "lpTKN") {<br>    require(token != address(0), "Token address passed is a null address");<br>    tokenAddress = token;<br> }<br>}

The Exchange contract is initialized via its constructor, which:

  1. Identifies the Paired Token: Accepts the address of an already deployed ERC-20 token (e.g., MyToken).
  2. Defines the LP Token: Sets the Exchange as an ERC-20 token (LP Token) to issue LP tokens to liquidity providers.
  3. Stores the Token Address: Saves the ERC-20 token address for interacting with its functions.
  4. LP tokens represent ownership of the ETH-token liquidity pool, enabling decentralized swaps and automated market-making.

Main Functionalities

  1. addLiquidity:
code
// addLiquidity allows users to add liquidity to the exchange<br>function addLiquidity(<br>    uint256 amountOfToken<br>) public payable returns (uint256) {<br>    uint256 lpTokensToMint;<br>    uint256 ethReserveBalance = address(this).balance;<br>    uint256 tokenReserveBalance = getReserve();<br><br>    ERC20 token = ERC20(tokenAddress);<br><br>    // If the reserve is empty, take any user supplied value for initial liquidity<br>    if (tokenReserveBalance == 0) {<br>        // Transfer the token from the user to the exchange<br>        token.transferFrom(msg.sender, address(this), amountOfToken);<br><br>        // lpTokensToMint = ethReserveBalance = msg.value<br>        lpTokensToMint = ethReserveBalance;<br><br>        // Mint LP tokens to the user<br>        _mint(msg.sender, lpTokensToMint);<br><br>        return lpTokensToMint;<br>    }<br><br>    // If the reserve is not empty, calculate the amount of LP Tokens to be minted<br>    uint256 ethReservePriorToFunctionCall = ethReserveBalance - msg.value;<br>    uint256 minTokenAmountRequired = (msg.value * tokenReserveBalance) /<br>        ethReservePriorToFunctionCall;<br><br>    require(<br>        amountOfToken >= minTokenAmountRequired,<br>        "Insufficient amount of tokens provided"<br>    );<br><br>    // Transfer the token from the user to the exchange<br>    token.transferFrom(msg.sender, address(this), minTokenAmountRequired);<br><br>    // Calculate the amount of LP tokens to be minted<br>    lpTokensToMint =<br>        (totalSupply() * msg.value) /<br>        ethReservePriorToFunctionCall;<br><br>    // Mint LP tokens to the user<br>    _mint(msg.sender, lpTokensToMint);<br><br>    return lpTokensToMint;<br>}

Users call addLiquidity, sending ETH and tokens. The function calculates the required token amount to maintain the pool ratio, updates reserves, and mints LP tokens.

  • LP Token Minting: LP tokens represent the user’s share of the pool. If the pool is empty, tokens minted match the ETH added. Otherwise, they are proportional to the user’s contribution.
  • Formula:
code
lpTokensToMint = (totalSupply() * msg.value) / ethReservePriorToFunctionCall;
  • Example:
    A user adds 10 ETH and 500 tokens to an empty pool, receiving 10 LP tokens. Adding 1 ETH to a pool with 100 ETH and 5000 tokens mints 1% of LP tokens, matching the user’s ETH contribution.

2. removeLiquidity:

code
function removeLiquidity(<br>    uint256 amountOfLPTokens<br>) public returns (uint256, uint256) {<br>    // Check that the user wants to remove >0 LP tokens<br>    require(<br>        amountOfLPTokens > 0,<br>        "Amount of tokens to remove must be greater than 0"<br>    );<br><br>    uint256 ethReserveBalance = address(this).balance;<br>    uint256 lpTokenTotalSupply = totalSupply();<br><br>    // Calculate the amount of ETH and tokens to return to the user<br>    uint256 ethToReturn = (ethReserveBalance * amountOfLPTokens) /<br>        lpTokenTotalSupply;<br>    uint256 tokenToReturn = (getReserve() * amountOfLPTokens) /<br>        lpTokenTotalSupply;<br><br>    // Burn the LP tokens from the user, and transfer the ETH and tokens to the user<br>    _burn(msg.sender, amountOfLPTokens);<br>    payable(msg.sender).transfer(ethToReturn);<br>    ERC20(tokenAddress).transfer(msg.sender, tokenToReturn);<br><br>    return (ethToReturn, tokenToReturn);<br>}

This function allows users to withdraw liquidity from the pool by burning their LP tokens. It ensures that users can redeem their LP tokens for their share of the pool’s assets, in proportion to their stake in the liquidity pool. The function returns both ETH and tokens to the user while updating the pool’s reserves and LP token supply.

3. tokenSwap functions

code
// ethToTokenSwap allows users to swap ETH for tokens<br>function ethToTokenSwap(uint256 minTokensToReceive) public payable {<br>    uint256 tokenReserveBalance = getReserve();<br>    uint256 tokensToReceive = getOutputAmountFromSwap(<br>        msg.value,<br>        address(this).balance - msg.value,<br>        tokenReserveBalance<br>    );<br><br>    require(<br>        tokensToReceive >= minTokensToReceive,<br>        "Tokens received are less than minimum tokens expected"<br>    );<br><br>    ERC20(tokenAddress).transfer(msg.sender, tokensToReceive);<br>}<br><br>// tokenToEthSwap allows users to swap tokens for ETH<br>function tokenToEthSwap(<br>    uint256 tokensToSwap,<br>    uint256 minEthToReceive<br>) public {<br>    uint256 tokenReserveBalance = getReserve();<br>    uint256 ethToReceive = getOutputAmountFromSwap(<br>        tokensToSwap,<br>        tokenReserveBalance,<br>        address(this).balance<br>    );<br><br>    require(<br>        ethToReceive >= minEthToReceive,<br>        "ETH received is less than minimum ETH expected"<br>    );<br><br>    ERC20(tokenAddress).transferFrom(<br>        msg.sender,<br>        address(this),<br>        tokensToSwap<br>    );<br><br>    payable(msg.sender).transfer(ethToReceive);<br>}<br><br>// getOutputAmountFromSwap calculates the amount of output tokens to be received based on xy = (x + dx)(y - dy)<br>function getOutputAmountFromSwap(<br>    uint256 inputAmount,<br>    uint256 inputReserve,<br>    uint256 outputReserve<br>) public pure returns (uint256) {<br>    require(<br>        inputReserve > 0 && outputReserve > 0,<br>        "Reserves must be greater than 0"<br>    );<br><br>    uint256 inputAmountWithFee = inputAmount * 99;<br><br>    uint256 numerator = inputAmountWithFee * outputReserve;<br>    uint256 denominator = (inputReserve * 100) + inputAmountWithFee;<br><br>    return numerator / denominator;<br>}

The ethToTokenSwap and tokenToEthSwap functions facilitate seamless exchanges between ETH and tokens in the pool. Using the constant product formula x×y=kx, these swaps dynamically adjust prices based on reserves.

  • ETH to Token: Users send ETH, and the contract calculates the token amount using a 1% fee. Tokens are transferred if the amount meets the user-defined minimum.
  • Token to ETH: Users send tokens, and the contract calculates the corresponding ETH amount. ETH is transferred if it satisfies the minimum condition.

The formula ensures fair pricing and stability in the pool, with every swap slightly increasing liquidity.

Test your DEX

You can create a file in test/Exchange.t.sol and paste this test script:

code
// SPDX-License-Identifier: MIT<br>pragma solidity ^0.8.25;<br><br>import "forge-std/Test.sol";<br>import "../src/MyToken.sol";<br>import "../src/Exchange.sol";<br><br>contract ExchangeTest is Test {<br>    MyToken private token;<br>    Exchange private exchange;<br><br>    address private user = address(0x1234);<br>    uint256 private initialSupply = 10_000_000 * 10 ** 18;<br><br>    function setUp() public {<br>        // Deploy MyToken and Exchange contracts<br>        token = new MyToken();<br>        exchange = new Exchange(address(token));<br><br>        // Allocate initial tokens to user<br>        token.transfer(user, initialSupply);<br><br>        // Label addresses for readability in logs<br>        vm.label(address(token), "MyToken");<br>        vm.label(address(exchange), "Exchange");<br>        vm.label(user, "User");<br>    }<br><br>    function testAddInitialLiquidity() public {<br>        uint256 ethAmount = 10 ether;<br>        uint256 tokenAmount = 1000 * 10 ** 18;<br><br>        // Simulate user approving tokens and adding liquidity<br>        vm.startPrank(user);<br>        token.approve(address(exchange), tokenAmount);<br>        vm.deal(user, ethAmount); // Allocate ETH to user<br><br>        uint256 lpTokensMinted = exchange.addLiquidity{value: ethAmount}(tokenAmount);<br>        assertEq(lpTokensMinted, ethAmount); // Initial LP tokens minted should match ETH amount<br><br>        vm.stopPrank();<br>    }<br><br>    function testEthToTokenSwap() public {<br>        uint256 ethAmount = 10 ether;<br>        uint256 tokenAmount = 1000 * 10 ** 18;<br><br>        // Add initial liquidity<br>        vm.startPrank(user);<br>        token.approve(address(exchange), tokenAmount);<br>        vm.deal(user, ethAmount);<br>        exchange.addLiquidity{value: ethAmount}(tokenAmount);<br>        vm.stopPrank();<br><br>        // Perform ETH to token swap<br>        uint256 ethToSwap = 1 ether;<br>        uint256 minTokens = 90 * 10 ** 18;<br><br>        vm.startPrank(user);<br>        vm.deal(user, ethToSwap);<br>        uint256 tokenBalanceBefore = token.balanceOf(user);<br>        exchange.ethToTokenSwap{value: ethToSwap}(minTokens);<br>        uint256 tokenBalanceAfter = token.balanceOf(user);<br><br>        assert(tokenBalanceAfter > tokenBalanceBefore);<br>        vm.stopPrank();<br>    }<br><br>    function testRemoveLiquidity() public {<br>        uint256 ethAmount = 10 ether;<br>        uint256 tokenAmount = 1000 * 10 ** 18;<br><br>        // Add initial liquidity<br>        vm.startPrank(user);<br>        token.approve(address(exchange), tokenAmount);<br>        vm.deal(user, ethAmount);<br>        uint256 lpTokensMinted = exchange.addLiquidity{value: ethAmount}(tokenAmount);<br>        vm.stopPrank();<br><br>        // Remove liquidity<br>        vm.startPrank(user);<br>        uint256 ethBalanceBefore = user.balance;<br>        uint256 tokenBalanceBefore = token.balanceOf(user);<br><br>        (uint256 ethReturned, uint256 tokensReturned) = exchange.removeLiquidity(lpTokensMinted);<br><br>        uint256 ethBalanceAfter = user.balance;<br>        uint256 tokenBalanceAfter = token.balanceOf(user);<br><br>        assertEq(ethBalanceAfter, ethBalanceBefore + ethReturned);<br>        assertEq(tokenBalanceAfter, tokenBalanceBefore + tokensReturned);<br>        vm.stopPrank();<br>    }<br>}

Run the Tests

  1. Compile Contracts:
code
forge build
  • Run Tests:
code
forge test

. View Gas Reports (optional):

code
forge test --gas-report

If you followed along, you should see in your terminal:

  1. testAddInitialLiquidity:
    Tests the initial liquidity addition by verifying the minted LP tokens.
  2. testEthToTokenSwap:
    Tests ETH to token swaps, ensuring tokens are received based on the reserves and fees.
  3. testRemoveLiquidity:
    Tests liquidity removal, ensuring the returned ETH and tokens match expectations.

This script ensures your contract works as expected, covering critical functionalities like liquidity provision, swaps, and withdrawals.

Deployment Instructions

With the contracts completed, the next step is deploying them. Follow these steps:

  1. Load Environment Variables
    Start by loading the environment variables into your terminal. Run the following command:
code
source .env
  1. Deploy the MyToken Contract
    Navigate to the dex-app directory in your terminal and deploy the token contract using this command:
code
forge create --rpc-url $INFURA_RPC_URL --private-key $PRIVATE_KEY --etherscan-api-key $ETHERSCAN_API_KEY --verify src/MyToken.sol:Token

Once deployed, note down the token contract address, as you’ll need it for deploying the Exchange contract.

2. Deploy the Exchange Contract
Using the token contract address obtained earlier, deploy the Exchange contract by running:

code
forge create --rpc-url $INFURA_RPC_URL --private-key $PRIVATE_KEY --constructor-args <token_contract_address> --etherscan-api-key $ETHERSCAN_API_KEY --verify src/Exchange.sol:Exchange
NOTE:
If you encounter a message stating that the contracts are already verified, it might be because identical contracts cannot be re-verified on the same network. To handle this, you can run:
forge verify-contract <contract_address> <contract_name> --chain <chain_name>

You can as well manually open up both contracts — the MyToken and the Exchange — on Sepolia Etherscan and manually test.

CONGRATULATIONS, we have just rebuilt a simple DEX similar to UNISWAP V1 from scratch, tested and successfully deployed to a testnet.