Build a Constant-Product AMM on Rootstock (Testnet)
A Constant-Product Automated Market Maker (AMM) is a decentralized exchange mechanism that maintains liquidity using the formula x * y = k, where the product of two token reserves remains constant after swaps.
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
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.
Prerequisites
Prerequisites: Follow the Shared Setup Guide before starting.
Test Setup
We'll use Hardhat with ethers and Chai for testing. First, create a test file test/SimpleAMM.test.js.
You'll also need a mock ERC20 token for testing. Create contracts/ERC20Mock.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract ERC20Mock is ERC20 {
constructor(
string memory name,
string memory symbol,
uint8 decimals
) ERC20(name, symbol) {
_setupDecimals(decimals);
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function _setupDecimals(uint8 decimals_) internal {
_decimals = decimals_;
}
uint8 private _decimals;
}
Now create the test file:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("SimpleAMM", function () {
let tokenA, tokenB, amm, owner, user;
beforeEach(async function () {
[owner, user] = await ethers.getSigners();
// Deploy mock ERC-20 tokens
const Token = await ethers.getContractFactory("ERC20Mock");
tokenA = await Token.deploy("Token A", "TKA", 18);
tokenB = await Token.deploy("Token B", "TKB", 18);
await tokenA.deployed();
await tokenB.deployed();
// Deploy AMM
const AMM = await ethers.getContractFactory("SimpleAMM");
amm = await AMM.deploy(tokenA.address, tokenB.address);
await amm.deployed();
// Mint tokens to owner and approve AMM
await tokenA.mint(owner.address, ethers.utils.parseEther("1000"));
await tokenB.mint(owner.address, ethers.utils.parseEther("1000"));
await tokenA.approve(amm.address, ethers.utils.parseEther("1000"));
await tokenB.approve(amm.address, ethers.utils.parseEther("1000"));
});
// Tests go here
});
Test: Adding Initial Liquidity
it("Should add initial liquidity", async function () {
await amm.addLiquidity(ethers.utils.parseEther("100"), ethers.utils.parseEther("100"));
expect(await amm.reserveA()).to.equal(ethers.utils.parseEther("100"));
expect(await amm.reserveB()).to.equal(ethers.utils.parseEther("100"));
expect(await amm.totalLiquidity()).to.equal(ethers.utils.parseEther("100")); // sqrt(100e18 * 100e18) = 100e18
});
Test: Swapping
it("Should swap tokenA for tokenB", async function () {
await amm.addLiquidity(ethers.utils.parseEther("100"), ethers.utils.parseEther("100"));
const amountIn = ethers.utils.parseEther("10");
const expectedOut = await amm.getAmountOut(amountIn, ethers.utils.parseEther("100"), ethers.utils.parseEther("100"));
// expectedOut ≈ 9.07 (since 10*997 / (100*1000+9970) * 100 = 9970 / (100000+9970) * 100 = 9970/109970*100 ≈ 9.07)
await amm.swapAforB(amountIn, expectedOut.sub(1)); // allow slight slippage
const newReserveA = ethers.utils.parseEther("110");
const newReserveB = ethers.utils.parseEther("90.9..."); // roughly 90.909...
expect(await amm.reserveA()).to.be.closeTo(newReserveA, ethers.utils.parseEther("0.1"));
expect(await amm.reserveB()).to.be.closeTo(newReserveB, ethers.utils.parseEther("0.1"));
});
The closeTo matcher handles small rounding differences.
Test: Slippage Protection
it("Should revert if slippage too high", async function () {
await amm.addLiquidity(ethers.utils.parseEther("100"), ethers.utils.parseEther("100"));
const amountIn = ethers.utils.parseEther("10");
const expectedOut = await amm.getAmountOut(amountIn, ethers.utils.parseEther("100"), ethers.utils.parseEther("100"));
// Set minimum out higher than expected
await expect(amm.swapAforB(amountIn, expectedOut.add(1))).to.be.revertedWith("Slippage too high");
});
### Test: Removing Liquidity
```javascript
it("Should remove liquidity", async function () {
await amm.addLiquidity(ethers.utils.parseEther("100"), ethers.utils.parseEther("100"));
const lpTokens = await amm.liquidity(owner.address);
await amm.removeLiquidity(lpTokens);
expect(await amm.reserveA()).to.equal(0);
expect(await amm.reserveB()).to.equal(0);
expect(await tokenA.balanceOf(owner.address)).to.equal(ethers.utils.parseEther("1000"));
expect(await tokenB.balanceOf(owner.address)).to.equal(ethers.utils.parseEther("1000"));
});