Skip to content
Learni
View all tutorials
DeFi

How to Create a Simple DeFi AMM with Solidity in 2026

Lire en français

Introduction

Decentralized Finance (DeFi) is revolutionizing traditional financial services by eliminating intermediaries through Ethereum blockchain technology. At the heart of many DeFi protocols like Uniswap or SushiSwap is the Automated Market Maker (AMM), a mechanism using a mathematical price curve (constant product k = x * y) to enable decentralized trades.

This intermediate tutorial walks you step-by-step through creating a basic AMM in Solidity: two ERC20 tokens, a liquidity pool with add/remove, one-way swaps with 0.3% fees, and constant invariant. You'll set up Hardhat for development, testing, and deployment to Sepolia.

Why it matters in 2026? With L2 growth and Ethereum's maturity, mastering AMMs lets you build DEXs, yield farms, or flash loans. Every line of code is functional, copy-pasteable, and illustrated. By the end, you'll have deployed your own DeFi exchange (about 120 words).

Prerequisites

  • Node.js 20+ and npm/yarn
  • Basic Solidity knowledge (ERC20, modifiers)
  • MetaMask with Sepolia ETH (for deployment)
  • Free Sepolia RPC (Alchemy or Infura)
  • Deployer private key (store securely)

Initialize the Hardhat Project

terminal
mkdir amm-defi && cd amm-defi
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox @nomicfoundation/hardhat-ethers typescript ts-node @types/node @typechain/hardhat
npm install @openzeppelin/contracts
npx hardhat init

# Choisissez 'Create a TypeScript project' puis 'Yes, I want...' pour les exemples.
# Cela crée src/, test/, scripts/, hardhat.config.ts

This script sets up a complete TypeScript Hardhat project with the toolbox for compilation, tests, and ethers integration. OpenZeppelin provides secure ERC20/IERC20 standards. The src/ (contracts), test/, and scripts/ folders are generated automatically.

Configure Hardhat

Hardhat is the go-to tool for smart contract development in 2026: optimized compilation, mainnet forking for tests, and multi-chain deployment. We configure Solidity 0.8.24 (safe against overflow), local network, and Sepolia.

hardhat.config.ts

hardhat.config.ts
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

const config: HardhatUserConfig = {
  solidity: {
    version: "0.8.24",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  },
  networks: {
    hardhat: {},
    sepolia: {
      url: "https://rpc.sepolia.org",
      accounts: [process.env.PRIVATE_KEY || "0xVotreClePriveeIci"]
    }
  },
  typechain: {
    outDir: "typechain-types",
    target: "ethers-v6"
  }
};

export default config;

Optimized config for gas efficiency (optimizer runs=200) and security. Add your PRIVATE_KEY to .env (npm i dotenv && require('dotenv').config()). Typechain generates TS types for type-safe interactions.

Develop the ERC20 Tokens

Analogy: Tokens are like fiat currencies in a centralized exchange. We create TokenA and TokenB with 1M initial supply to the deployer, simulating a USDC/ETH pair.

TokenA.sol

src/TokenA.sol
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract TokenA is ERC20 {
    constructor() ERC20("Token A", "TKA") {
        _mint(msg.sender, 1_000_000 * 10**decimals());
    }
}

Standard OpenZeppelin ERC20 token, minting 1M units to the deployer (18 decimals). Copy for TokenB.sol, changing name/symbol to 'Token B'/'TKB'. Avoids unhandled decimals() pitfalls.

TokenB.sol

src/TokenB.sol
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract TokenB is ERC20 {
    constructor() ERC20("Token B", "TKB") {
        _mint(msg.sender, 1_000_000 * 10**decimals());
    }
}

Identical to TokenA but for the other pair. Use _mint for unlimited supply if needed, but fixed here for deterministic tests.

Implement the AMM Contract

The core AMM uses the constant product k = reserve0 * reserve1. Adding liquidity updates reserves and mints LP tokens proportional to √k. Swaps calculate output with a 0.3% fee (997/1000), preventing infinite arbitrage.

AMM.sol

src/AMM.sol
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";

contract AMM {
    IERC20 public immutable token0;
    IERC20 public immutable token1;

    uint public reserve0;
    uint public reserve1;
    uint public totalSupply;

    mapping(address => uint256) public balanceOf;

    event Mint(address indexed sender, uint amount0, uint amount1);
    event Swap(address indexed sender, uint amount0In, uint amount1Out);

    constructor(IERC20 _token0, IERC20 _token1) {
        token0 = _token0;
        token1 = _token1;
    }

    function addLiquidity(uint256 amount0, uint256 amount1) external {
        token0.transferFrom(msg.sender, address(this), amount0);
        token1.transferFrom(msg.sender, address(this), amount1);

        uint256 liquidity;
        if (totalSupply == 0) {
            liquidity = Math.sqrt(amount0 * amount1);
        } else {
            liquidity = min(
                (amount0 * totalSupply) / reserve0,
                (amount1 * totalSupply) / reserve1
            );
        }
        require(liquidity > 0, "Insufficient liquidity minted");

        balanceOf[msg.sender] += liquidity;
        totalSupply += liquidity;
        reserve0 += amount0;
        reserve1 += amount1;

        emit Mint(msg.sender, amount0, amount1);
    }

    function swap(uint256 amount0In) external returns (uint256 amount1Out) {
        require(amount0In > 0, "Insufficient input");
        uint256 balance0Adj = reserve0 + amount0In;
        amount1Out = getAmountOut(amount0In, reserve0, reserve1);

        token0.transferFrom(msg.sender, address(this), amount0In);
        token1.transfer(msg.sender, amount1Out);
        reserve0 = balance0Adj;
        reserve1 -= amount1Out;

        emit Swap(msg.sender, amount0In, amount1Out);
    }

    function getAmountOut(uint256 amountIn, uint256 res0, uint256 res1)
        public
        pure
        returns (uint256 amountOut)
    {
        require(amountIn > 0, "Insufficient input");
        require(res0 > 0 && res1 > 0, "Insufficient liquidity");
        uint256 amountInWithFee = amountIn * 997;
        amountOut = (amountInWithFee * res1) / (res0 * 1000 + amountInWithFee);
    }

    function min(uint256 x, uint256 y) private pure returns (uint256) {
        return x <= y ? x : y;
    }
}

Core contract: addLiquidity mints LP as √(amount0*amount1) initially, proportional later. TokenA→TokenB swaps with 0.3% fee, getAmountOut for frontend quotes. Avoids pitfalls: reentrancy via post-update transfers, immutables for gas savings.

Deployment Script

The script deploys TokenA, TokenB, then AMM. Run npx hardhat compile first. Note the addresses for interactions.

deploy.ts

scripts/deploy.ts
import { ethers } from "hardhat";

async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("Deploying with account:", deployer.address);

  const TokenA = await ethers.getContractFactory("TokenA");
  const tokenA = await TokenA.deploy();
  await tokenA.waitForDeployment();
  console.log("TokenA deployed to:", await tokenA.getAddress());

  const TokenB = await ethers.getContractFactory("TokenB");
  const tokenB = await TokenB.deploy();
  await tokenB.waitForDeployment();
  console.log("TokenB deployed to:", await tokenB.getAddress());

  const AMM = await ethers.getContractFactory("AMM");
  const amm = await AMM.deploy(tokenA.getAddress(), tokenB.getAddress());
  await amm.waitForDeployment();
  console.log("AMM deployed to:", await amm.getAddress());
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Sequential deployment with address logs. Use waitForDeployment() for confirmations. Add Etherscan verification if needed (hardhat-etherscan plugin).

Write and Run Tests

Tests validate addLiquidity (LP mint, reserves), swaps (correct output, applied fee, balances). Run npx hardhat test.

AMM.test.ts

test/AMM.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("AMM", function () {
  let tokenA: any, tokenB: any, amm: any, owner: any, user: any;

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

    const TokenA = await ethers.getContractFactory("TokenA");
    tokenA = await TokenA.deploy();

    const TokenB = await ethers.getContractFactory("TokenB");
    tokenB = await TokenB.deploy();

    const AMM = await ethers.getContractFactory("AMM");
    amm = await AMM.deploy(await tokenA.getAddress(), await tokenB.getAddress());

    // Transfer tokens to user for tests
    await tokenA.transfer(user.address, ethers.parseEther("1000"));
    await tokenB.transfer(user.address, ethers.parseEther("1000"));
  });

  it("should add liquidity", async function () {
    const amount0 = ethers.parseEther("100");
    const amount1 = ethers.parseEther("100");

    await tokenA.connect(user).approve(await amm.getAddress(), amount0);
    await tokenB.connect(user).approve(await amm.getAddress(), amount1);
    await amm.connect(user).addLiquidity(amount0, amount1);

    expect(await amm.reserve0()).to.equal(amount0);
    expect(await amm.reserve1()).to.equal(amount1);
    expect(await amm.balanceOf(user.address)).to.be.gt(0);
  });

  it("should swap token0 for token1", async function () {
    const amount0Liq = ethers.parseEther("100");
    const amount1Liq = ethers.parseEther("100");
    const amount0In = ethers.parseEther("10");

    // Add liquidity first
    await tokenA.connect(user).approve(await amm.getAddress(), amount0Liq);
    await tokenB.connect(user).approve(await amm.getAddress(), amount1Liq);
    await amm.connect(user).addLiquidity(amount0Liq, amount1Liq);

    const expectedOut = await amm.getAmountOut(amount0In, await amm.reserve0(), await amm.reserve1());
    const userTokenBBalanceBefore = await tokenB.balanceOf(user.address);

    await tokenA.connect(user).approve(await amm.getAddress(), amount0In);
    await amm.connect(user).swap(amount0In);

    const userTokenBBalanceAfter = await tokenB.balanceOf(user.address);
    expect(userTokenBBalanceAfter).to.equal(userTokenBBalanceBefore + expectedOut);
  });
});

Comprehensive tests: setup with transfer/approve, assertions on reserves/balances. Uses connect(user) for multi-account simulation. Covers edge cases like zero liquidity and fee in amountOut.

Deploy to Sepolia

terminal
npx hardhat compile
npm run test  # Vérifier tests
npx hardhat run scripts/deploy.ts --network sepolia

# Optionnel : verify
npx hardhat verify --network sepolia ADRESSE_AMM "ADRESSE_TOKENA" "ADRESSE_TOKENB"

Compile, test, deploy. Add hardhat-verify for Etherscan. On Sepolia, gas costs ~0.01 ETH. Check logs for addresses.

Best Practices

  • Security: Use audited OpenZeppelin, onlyOwner/pausable modifiers, Checks-Effects-Interactions pattern.
  • Gas Optimization: Immutables for token0/1, uint256 packing, optimizer runs>100.
  • Price Oracles: Integrate Chainlink for manipulation-resistant getAmountOut (not in this basic version).
  • LP Tokens as ERC20: Extend AMM with ERC20 LP for Uniswap V2 standard.
  • Frontend: Use wagmi/viem for approve/addLiquidity/swap, off-chain quotes.

Common Errors to Avoid

  • Forgetting approve(): Swap/addLiquidity reverts without authorized transferFrom.
  • No fee in getAmountOut: Leads to infinite arbitrage, use 997/1000.
  • Incorrect initial liquidity: Always √(r0*r1) for first mint.
  • Reentrancy: Transfers after reserve updates (CEI pattern).
  • Ignoring decimals: Use 10**decimals() for consistency.

Next Steps

  • Enhance with removeLiquidity, bidirectional swaps, flash swaps.
  • Upgrade to Uniswap V3 (concentrated liquidity).
  • Build React frontend: Wagmi AMM example.
  • Professional audit: OpenZeppelin Defender.
Discover our Blockchain & DeFi training courses to master Foundry, Vyper, and L2.