Error Handling & Failure Modes
The DEX-specific bug catalog. Every one of these has lost real money in production. Internalize the patterns, the defenses, and the test you would write to catch the regression.
The DEX bug catalog
Most DEX-flavored bugs cluster into 10 categories. A senior interview tests fluency across all of them.
| Category | Famous instances |
|---|---|
| Fee-on-transfer breakage | numerous router exploits 2020-21 |
| Math at extreme prices | v3 tick boundary bugs in forks |
| Reentrancy via callbacks | imBTC / Lendf.Me 2020 ($25M) |
| Sandwich/MEV | ongoing structural |
| TWAP manipulation | Mango Markets 2022 ($110M), Inverse Finance ($15M) |
| Pool init races | numerous v3 first-LP scams |
| Approval-related | InfiniteAllowance exploits |
| Selector collisions | Curve / Vyper Reentrancy 2023 ($60M) |
| Storage collisions (proxies) | Audius 2022 ($6M) |
| Read-only reentrancy | Curve 2022 (multiple integrators affected) |
Fee-on-transfer breakage
The pattern: token charges 1-10% on every transfer. A router calls transferFrom(user, pool, 100) expecting the pool to receive 100. The pool actually receives 95. Subsequent math (k-check, mint) uses 100 and breaks.
// BAD
function swap(uint256 in_) external returns (uint256 out) {
token.transferFrom(msg.sender, address(this), in_);
uint256 amountInWithFee = in_ * 997; // wrong — `in_` may not have arrived
// ...
}
// GOOD — measure what arrived
function swap(uint256 in_) external returns (uint256 out) {
uint256 before = token.balanceOf(address(this));
token.transferFrom(msg.sender, address(this), in_);
uint256 actualIn = token.balanceOf(address(this)) - before;
uint256 amountInWithFee = actualIn * 997;
// ...
}Better still — separate "supportsFeeOnTransfer" variants and force users to opt in. Uniswap v2 router has swapExactTokensForTokensSupportingFeeOnTransferTokens for exactly this reason.
Bad math under extreme prices
Concentrated liquidity math involves sqrtPriceX96 values up to 2¹⁶⁰. Multiplying two such values overflows uint256. Real-world tick boundaries push prices to ranges where naive implementations silently truncate.
Defenses:
- Use
FullMath.mulDiv— 512-bit intermediate. - Use
SafeCast.toUint160at the boundary; revert on overflow. - Test the math at
MIN_TICKandMAX_TICKexplicitly. Don't rely on fuzz finding them. - Use the canonical
TickMathhelpers; don't reimplement.
// Test the boundaries by name, not just by fuzz
function test_TickMath_MIN() public {
uint160 sp = TickMath.getSqrtRatioAtTick(TickMath.MIN_TICK);
assertEq(sp, TickMath.MIN_SQRT_RATIO);
}
function test_TickMath_MAX() public {
uint160 sp = TickMath.getSqrtRatioAtTick(TickMath.MAX_TICK);
assertEq(sp, TickMath.MAX_SQRT_RATIO);
}Reentrancy via callbacks
Modern AMMs are reentrancy-aware by design — flash swap callbacks are a designed reentrancy. The risk is when:
- Token transfer (ERC-777, ERC-1363, native ETH) invokes user code, which re-enters before the original transfer's state effect.
- Hook lifecycle (v4) allows hooks to call back into PoolManager while a swap is mid-flight.
Defenses:
- Locks. Pre-Cancun: storage-bool lock. Post-Cancun:
TSTORE-based lock (~20× cheaper). - Checks-Effects-Interactions. Update state before any external call.
- Lock the read paths too. Mid-swap, the pool's reported price is inconsistent. Read-only callers must respect the lock (see read-only reentrancy below).
MEV — sandwich, JIT, frontrun, backrun
| Attack | Mechanism | Defense |
|---|---|---|
| Sandwich | Attacker trades same direction before victim, opposite direction after. Profits from slippage. | Slippage limits, batch auctions, private flow |
| JIT liquidity | Searcher adds concentrated LP right before a large swap, captures fee, removes | Hook with min-lockup, fee surcharge on hot liquidity |
| Frontrun | Copy victim's tx with higher gas; e.g. govt-action arb, initialization race | Commit-reveal, encrypted mempool |
| Backrun | Right-after-victim arb — mostly benign; rebalances the pool | Often desired; some protocols capture it via MEV-Share |
| Liquidation backrun | Place own liquidation right after price-update tx | Same; or auction the liquidation |
Oracle TWAP manipulation
Uniswap v2 introduced an on-chain TWAP. v3 made it cheaper. But a TWAP is only as resistant as its observation window and the depth of the pool it watches.
Attack: low-liquidity pool, attacker pushes price for one block (or several), inflated TWAP feeds into a lending protocol that uses it for collateral valuation. Mango Markets, Inverse Finance, and many others lost real money this way.
Defenses:
- Long enough window. 30-minute TWAP across high-TVL pools.
- Pool selection. Use a primary pool with depth sufficient that single-block manipulation costs more than it earns.
- Multi-source. Combine on-chain TWAP with Chainlink / Pyth and require agreement within tolerance.
- Liquidity floor. Don't accept the TWAP if observed liquidity drops below a threshold.
Pool initialization races
Anyone can be first to createPool(tokenA, tokenB, fee). The first initializer sets the starting price. Two failure modes:
- Wrong-price init. First LP sets a wildly off-market price; their first liquidity gets arbed away. Mitigation: front-end checks; permissionless arbitrageurs are usually fast enough.
- Init grief / squatting. Someone initializes with attacker-favorable price right before your protocol launch. Mitigation: deploy the pool yourself as part of your launch tx.
- v2-specific: the first LP gets a slightly larger initial-share allocation, which can be exploited via the "donation attack" by the first liquidity provider. v2 mints 1000 dead shares to address(0) to mitigate; you must respect that pattern in clones.
Token approval-tied vulnerabilities
- Infinite approval drains. Users approve
max uint256to a router; router contract is later upgraded or has a bug; approval is now a key to user funds. Mitigation: Permit2 (per-swap signatures, no standing allowance). - USDT approve-front-run. USDT requires zero-first when changing allowance. Approvals to mid-stack contracts that don't know this break.
- Allowance race. An attacker watching for an upcoming allowance reduction can spend the old allowance first, then the new one. ERC-20 spec describes this; permit-based flows sidestep it.
Selector collisions (4-byte clashes)
Function selectors are 4 bytes of keccak256 of the signature. With ~4 billion possible selectors, collisions are findable by brute force.
The Vyper-reentrancy / Curve incident in July 2023 wasn't a selector collision per se, but selector collisions are a real attack surface for:
- Proxies. If a proxy's admin function shares a selector with an implementation function, calls can be misrouted. OpenZeppelin transparent proxies handle this; UUPS proxies require care.
- Function dispatch in custom Yul. Hand-rolled dispatchers must validate selectors carefully.
- Spoofed callbacks. If
uniswapV3SwapCallbackand some other 4-byte function clash, attackers can confuse callback-trusting code.
Storage collisions in upgradeable patterns
Upgradeable contracts use a proxy + implementation. The proxy holds state at slot N; the implementation reads/writes slot N. If a new implementation reorders variables, slots shift and existing data is misinterpreted.
Defenses:
- Storage gaps (
uint256[50] private __gap;) at the end of every base contract. - Diamond / namespaced storage via ERC-7201 — each module gets a deterministic slot region.
- Upgrade-safe linters like
openzeppelin-upgradesthat compare storage layouts. - The simpler answer: don't make core contracts upgradeable. Most AMM cores are immutable; periphery is replaced wholesale.
Read-only reentrancy in price calculations
Mid-callback, the pool's state may be inconsistent. A view function called during a callback might return a stale or unphysical price. Integrators (lending markets, liquidators) that read this price get fooled into bad collateral decisions.
Curve's 2022 read-only reentrancy is the canonical example. The pool's get_virtual_price could be called mid-remove-liquidity and return inflated values.
Defenses:
- Lock the read paths. Inside the pool, view functions check the reentrancy lock and revert if held.
- Snapshot pricing. Compute the price into a separate cached value that's stable across reentrancy.
- For integrators: assume any price-read can be malicious. Verify lock state, or wrap the call in a try/catch on a known-reentrancy-checked function.
// Pool defends its own view functions
modifier checkReentrancy() {
require(_unlocked == 1, "REENTRANT"); // same lock as the mutating path
_;
}
function getPrice() external view checkReentrancy returns (uint256) {
return _spotPrice();
}Being able to enumerate these ten categories cold — with one paragraph of defense each — is a near-certain pass on the "what could go wrong" portion of a design round.