A minimal constant-product AMM in ~180 lines of Solidity, built with a security-first mindset.
Every function documents its invariants. Every invariant is tested — both with property-based fuzzing (1000 runs) and stateful invariant fuzzing (500 sequences × 50 calls = 25,000 calls per invariant).
| ID | Invariant | Tested By |
|---|---|---|
| I-1 | k never decreases after a swap (fees only grow k) |
testFuzz_swap_kNeverDecreases, invariant_kNeverDecreases |
| I-2 | Both reserves > 0 while LP shares exist | invariant_reservesNonZeroWhileSharesExist |
| I-3 | LP shares proportional to liquidity contributed | testFuzz_addRemove_noMoreThanDeposited |
| I-4 | MINIMUM_LIQUIDITY permanently locked (first-deposit guard) |
invariant_minimumLiquidityPermanentlyLocked |
| I-5 | Full token accounting: reserves == total_in - total_out |
invariant_tokenAccounting |
| I-6 | Tracked reserves match actual token balances | invariant_reservesMatchBalances |
| Attack | Mitigation |
|---|---|
| Price manipulation via donation | Reserves tracked internally, not via balanceOf |
| First-depositor ratio grief | MINIMUM_LIQUIDITY burned to address(1) on first deposit |
| Reentrancy on swap | Checks-Effects-Interactions ordering throughout |
| Slippage / sandwich | minAmountOut parameter on swap() |
Integer overflow in k |
Solidity 0.8 checked math |
Ran 19 tests: 19 passed, 0 failed
Unit + Fuzz (1000 runs each):
[PASS] test_addLiquidity_firstDeposit
[PASS] test_addLiquidity_subsequentDeposit_proportional
[PASS] test_addLiquidity_reverts_zeroAmount
[PASS] test_removeLiquidity_returnsProportionalTokens
[PASS] test_removeLiquidity_reverts_insufficientShares
[PASS] test_swap_basicToken0In
[PASS] test_swap_reverts_slippage
[PASS] test_swap_reverts_invalidToken
[PASS] test_minimumLiquidityLocked
[PASS] testFuzz_swap_kNeverDecreases (1001 runs)
[PASS] testFuzz_swap_token1In_kNeverDecreases (1001 runs)
[PASS] testFuzz_addRemove_noMoreThanDeposited (1000 runs)
[PASS] testFuzz_swap_priceImpactMonotonic (1001 runs)
Stateful Invariant (500 sequences × 50 calls = 25,000 calls each):
[PASS] invariant_kNeverDecreases
[PASS] invariant_reservesNonZeroWhileSharesExist
[PASS] invariant_minimumLiquidityPermanentlyLocked
[PASS] invariant_totalSharesFloor
[PASS] invariant_tokenAccounting
[PASS] invariant_reservesMatchBalances
Note: During development,
invariant_tokenAccountingcaught a wrong assumption in the ghost variable design — a swap flowing token1 INTO the pool lets LPs withdraw MORE token1 than they deposited (fee accrual). The invariant was corrected from a naivewithdrawn <= depositedcheck to a full accounting equationreserves == total_in - total_out, which is the correct solvency property.
src/
MiniAMM.sol constant-product AMM, NatSpec invariants on every fn
test/
MiniAMM.t.sol unit + property-based fuzz tests
MiniAMM.invariant.t.sol stateful invariant tests with Handler + ghost variables
script/
Deploy.s.sol deployment script
forge test # all tests
forge test -vvv # verbose output
forge test --match-test invariant # invariant tests only
forge coverage # coverage report- Solidity 0.8.24
- Foundry — forge test, fuzz, invariant
- No external dependencies (no OpenZeppelin)