Skip to content
Learni
View all tutorials
DeFi

Comment créer un AMM DeFi simple avec Solidity en 2026

Introduction

La Finance Décentralisée (DeFi) révolutionne les services financiers traditionnels en éliminant les intermédiaires grâce à la blockchain Ethereum. Au cœur de nombreux protocoles DeFi comme Uniswap ou SushiSwap se trouve l'Automated Market Maker (AMM), un mécanisme utilisant une courbe de prix mathématique (produit constant k = x * y) pour permettre des échanges décentralisés.

Ce tutoriel intermédiaire vous guide pas à pas pour créer un AMM basique en Solidity : deux tokens ERC20, un pool de liquidité avec ajout/retrait, swaps unidirectionnels avec frais de 0,3 %, et invariant constant. Vous apprendrez à configurer Hardhat pour le développement, les tests et le déploiement sur Sepolia.

Pourquoi c'est crucial en 2026 ? Avec l'essor des L2 et la maturité d'Ethereum, comprendre les AMM permet de bâtir des DEX, yield farms ou flash loans. Chaque ligne de code est fonctionnelle, copier-collable, et illustrée. À la fin, vous déployez votre propre exchange DeFi (environ 120 mots).

Prérequis

  • Node.js 20+ et npm/yarn
  • Connaissances basiques en Solidity (ERC20, modifiers)
  • MetaMask avec ETH Sepolia (pour déploiement)
  • RPC Sepolia gratuit (Alchemy ou Infura)
  • Clé privée deployer (sauvegardez-la sécuritairement)

Initialiser le projet Hardhat

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

Ce script initialise un projet Hardhat TypeScript complet avec toolbox pour compilation, tests et ethers. OpenZeppelin fournit les standards ERC20/IERC20 sécurisés. Les dossiers src/ (contrats), test/, scripts/ sont générés automatiquement.

Configurer Hardhat

Hardhat est l'outil de référence pour développer des smart contracts en 2026 : compilation optimisée, fork de mainnet pour tests, et déploiement multi-chaînes. Nous configurons Solidity 0.8.24 (sûr contre overflow), réseau local et 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;

Configuration optimisée pour gas (optimizer runs=200) et sécurité. Ajoutez votre PRIVATE_KEY dans .env (npm i dotenv && require('dotenv').config()). Typechain génère des types TS pour interactions type-safe.

Développer les tokens ERC20

Analogie : Les tokens sont comme des devises fiat dans un exchange centralisé. Nous créons TokenA et TokenB avec 1M supply initial au deployer, pour simuler un paire USDC/ETH.

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());
    }
}

Token ERC20 standard OpenZeppelin, mint 1M unités au deployer (18 decimals). Copiez pour TokenB.sol en changeant nom/symbole en 'Token B'/'TKB'. Évite les pièges de decimals() non géré.

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());
    }
}

Identique à TokenA mais paire différente. Utilisez _mint pour supply illimité si besoin, mais fixe ici pour tests déterministes.

Implémenter le contrat AMM

L'AMM core utilise produit constant k = reserve0 * reserve1. Ajout liquidité met à jour réserves et mint LP tokens proportionnels à √k. Swap calcule output avec fee 0.3% (997/1000), évitant arbitrage infini.

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;
    }
}

Contrat core : addLiquidity mint LP √(amount0*amount1) initial, proportionnel ensuite. Swap TokenA→TokenB avec fee 0.3%, getAmountOut pour front-end quotes. Piège évité : reentrancy via transfers post-update, immutable pour gas.

Script de déploiement

Le script déploie TokenA, TokenB puis AMM. Utilisez npx hardhat compile avant. Vérifiez adresses pour 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;
});

Déploiement séquentiel avec logs adresses. Utilisez waitForDeployment() pour confirmations. Ajoutez verification Etherscan si besoin (hardhat-etherscan plugin).

Écrire et exécuter les tests

Les tests valident addLiquidity (LP mint, réserves), swap (amountOut correct, fee appliqué, balances). Exécutez 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);
  });
});

Tests exhaustifs : setup avec transfer/approve, assertions sur réserves/balances. Utilise connect(user) pour multi-comptes. Couvre edge-case liquidity=0 et fee dans amountOut.

Déployer sur 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, déploie. Ajoutez hardhat-verify pour Etherscan. Sur Sepolia, gas ~0.01 ETH. Vérifiez logs pour adresses.

Bonnes pratiques

  • Sécurité : Utilisez OpenZeppelin audits, modifiers onlyOwner/pausable, Checks-Effects-Interactions pattern.
  • Gas optimisation : Immutables pour token0/1, uint256 packing, optimizer runs>100.
  • Prix oracles : Intégrez Chainlink pour getAmountOut anti-manipulation (pas dans ce basic).
  • LP tokens ERC20 : Étendez AMM avec ERC20 LP pour standard Uniswap V2.
  • Frontend : Utilisez wagmi/viem pour approve/addLiquidity/swap, quotes off-chain.

Erreurs courantes à éviter

  • Oublier approve() : Swap/addLiquidity revert sans transferFrom autorisé.
  • Pas de fee dans getAmountOut : Arbitrage infini, utilisez 997/1000.
  • Liquidity initial mal calculé : Toujours √(r0*r1) pour première mint.
  • Reentrancy : Transfers après updates réserves (CEI pattern).
  • Decimals ignorés : Utilisez 10**decimals() pour cohérence.

Pour aller plus loin

  • Améliorez avec removeLiquidity, bidirectional swaps, flash swaps.
  • Migrez vers Uniswap V3 (concentrated liquidity).
  • Intégrez frontend React : Exemple wagmi AMM.
  • Audit pro : OpenZeppelin Defender.
Découvrez nos formations Blockchain & DeFi Learni pour maîtriser Foundry, Vyper et L2.