
MonoDEX — Building a Constant-Product AMM from Scratch
I built MonoDEX, a decentralized exchange implementing the classic constant-product market maker — the same mechanics that power Uniswap V2. One contract handles pair creation, liquidity provision, and single-hop swaps, with native ETH support built in. It's deployed and live on Sepolia.
The Core Math
At the heart of any constant-product AMM is the invariant x * y = k. A swap must preserve the product of the two reserves, which gives the output-amount formula with a fee applied to the input:
function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) public view returns (uint256) {
uint256 amountInWithFee = amountIn * (FEE_DENOM - feeBP) / FEE_DENOM;
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = reserveIn + amountInWithFee;
return numerator / denominator;
}
With feeBP defaulting to 300 basis points (0.30%), the same math powers both quotes and actual swaps.
Pairs and Canonical Ordering
Every token pair has a unique pairId — the keccak hash of the two addresses in sorted order. Sorted order matters: tokenA/tokenB and tokenB/tokenA must resolve to the same pool, and the contract needs a canonical token0/token1 to store reserves consistently:
function _pairId(address tokenA, address tokenB) public pure returns (bytes32) {
(address t0, address t1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
return keccak256(abi.encodePacked(t0, t1));
}
Pairs are created lazily — the first liquidity provider for a pair auto-creates it.
Native ETH as a First-Class Token
A token address of address(0) means ETH. The _safeTransferFrom and _safeTransfer helpers branch on this: for ETH they check msg.value and forward the value; for ERC20s they call transferFrom/transfer and verify the return value properly:
function _safeTransfer(address token, address to, uint256 amount) internal {
if (token == address(0)) {
(bool ok,) = payable(to).call{value: amount}("");
require(ok, "MonoDEX: ETH_TRANSFER_FAILED");
} else {
(bool ok, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transfer.selector, to, amount));
require(ok && (data.length == 0 || abi.decode(data, (bool))), "MonoDEX: TRANSFER_FAILED");
}
}
This means users can swap ETH for tokens (and tokens for ETH) directly through the same swapExactTokensForTokens path — the contract pulls input, applies the invariant, and pays out the output.
Liquidity Provision
addLiquidity follows the standard AMM flow:
- First deposit — mints
sqrt(amount0 * amount1) - MINIMUM_LIQUIDITYLP tokens, permanently locking the minimum to address(0) so the pool's ratio can never be trivially manipulated to zero - Subsequent deposits — mints LP proportional to the smaller of the two reserve ratios
- Reserves are stored as
uint112(like Uniswap) to leave headroom for overflow checks within a single 256-bit word
Removal works in reverse — LP tokens are burned, reserves are drawn down, and both tokens are sent to the recipient.
Single-Hop Swaps
swapExactTokensForTokens pulls the input, determines the reserve mapping by token order, applies the fee-adjusted formula, checks slippage against minOut, and updates reserves with the fee-inclusive input. Updating reserves before the external transfer out keeps the contract safe against reentrancy alongside the nonReentrant guard.
Frontend
The frontend is a Vite + React + TypeScript app using wagmi, viem, and RainbowKit for wallet management. It ships two pages:
- Swap — pick tokens, get a live quote from
getAmountOut, approve if needed, and execute - Pool — add and remove liquidity, with per-token approval flows
Token selection uses an on-chain token lookup, and every mutation surfaces a transaction modal so the user sees exactly what's happening. Supabase handles the off-chain token list.
Testing
The Foundry suite covers the AMM's core invariants:
forge test
- Pair creation and duplicate-pair rejection
- First and subsequent liquidity provision
- Swap math and slippage enforcement
- ETH and ERC20 paths, including token transfers that return no data
Deployment
The contract is deployed on Sepolia at 0xc7549caf1580a1a556625dE2245D093AA601B67f. The live app is at monodex.foo.ng.
Reflections
Building an AMM from scratch rather than forking Uniswap makes the mechanics concrete. The details that look like boilerplate — canonical token ordering, the minimum-liquidity burn, zero-return-value ERC20 handling — are actually the difference between a DEX that works and one that silently breaks. The result is a fully working, deployed DEX you can actually swap on.