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
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.tsCe 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
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
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
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
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
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
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
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.