:::warning[Understanding AMMs] An Automated Market Maker (AMM) uses a mathematical formula to set asset prices. This guide builds a minimal constant product AMM (x*y=k). It is for educational purposes – always audit before mainnet. ::: # Building an AMM on Rootstock In this guide, we'll build a simplified Automated Market Maker (AMM) from scratch. You'll learn how liquidity pools work, how swaps are priced, and how to interact with the AMM from a frontend. By the end, you'll have a working AMM contract that you can deploy on Rootstock testnet. ## What is an AMM? Traditional exchanges use order books (buyers and sellers). An AMM replaces the order book with a liquidity pool – a smart contract that holds reserves of two tokens. Users can swap between them at any time, and the price is determined algorithmically by the ratio of reserves. The most common formula is the **constant product**: x * y = k Where: - `x` = reserve of token A - `y` = reserve of token B - `k` = constant (product of reserves) This means that after any trade, the product of the reserves must remain unchanged (ignoring fees). If you buy token A, you add token B to the pool and remove token A, keeping `x*y` constant. ## Core Concepts - **Liquidity Pool**: A contract holding two token reserves. - **LP Tokens**: Represent a liquidity provider's share of the pool. When you add liquidity, you receive LP tokens that can be redeemed later for your share. - **Constant Product Formula**: The pricing rule `x*y=k`. - **Slippage**: The difference between the expected price and the executed price due to pool size. Traders set a minimum amount out to protect against slippage. - **Fee**: Usually 0.3% of the trade is added to the pool as a reward for liquidity providers. ## 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 ```solidity // 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. ```solidity 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. ```solidity 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. ```solidity 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: ```text 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. ```solidity 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. ```solidity 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 First, make sure you have the necessary testing dependencies installed: ```bash npm install --save-dev hardhat @nomiclabs/hardhat-ethers ethers chai ``` Your `hardhat.config.js` should include: ```javascript require("@nomiclabs/hardhat-ethers"); module.exports = { solidity: "0.8.0", }; ``` ### 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`: ```solidity // 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: ```javascript 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 ```javascript 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 ```javascript 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 ```javascript 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")); }); ## Integrating with a React Frontend To interact with the AMM from a web app, you'll need: ethers.js or web3.js AMM contract address and ABI User's wallet (e.g., MetaMask) ### Getting the Contract ABI First, compile your Solidity contract to generate the ABI: ```bash npx hardhat compile ``` This will create the ABI in `artifacts/contracts/SimpleAMM.sol/SimpleAMM.json`. Copy this file to your frontend project and import it as shown below. ### Example: Swap Component ```jsx import AMM_ABI from './SimpleAMM.json'; // ABI from compilation const Swap = ({ ammAddress }) => { const [amountIn, setAmountIn] = useState(''); const [loading, setLoading] = useState(false); const swap = async () => { if (!window.ethereum) return alert('Install MetaMask'); setLoading(true); try { const provider = new ethers.providers.Web3Provider(window.ethereum); const signer = provider.getSigner(); const amm = new ethers.Contract(ammAddress, AMM_ABI, signer); // Approve tokenA spending (if not already approved) const tokenAAddress = await amm.tokenA(); const tokenA = new ethers.Contract(tokenAAddress, ['function approve(address spender, uint256 amount)'], signer); const txApprove = await tokenA.approve(ammAddress, ethers.utils.parseEther(amountIn)); await txApprove.wait(); // Get expected output (optional) const reserveA = await amm.reserveA(); const reserveB = await amm.reserveB(); const expectedOut = await amm.getAmountOut(ethers.utils.parseEther(amountIn), reserveA, reserveB); const minOut = expectedOut.mul(95).div(100); // 5% slippage tolerance // Execute swap const txSwap = await amm.swapAforB(ethers.utils.parseEther(amountIn), minOut); await txSwap.wait(); alert('Swap successful!'); } catch (error) { console.error(error); alert('Swap failed'); } setLoading(false); }; return (