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.
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.
// 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
// 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"
);
}
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.
// 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
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.
// ❌ 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.
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,500 | No | Target holds >$6K |
| $1M TVL | ~$55,000 | Possibly | Target holds >$60K |
| $10M TVL | ~$550,000 | Yes (flash loan) | Target holds >$600K |
| $100M TVL | ~$5.5M | Yes (multiple protocols) | Target holds >$6M |
| Chainlink Feed | Impractical (aggregated) | N/A | Not 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.
// 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();
}
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
// 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
| Category | What to Check | Common Finding |
|---|---|---|
| Invariant Checks | Is k verified after EVERY state-changing path? | Missing check on certain swap paths |
| Slippage | Are amountOutMin and deadline enforced? | Protocol swaps internally with 0 slippage |
| Reentrancy | Are ETH transfers before or after state updates? | Read-only reentrancy via ETH callback |
| Oracle Usage | Does any protocol read this pool's spot price? | Spot price used as collateral oracle |
| Fee Accounting | Are fees correctly excluded from k calculation? | Fee-on-transfer tokens break accounting |
| Flash Swaps | Can the callback re-enter other functions? | Flash swap + reentrancy combo |
| Token Compatibility | What happens with rebasing/fee tokens? | Reserve accounting breaks with AMPL |
Foundry Fork Test: Simulate a Sandwich Attack
// 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
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 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
// 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
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.
// ❌ 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 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
// 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 = kmust 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 —
amountOutMinanddeadlineare 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.