Section B · Technical Core

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.

CategoryFamous instances
Fee-on-transfer breakagenumerous router exploits 2020-21
Math at extreme pricesv3 tick boundary bugs in forks
Reentrancy via callbacksimBTC / Lendf.Me 2020 ($25M)
Sandwich/MEVongoing structural
TWAP manipulationMango Markets 2022 ($110M), Inverse Finance ($15M)
Pool init racesnumerous v3 first-LP scams
Approval-relatedInfiniteAllowance exploits
Selector collisionsCurve / Vyper Reentrancy 2023 ($60M)
Storage collisions (proxies)Audius 2022 ($6M)
Read-only reentrancyCurve 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.toUint160 at the boundary; revert on overflow.
  • Test the math at MIN_TICK and MAX_TICK explicitly. Don't rely on fuzz finding them.
  • Use the canonical TickMath helpers; 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

AttackMechanismDefense
SandwichAttacker trades same direction before victim, opposite direction after. Profits from slippage.Slippage limits, batch auctions, private flow
JIT liquiditySearcher adds concentrated LP right before a large swap, captures fee, removesHook with min-lockup, fee surcharge on hot liquidity
FrontrunCopy victim's tx with higher gas; e.g. govt-action arb, initialization raceCommit-reveal, encrypted mempool
BackrunRight-after-victim arb — mostly benign; rebalances the poolOften desired; some protocols capture it via MEV-Share
Liquidation backrunPlace own liquidation right after price-update txSame; 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 uint256 to 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 uniswapV3SwapCallback and 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-upgrades that 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();
}
Senior signal

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.