Build Your AMM
This is the main event. You will build a constant-product AMM pool from scratch.
The Math Behind It
An Automated Market Maker replaces order books with a simple formula:
x * y = kx is the reserve of Token A, y is the reserve of Token B, and k is a constant that must never increase after a swap.
Here is a concrete example:
- The pool holds
100TokenA and100TokenB. - Therefore
k = 100 * 100 = 10,000. - A user sends in
10TokenA. - New reserve0 =
110. - To keep
k = 10,000, new reserve1 =10,000 / 110 = 90(Solidity truncates decimals). - The user receives
100 - 90 = 10TokenB. - After the swap, check the invariant:
110 * 90 = 9,900, which is<=the originalk. It holds.
Your job is to turn this math into Solidity.
What You Need to Build
Before you start, deploy two ERC20 tokens from the ERC20 page. You will need their addresses.
Create a contract called SimpleAMM with these functions:
constructor(address _token0, address _token1), stores the two token contracts.addLiquidity(uint256 amount0, uint256 amount1), pulls both tokens from the caller, updates reserves, and setsk.getSwapAmountOut(uint256 amountIn, bool isToken0In), a view function that returns the output amount using thex * y = kmath.swap(uint256 amountIn, bool isToken0In), executes the actual token exchange.getReserves(), a view function that returns(reserve0, reserve1).
Starter Skeleton
Do not just copy-paste. Read the TODOs and think through each one before you write the code.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract SimpleAMM {
IERC20 public token0;
IERC20 public token1;
uint256 public reserve0;
uint256 public reserve1;
uint256 public k;
// TODO: constructor. Store _token0 and _token1.
function addLiquidity(uint256 amount0, uint256 amount1) external {
// TODO:
// 1. Reject if either amount is 0.
// 2. If reserves already exist, enforce the same ratio.
// Hint: cross-multiply to avoid division.
// amount0 * reserve1 == amount1 * reserve0
// 3. Pull both tokens from msg.sender using transferFrom.
// 4. Update reserve0 and reserve1.
// 5. Update k = reserve0 * reserve1.
}
function getSwapAmountOut(uint256 amountIn, bool isToken0In)
public
view
returns (uint256 amountOut)
{
// TODO:
// 1. Reject if amountIn is 0 or if there is no liquidity (k == 0).
// 2. If isToken0In:
// newReserve0 = reserve0 + amountIn
// newReserve1 = k / newReserve0
// amountOut = reserve1 - newReserve1
// Else do the mirror math for token1 -> token0.
}
function swap(uint256 amountIn, bool isToken0In) external {
// TODO:
// 1. Call getSwapAmountOut to know the output.
// 2. Reject if amountOut is 0.
// 3. Use transferFrom to pull the input token from the user.
// 4. Use transfer to push the output token to the user.
// 5. Update the reserves.
}
function getReserves() external view returns (uint256, uint256) {
// TODO: return both reserves.
}
}Remix Track
Your Task
Fill in the TODOs above. Compile your contract in Remix. If it compiles, you are halfway there.
Deploy and Verify
Deploy SimpleAMM with your two token addresses.
- Approve the AMM to spend
100tokens of each type. - Call
addLiquidity(100000000000000000000, 100000000000000000000). - Call
getReserves. You should see two equal numbers. - Call
k. It should equal the product of those two numbers. - Call
getSwapAmountOutwith10000000000000000000andtrue. This is a view function, it costs no gas. Note the predicted output. - Approve the input token again, then call
swapwith the same arguments. - Call
getReservesagain. Multiply the two numbers. Is the product<=the originalk? If yes, your invariant holds.
If you get stuck, re-read the math example at the top of the page. The contract is just that math translated into code.
Foundry Track
Your Task
Create src/SimpleAMM.sol and fill in the TODOs from the skeleton above.
Use forge build to check compilation. Fix errors until it compiles cleanly.
The Test
Here is a test file. Do not modify it. Your contract must make every assertion pass.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/MyToken.sol";
import "../src/SimpleAMM.sol";
contract SimpleAMMTest is Test {
MyToken tokenA;
MyToken tokenB;
SimpleAMM amm;
address alice = address(0x1);
function setUp() public {
tokenA = new MyToken("Token A", "TKA", 10000);
tokenB = new MyToken("Token B", "TKB", 10000);
amm = new SimpleAMM(address(tokenA), address(tokenB));
tokenA.transfer(alice, 1000 * 10 ** 18);
tokenB.transfer(alice, 1000 * 10 ** 18);
}
function testAddLiquidity() public {
vm.startPrank(alice);
tokenA.approve(address(amm), 100 * 10 ** 18);
tokenB.approve(address(amm), 100 * 10 ** 18);
amm.addLiquidity(100 * 10 ** 18, 100 * 10 ** 18);
vm.stopPrank();
(uint256 r0, uint256 r1) = amm.getReserves();
assertEq(r0, 100 * 10 ** 18);
assertEq(r1, 100 * 10 ** 18);
assertEq(amm.k(), r0 * r1);
}
function testSwapMaintainsInvariant() public {
vm.startPrank(alice);
tokenA.approve(address(amm), 100 * 10 ** 18);
tokenB.approve(address(amm), 100 * 10 ** 18);
amm.addLiquidity(100 * 10 ** 18, 100 * 10 ** 18);
uint256 predictedOut = amm.getSwapAmountOut(10 * 10 ** 18, true);
assertGt(predictedOut, 0);
tokenA.approve(address(amm), 10 * 10 ** 18);
amm.swap(10 * 10 ** 18, true);
vm.stopPrank();
(uint256 r0, uint256 r1) = amm.getReserves();
uint256 oldK = 100 * 10 ** 18 * 100 * 10 ** 18;
assertLe(r0 * r1, oldK);
assertGt(tokenB.balanceOf(alice), 900 * 10 ** 18);
}
}Save it as test/SimpleAMM.t.sol and run:
forge test -vvIf you see green checkmarks, your AMM works. If a test fails, read the failure message, it tells you exactly which assertion broke.
Next Step
Once your tests pass, head to the Deployment page to deploy your AMM to a real testnet.