# Construire un AMM facon Uniswap V2 a partir de zero
Les teneurs de marche automatises (AMM) ont remplace le carnet d'ordres par une simple equation. Au lieu d'apparier acheteurs et vendeurs, un pool Uniswap V2 detient deux reserves de tokens et laisse chacun echanger contre elles a un prix que les maths fixent automatiquement. Dans ce tutoriel, on reconstruit un contrat Pair minimal a partir de zero pour que le fameux invariant, les frais et la comptabilite de liquidite cessent d'etre de la magie.
L'invariant du produit constant
Un pool detient deux reserves, appelons-les x et y. La regle centrale est :
x * y = k
Le produit k ne doit jamais diminuer pendant un swap. Quand un trader ajoute du token X au pool, la reserve de X augmente, donc la reserve de Y doit baisser pour garder le produit au moins egal a k. Ce Y qui baisse, c'est exactement ce que le trader recoit.
Deux consequences decoulent de cette unique equation :
- Le prix n'est qu'un ratio. Le prix marginal de X en termes de Y vaut
y / x. A mesure que vous achetez du Y,xmonte etybaisse, donc chaque unite de Y coute plus cher que la precedente. C'est le slippage, integre dans la courbe et non rajoute par-dessus.
- Le pool ne peut jamais etre entierement vide. Quand
xtend vers l'infini, le prix de Y tend vers l'infini aussi, donc vous ne pouvez jamais sortir la derniere unite. Les reserves sont asymptotiques.
Ajouter de la liquidite et minter des LP tokens
Les fournisseurs de liquidite deposent les deux tokens et recoivent des LP tokens qui representent leur part du pool. Le contrat doit minter ces LP tokens de facon proportionnelle pour qu'aucun fournisseur ne puisse en diluer un autre.
La regle utilisee par Uniswap V2 :
- Pour le premier depot, l'offre de LP vaut
sqrt(amount0 * amount1), moins une petite quantite verrouillee a jamais (MINIMUM_LIQUIDITY, 1000 wei). Verrouiller cette poussiere empeche un attaquant de manipuler le prix de la part quand l'offre est proche de zero.
- Pour les depots suivants, le contrat mint
min(amount0 * totalSupply / reserve0, amount1 * totalSupply / reserve1). Utiliser le minimum force les deposants a ajouter les tokens dans le ratio courant. Tout ce que vous ajoutez au-dela du ratio est un cadeau aux detenteurs existants.
La moyenne geometrique (sqrt) pour le premier mint rend la quantite initiale de LP independante des decimales absolues des tokens et resistante a une inflation a bas cout.
Le swap avec des frais de 0,3 pour cent
Chaque swap preleve des frais de 0,3 pour cent qui restent dans le pool : c'est ainsi que les fournisseurs de liquidite gagnent leur revenu. Uniswap V2 exprime cela elegamment en verifiant l'invariant apres avoir soustrait les frais du montant d'entree.
La formule canonique getAmountOut, pour une entree amountIn contre les reserves reserveIn et reserveOut :
amountInWithFee = amountIn * 997
numerator = amountInWithFee * reserveOut
denominator = reserveIn * 1000 + amountInWithFee
amountOut = numerator / denominator
Le facteur 997 / 1000 represente les frais de 0,3 pour cent. Notez que les frais sont appliques a l'entree, puis le calcul du produit constant tourne sur le montant ajuste. Comme tout est de l'arithmetique entiere, multipliez avant de diviser pour eviter la troncature, et ne reordonnez jamais les operations.
Un contrat Pair minimal
Voici un Pair compact mais correct. Il garde l'essentiel (reserves, mint, burn, swap, la verification des frais) et utilise une base ERC20 simple pour le LP token. Il omet volontairement l'oracle de prix et les hooks de flash swap pour que le coeur reste lisible.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function balanceOf(address) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
function sqrt(uint256 y) pure returns (uint256 z) {
if (y > 3) {
z = y;
uint256 x = y / 2 + 1;
while (x < z) {
z = x;
x = (y / x + x) / 2;
}
} else if (y != 0) {
z = 1;
}
}
contract MiniPair {
uint256 public constant MINIMUM_LIQUIDITY = 1000;
address public immutable token0;
address public immutable token1;
uint112 private reserve0;
uint112 private reserve1;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
uint256 private unlocked = 1;
modifier lock() {
require(unlocked == 1, "LOCKED");
unlocked = 0;
_;
unlocked = 1;
}
constructor(address _token0, address _token1) {
token0 = _token0;
token1 = _token1;
}
function getReserves() public view returns (uint112, uint112) {
return (reserve0, reserve1);
}
function _mint(address to, uint256 value) private {
totalSupply += value;
balanceOf[to] += value;
}
function _burn(address from, uint256 value) private {
balanceOf[from] -= value;
totalSupply -= value;
}
function _update(uint256 bal0, uint256 bal1) private {
require(bal0 <= type(uint112).max && bal1 <= type(uint112).max, "OVERFLOW");
reserve0 = uint112(bal0);
reserve1 = uint112(bal1);
}
// L'appelant doit transferer les tokens vers ce contrat avant d'appeler mint.
function mint(address to) external lock returns (uint256 liquidity) {
(uint112 r0, uint112 r1) = getReserves();
uint256 bal0 = IERC20(token0).balanceOf(address(this));
uint256 bal1 = IERC20(token1).balanceOf(address(this));
uint256 amount0 = bal0 - r0;
uint256 amount1 = bal1 - r1;
if (totalSupply == 0) {
liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
_mint(address(0), MINIMUM_LIQUIDITY); // verrouille a jamais
} else {
uint256 l0 = (amount0 * totalSupply) / r0;
uint256 l1 = (amount1 * totalSupply) / r1;
liquidity = l0 < l1 ? l0 : l1;
}
require(liquidity > 0, "INSUFFICIENT_LIQUIDITY_MINTED");
_mint(to, liquidity);
_update(bal0, bal1);
}
// L'appelant doit transferer les LP tokens vers ce contrat avant d'appeler burn.
function burn(address to) external lock returns (uint256 amount0, uint256 amount1) {
uint256 bal0 = IERC20(token0).balanceOf(address(this));
uint256 bal1 = IERC20(token1).balanceOf(address(this));
uint256 liquidity = balanceOf[address(this)];
amount0 = (liquidity * bal0) / totalSupply;
amount1 = (liquidity * bal1) / totalSupply;
require(amount0 > 0 && amount1 > 0, "INSUFFICIENT_LIQUIDITY_BURNED");
_burn(address(this), liquidity);
require(IERC20(token0).transfer(to, amount0), "T0_FAIL");
require(IERC20(token1).transfer(to, amount1), "T1_FAIL");
_update(
IERC20(token0).balanceOf(address(this)),
IERC20(token1).balanceOf(address(this))
);
}
// L'appelant envoie d'abord les tokens d'entree, puis demande amountOut.
function swap(uint256 amount0Out, uint256 amount1Out, address to) external lock {
require(amount0Out > 0 || amount1Out > 0, "INSUFFICIENT_OUTPUT");
(uint112 r0, uint112 r1) = getReserves();
require(amount0Out < r0 && amount1Out < r1, "INSUFFICIENT_LIQUIDITY");
if (amount0Out > 0) require(IERC20(token0).transfer(to, amount0Out), "T0_FAIL");
if (amount1Out > 0) require(IERC20(token1).transfer(to, amount1Out), "T1_FAIL");
uint256 bal0 = IERC20(token0).balanceOf(address(this));
uint256 bal1 = IERC20(token1).balanceOf(address(this));
uint256 amount0In = bal0 > r0 - amount0Out ? bal0 - (r0 - amount0Out) : 0;
uint256 amount1In = bal1 > r1 - amount1Out ? bal1 - (r1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, "INSUFFICIENT_INPUT");
// Applique les frais de 0,3%, puis impose x*y=k sur les soldes ajustes.
uint256 bal0Adj = bal0 * 1000 - amount0In * 3;
uint256 bal1Adj = bal1 * 1000 - amount1In * 3;
require(
bal0Adj * bal1Adj >= uint256(r0) * uint256(r1) * (1000 ** 2),
"K"
);
_update(bal0, bal1);
}
}
Remarquez le schema : le contrat ne tire jamais les tokens avec transferFrom a l'interieur de swap. Le routeur (ou l'appelant) envoie d'abord les tokens d'entree, puis appelle swap. Le Pair verifie seulement que les soldes finaux respectent l'invariant. Ce design de transfert optimiste est ce qui permet ensuite les flash swaps.
Une fonction d'aide pour les appelants
La plupart des utilisateurs ne calculent pas amount0Out a la main. Un routeur enveloppe le calcul. La fonction pure de cotation reprend la formule ci-dessus :
function getAmountOut(
uint256 amountIn,
uint256 reserveIn,
uint256 reserveOut
) pure returns (uint256 amountOut) {
require(amountIn > 0, "INSUFFICIENT_INPUT");
require(reserveIn > 0 && reserveOut > 0, "INSUFFICIENT_LIQUIDITY");
uint256 amountInWithFee = amountIn * 997;
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = reserveIn * 1000 + amountInWithFee;
amountOut = numerator / denominator;
}
Notes de securite
Un AMM jouet est facile. Un AMM sur ne l'est pas. Surveillez ces points :
lock protege chaque fonction qui modifie l'etat. Sans lui, un token malveillant avec un callback dans transfer pourrait re-entrer dans swap en pleine execution et casser la verification de l'invariant.balanceOf apres avoir envoye la sortie et apres l'arrivee de l'entree. Faire confiance aux nombres fournis par l'appelant plutot qu'aux soldes on chain est un vecteur de vol classique.transfer, le solde que lit le Pair est inferieur a l'attendu et la verification de K peut echouer ou, pire, la comptabilite derive. Les pools soit bloquent ces tokens, soit documentent le risque.* 1000, * 997) exactement comme montre. Reordonner arrondit silencieusement de la valeur hors du pool.getReserves est trivialement manipulable au sein d'un seul bloc. Le code de production accumule plutot un prix moyen pondere dans le temps (TWAP). Ce Pair minimal n'a pas d'oracle, donc n'utilisez jamais son prix instantane comme source de verite.Mettez-le en pratique
Lire l'invariant est une chose. Voir k tenir pendant que vous poussez des trades, ou voir un token a frais de transfert corrompre les reserves, fait tout comprendre. Vous pouvez construire et casser un Pair comme celui-ci, etape par etape, dans l'environnement Solidity interactif sur app.solingo-blockchain.xyz. Deployez le contrat, ajoutez de la liquidite, lancez un swap et verifiez que le produit ne descend jamais sous k.