Build a Constant-Product AMM on Rootstock (Testnet)
By the end of this tutorial, you will have:
- A working
SimpleAMMconstant‑product AMM contract deployed on Rootstock testnet - A Hardhat test suite that validates add/remove liquidity and swaps
- A basic example of how to wire the contract into a frontend
Prerequisites: Follow the Shared Setup Guide before starting.
For background concepts, token standards, and security review, see Rootstock DeFi 101.
This tutorial is a minimal implementation for learning and experimentation. Before shipping to production, review Rootstock DeFi 101 and get professional audits.
Our SimpleAMM Contract
We'll build a contract that supports:
- Adding liquidity
- Removing liquidity
- Swapping token A for token B (and vice versa)
- Computing swap amounts
1. Contract Setup and State Variables
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract SimpleAMM {
IERC20 public tokenA;
IERC20 public tokenB;
uint256 public reserveA;
uint256 public reserveB;
uint256 public totalLiquidity;
mapping(address => uint256) public liquidity;
// Events for tracking
event LiquidityAdded(address indexed provider, uint256 amountA, uint256 amountB);
event LiquidityRemoved(address indexed provider, uint256 amountA, uint256 amountB);
event Swapped(address indexed swapper, address tokenIn, uint256 amountIn, address tokenOut, uint256 amountOut);
constructor(address _tokenA, address _tokenB) {
tokenA = IERC20(_tokenA);
tokenB = IERC20(_tokenB);
}
// ... functions will go here
}
Explanation:
-
tokenA and tokenB are the ERC-20 tokens the pool will trade.
-
reserveA and reserveB track the current reserves in the pool.
-
totalLiquidity is the total supply of LP tokens.
-
liquidity maps each address to their LP token balance.
-
Events help off-chain monitoring (e.g., for a frontend).
2. Adding Liquidity
Liquidity providers (LPs) deposit an equivalent value of both tokens. The number of LP tokens they receive depends on the current pool size.
function addLiquidity(uint256 amountA, uint256 amountB) external {
require(amountA > 0 && amountB > 0, "Amounts must be >0");
// Transfer tokens from user to contract
tokenA.transferFrom(msg.sender, address(this), amountA);
tokenB.transferFrom(msg.sender, address(this), amountB);
uint256 lpTokens;
if (totalLiquidity == 0) {
// First deposit: LP tokens = sqrt(amountA * amountB)
lpTokens = sqrt(amountA * amountB);
} else {
// Subsequent deposits: proportional to existing reserves
lpTokens = min(
(amountA * totalLiquidity) / reserveA,
(amountB * totalLiquidity) / reserveB
);
}
require(lpTokens > 0, "Insufficient liquidity minted");
liquidity[msg.sender] += lpTokens;
totalLiquidity += lpTokens;
reserveA += amountA;
reserveB += amountB;
emit LiquidityAdded(msg.sender, amountA, amountB);
}
How it works:
The user must first approve the contract to spend their tokens (done off-chain).
For the first deposit, we set LP tokens to the geometric mean (sqrt(amountA * amountB)) to avoid rounding issues.
For later deposits, the LP tokens are calculated proportionally to the smaller contribution relative to existing reserves. This ensures fairness.
Reserves and totalLiquidity are updated.
The user receives LP tokens representing their share.
3. Removing Liquidity
LP holders can burn their LP tokens to withdraw their share of reserves.
function removeLiquidity(uint256 lpTokens) external {
require(lpTokens > 0 && liquidity[msg.sender] >= lpTokens, "Insufficient LP tokens");
uint256 amountA = (lpTokens * reserveA) / totalLiquidity;
uint256 amountB = (lpTokens * reserveB) / totalLiquidity;
require(amountA > 0 && amountB > 0, "Insufficient tokens withdrawn");
liquidity[msg.sender] -= lpTokens;
totalLiquidity -= lpTokens;
reserveA -= amountA;
reserveB -= amountB;
tokenA.transfer(msg.sender, amountA);
tokenB.transfer(msg.sender, amountB);
emit LiquidityRemoved(msg.sender, amountA, amountB);
}
Calculation:
The user's share = lpTokens / totalLiquidity.
Multiply that share by each reserve to get amounts to withdraw.
4. Swapping Tokens
The core of an AMM: users trade one token for the other. We'll implement two functions: swapAforB and swapBforA.
function swapAforB(uint256 amountAIn, uint256 amountBOutMin) external {
require(amountAIn > 0, "Amount in must be >0");
uint256 amountBOut = getAmountOut(amountAIn, reserveA, reserveB);
require(amountBOut >= amountBOutMin, "Slippage too high");
require(amountBOut <= reserveB, "Insufficient liquidity");
tokenA.transferFrom(msg.sender, address(this), amountAIn);
tokenB.transfer(msg.sender, amountBOut);
reserveA += amountAIn;
reserveB -= amountBOut;
emit Swapped(msg.sender, address(tokenA), amountAIn, address(tokenB), amountBOut);
}
function swapBforA(uint256 amountBIn, uint256 amountAOutMin) external {
require(amountBIn > 0, "Amount in must be >0");
uint256 amountAOut = getAmountOut(amountBIn, reserveB, reserveA);
require(amountAOut >= amountAOutMin, "Slippage too high");
require(amountAOut <= reserveA, "Insufficient liquidity");
tokenB.transferFrom(msg.sender, address(this), amountBIn);
tokenA.transfer(msg.sender, amountAOut);
reserveB += amountBIn;
reserveA -= amountAOut;
emit Swapped(msg.sender, address(tokenB), amountBIn, address(tokenA), amountAOut);
}
Key points:
getAmountOut computes the output amount based on the constant product formula with a 0.3% fee.
amountBOutMin protects the user from slippage – the transaction reverts if the actual output is less than this minimum.
Reserves are updated after the swap.
5. Computing Output Amount
The core formula for a swap (with fee) is:
amountInWithFee = amountIn * 997
numerator = amountInWithFee * reserveOut
denominator = (reserveIn * 1000) + amountInWithFee
amountOut = numerator / denominator
This is derived from:
New reserveIn' = reserveIn + amountIn (but with fee, only 99.7% of amountIn actually enters the pool, the rest stays as fee).
The product (reserveIn + 0.997*amountIn) * (reserveOut - amountOut) = reserveIn * reserveOut.
function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) public pure returns (uint256) {
uint256 amountInWithFee = amountIn * 997; // 0.3% fee
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = (reserveIn * 1000) + amountInWithFee;
return numerator / denominator;
}
6. Utility Functions: sqrt and min
We need a square root function for initial LP token calculation, and a min function.
function sqrt(uint256 y) internal 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;
}
}
function min(uint256 a, uint256 b) internal pure returns (uint256) {
return a < b ? a : b;
}
The sqrt function implements the Babylonian method (Newton's method) for integer square roots.
Deploying and Testing with Hardhat
Now we'll write tests to ensure our AMM works correctly.