π¦ Day 6 of 7: Building a Mini Uniswap in 80 Lines of Solidity
Imagine a vending machine. It has 1,000 coffee beans and 1,000 coins. No menu, no cashier β just one iron rule: the product of the two numbers inside must never decrease.
That's it!
This is how Uniswap works β and this is what I built on Day 6, coming from .NET. Here's how, why it's elegant, and where you can step on a rake.
Why an Order Book Doesn't Work on a Blockchain
Traditional exchanges β Binance, NYSE, any CEX β run on an order book. Market makers post bids and asks. A matching engine pairs them. Millions of updates per second, all in a centralised database.
In a blockchain, this is impossible. Transactions take 12 seconds. Every state change costs gas. Storing millions of constantly changing orders would eat all the profit before a single trade completes.
Uniswap's solution: replace the order book with a liquidity pool β a smart contract holding two tokens β and replace the matching engine with pure math.
Just a formula β below.
x Β· y = k β The Formula That Broke Finance
The Constant Product Invariant:
x Β· y = k
Where x is the reserve of Token0, y is the reserve of Token1, and k is a constant that must never decrease during swaps.
When a trader sells Token0 into the pool, x increases. To keep k constant, y must decrease β the contract sends out Token1. The price is determined automatically by the ratio of reserves.
Live example with numbers:
Pool: 1,000 Token0, 1,000 Token1. k = 1,000,000.
Trader sells 100 Token0:
amountOut = (reserveOut Γ amountIn) / (reserveIn + amountIn)
amountOut = (1000 Γ 100) / (1000 + 100)
amountOut = 100,000 / 1,100
amountOut β 90.9 Token1
The trader gets ~90.9, not 100. That gap is slippage β and it's not a bug. It's the formula protecting the pool. The more you buy relative to pool size, the worse your price gets. Naturally. Mathematically.
After the swap: pool has 1,100 Token0 and ~909.1 Token1. k β 1,000,000. Invariant holds.
The Contract: SimpleAMM
Three functions. Each one exists for a specific reason.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract SimpleAMM {
error ZeroAmount();
error InvalidToken();
error ZeroLiquidity();
error TransferFailed();
error InvalidRatio();
IERC20 public immutable token0;
IERC20 public immutable token1;
// Internal reserves β cheaper than calling balanceOf() every time
uint256 public reserve0;
uint256 public reserve1;
event LiquidityAdded(address indexed provider, uint256 amount0, uint256 amount1);
event Swap(address indexed trader, address tokenIn, uint256 amountIn, uint256 amountOut);
constructor(address _token0, address _token1) {
token0 = IERC20(_token0);
token1 = IERC20(_token1);
}
// Pure math β no state, no side effects
function getAmountOut(uint256 _amountIn, uint256 _reserveIn, uint256 _reserveOut)
public pure returns (uint256)
{
if (_amountIn == 0) revert ZeroAmount();
if (_reserveIn == 0 || _reserveOut == 0) revert ZeroLiquidity();
// Multiply first, divide last β always
uint256 numerator = _reserveOut * _amountIn;
uint256 denominator = _reserveIn + _amountIn;
return numerator / denominator;
}
function addLiquidity(uint256 _amount0, uint256 _amount1) external {
if (_amount0 == 0 || _amount1 == 0) revert ZeroAmount();
// If pool already has liquidity, enforce the current price ratio
if (reserve0 > 0 && reserve1 > 0) {
if (_amount0 * reserve1 != _amount1 * reserve0) revert InvalidRatio();
}
if (!token0.transferFrom(msg.sender, address(this), _amount0)) revert TransferFailed();
if (!token1.transferFrom(msg.sender, address(this), _amount1)) revert TransferFailed();
reserve0 += _amount0;
reserve1 += _amount1;
emit LiquidityAdded(msg.sender, _amount0, _amount1);
}
function swap(address _tokenIn, uint256 _amountIn) external returns (uint256 amountOut) {
if (_amountIn == 0) revert ZeroAmount();
if (_tokenIn != address(token0) && _tokenIn != address(token1)) revert InvalidToken();
bool isToken0 = _tokenIn == address(token0);
(IERC20 tokenIn, IERC20 tokenOut, uint256 reserveIn, uint256 reserveOut) = isToken0
? (token0, token1, reserve0, reserve1)
: (token1, token0, reserve1, reserve0);
// CEI: pull tokens in first
if (!tokenIn.transferFrom(msg.sender, address(this), _amountIn)) revert TransferFailed();
// Calculate output
amountOut = getAmountOut(_amountIn, reserveIn, reserveOut);
// Update reserves
if (isToken0) {
reserve0 += _amountIn;
reserve1 -= amountOut;
} else {
reserve0 -= amountOut;
reserve1 += _amountIn;
}
emit Swap(msg.sender, _tokenIn, _amountIn, amountOut);
// Send output tokens to trader
if (!tokenOut.transfer(msg.sender, amountOut)) revert TransferFailed();
}
}
getAmountOut β pure math, no state. Separated deliberately so it can be called by anyone to preview a trade before executing it. In DeFi this is standard: quote first, then transact.
addLiquidity β the ratio check is the interesting part. If the pool already has reserves, you can't deposit in arbitrary proportions. _amount0 * reserve1 != _amount1 * reserve0 detects any imbalance. Deposit skewed amounts and you'd instantly change the price β essentially donating money to arbitrageurs.
swap β the ternary tuple assignment is the cleanest part of the contract. Instead of two separate if/else branches, one line maps all four variables correctly based on direction:
(IERC20 tokenIn, IERC20 tokenOut, uint256 reserveIn, uint256 reserveOut) = isToken0
? (token0, token1, reserve0, reserve1)
: (token1, token0, reserve1, reserve0);
Where You Can Step on a Rake
Integer division truncates, silently.
getAmountOut divides at the end β intentionally. But the truncation still happens. 100,000 / 1,100 = 90, not 90.909.... The pool keeps the remainder. At scale across millions of trades, this accumulated dust is non-trivial. Production AMMs handle this with basis points (fee = 30 bps = multiply by 997/1000 before dividing).
Internal reserves vs balanceOf.
The contract tracks reserve0 and reserve1 internally instead of calling token0.balanceOf(address(this)) every time. Two reasons: gas savings (SLOAD is expensive, external calls are more expensive), and security β if someone sends tokens directly to the contract without going through addLiquidity, the reserves won't silently become unbalanced and break the invariant.
Console Verification Flow
npx hardhat ignition deploy ignition/modules/SimpleAMM.ts --network localhost --reset
npx hardhat console --network localhost
const { viem } = await network.create();
const [owner, trader] = await viem.getWalletClients();
const cViem = require("viem");
const t0Address = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
const t1Address = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512";
const ammAddress = "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0";
const token0 = await viem.getContractAt("MyToken", t0Address);
const token1 = await viem.getContractAt("MyToken", t1Address);
const amm = await viem.getContractAt("SimpleAMM", ammAddress);
// Trader buys tokens via ICO
const t0AsTrader = await viem.getContractAt("MyToken", t0Address, { client: { wallet: trader } });
const t1AsTrader = await viem.getContractAt("MyToken", t1Address, { client: { wallet: trader } });
await t0AsTrader.write.buyTokens({ value: cViem.parseEther("2") }); // 2000 Token0
await t1AsTrader.write.buyTokens({ value: cViem.parseEther("2") }); // 2000 Token1
// Add liquidity 1000:1000
const ammAsTrader = await viem.getContractAt("SimpleAMM", ammAddress, { client: { wallet: trader } });
await t0AsTrader.write.approve([ammAddress, cViem.parseEther("1000")]);
await t1AsTrader.write.approve([ammAddress, cViem.parseEther("1000")]);
await ammAsTrader.write.addLiquidity([cViem.parseEther("1000"), cViem.parseEther("1000")]);
console.log("Reserve 0:", cViem.formatEther(await amm.read.reserve0())); // 1000
console.log("Reserve 1:", cViem.formatEther(await amm.read.reserve1())); // 1000
// Swap 100 Token0 β Token1
await t0AsTrader.write.approve([ammAddress, cViem.parseEther("100")]);
await ammAsTrader.write.swap([t0Address, cViem.parseEther("100")]);
const traderT1Balance = await token1.read.balanceOf([trader.account.address]);
console.log("Trader Token1 after swap:", cViem.formatEther(traderT1Balance));
// ~1090.909... β math checks out
The formula lands exactly. 1,000 Γ 100 / 1,100 = 90.909... Token1 received. The invariant holds.
What This Day Actually Meant
Six days ago I was writing owner = msg.sender in a constructor. Today I implemented the core pricing engine of a decentralised exchange.
What transferred directly from .NET:
- CEI pattern β same as any transactional system
- Separation of pure logic (
getAmountOut) from state mutation (swap) β same as keeping domain logic out of controllers - Defensive checks before any state change β same as guard clauses
What was genuinely new:
- Thinking in invariants instead of conditions
- Price as an emergent property of reserves, not a stored value
- The elegance of
x Β· y = kβ one line that replaces an entire matching engine
What's Next
Day 7: Reentrancy Protection β the vulnerability that cost $60M in the 2016 DAO hack, and how to write contracts that can't be drained.
Repo: github.com/alena-dev-soft
Follow the journey on Telegram: t.me/dotnetToWeb3
Stage: Dinosaur π¦ β going deeper into the bedrock. Day 6 of 7.
Top comments (0)