Week 08 — DeFi Security: AMM, Lending, Vault

“From this week on, you are no longer auditing ‘a contract’. You are auditing a balance sheet expressed in Solidity. The bugs that move money — the eight-figure ones — almost never live in a single line. They live in the accounting equations that the protocol promises will always hold, and in the moment some user finds a sequence of legal operations that breaks one. The job stops being ‘spot the pattern’ and starts being ‘name the invariant, then try to break it’.”

Tags: web3-security defi amm lending vault stablecoin invariants protocol-economic Learner: Past Tuan-07-Token-Standards-Integration-Risk → entering protocol-level audit work Time: 7 days (5–7h/day; this week is dense and lab-heavy) Related: Tuan-05-Vulnerability-Classes-Part-1 · Tuan-06-Vulnerability-Classes-Part-2 · Tuan-07-Token-Standards-Integration-Risk · Tuan-09-Oracle-MEV-Economic-Attack · Case-bZx-Price-Manipulation-2020 · Case-Harvest-Finance-2020 · Case-Euler-Finance-2023 · Case-KyberSwap-Elastic-2023 · Tuan-Bonus-Stablecoin-Economic-Modeling · Tuan-Bonus-Liquid-Staking-Restaking


1. Context & Why — the mindset shift

1.1 What changes this week

Weeks 04–07 trained one habit: read the function, find the bug. CEI violations, missing access control, reentrancy, signature replay, ERC-20 quirks — local issues, mostly diagnosable from a single file.

That habit will not find Euler. It will not find KyberSwap Elastic. It will not find bZx, Harvest, Mango. Every one of those exploits passed function-level review by competent engineers (in several cases multiple audit firms). They were not “lines of bad code”. They were violations of accounting equations the protocol believed would always hold.

The shift this week:

Mental toolWeeks 04–07Week 08+
Primary artifactFunctionInvariant
Primary question”What does this code do?""What must always be true after every call?”
Bug shapeLocal misuse of a primitiveSequence of legal operations breaks an accounting equation
PoC styleOne unit test, one transactionStateful fuzz handler + invariant assertion
Severity driverLoss per callLoss per drain of pool / cascading insolvency

The auditor’s prime directive for protocol work: before reading a single line of code, write down every accounting equation the protocol must maintain — totals, ratios, conservation laws, solvency relationships, monotonicity properties. Then ask, for each: “what user-callable sequence could violate this?” Code review is the search; the invariant is the target.

This week we apply that lens to four protocol families: AMMs, lending protocols, vaults, and stablecoins — plus a brief tour of liquid staking, restaking, and perp derivatives, which are revisited in their bonus chapters.

1.2 Why these four families anchor the protocol week

They’re the building blocks of every higher-order DeFi system:

flowchart TB
  AMM[AMMs<br>price discovery, swap]
  LEND[Lending<br>credit, leverage, liquidations]
  VAULT[Vaults<br>aggregation, strategy, accounting]
  STAB[Stablecoins<br>peg, collateral, redemption]

  AMM --> ORACLE[Spot price oracles]
  AMM --> VAULT
  LEND --> VAULT
  LEND --> STAB
  STAB --> LEND
  VAULT --> STAB
  VAULT --> AMM

  PERP[Perps<br>funding, mark price] --> AMM
  PERP --> LEND
  LST[LSTs / LRTs<br>staking yield] --> VAULT
  LST --> LEND

  style AMM fill:#fff2cc
  style LEND fill:#fff2cc
  style VAULT fill:#fff2cc
  style STAB fill:#fff2cc

Every high-impact exploit since 2020 has lived at the intersections in this graph. The hardest audits are cross-protocol: a Curve LP token used as Aave collateral whose virtual_price is read mid-removal (read-only reentrancy, Tuan-05-Vulnerability-Classes-Part-1); an Aave-style market with a thinly-traded asset whose oracle can be manipulated via a flash loan through a Uniswap pool (bZx); a self-liquidation in a lending protocol with a donation primitive (Euler). You cannot audit the intersection until you have audited the corners.

1.3 Learning goals

By Friday, you can:

  • Write the constant-product, StableSwap, and concentrated-liquidity invariants from memory, and explain at least one historical bug class anchored to each.
  • Trace how a flash-loan + spot-AMM oracle manipulation produces a liquidation profit on a lending protocol.
  • Audit a lending market’s liquidation logic for the “always-profitable” invariant and identify failure modes (gas-grief, bad debt, dust loops).
  • Identify donation-style attacks on vault and lending share accounting (Euler shape, ERC-4626 first-depositor shape, EToken/DToken shape).
  • Read an ERC-4626 vault and immediately name the four canonical rounding-direction rules.
  • Critique a CDP, fractional, and algorithmic stablecoin design under stress (Maker / Frax / UST shape).
  • Write Foundry invariant tests with a Handler that exercise a lending pool against five canonical invariants.

1.4 Primary references (read these, then this lesson)

SourceURLStatus
Uniswap V2 Core (Adams & Zinsmeister)https://app.uniswap.org/whitepaper.pdfCurrent — foundational
Uniswap V3 Core (Adams, Zinsmeister, Salem, Keefer, Robinson)https://app.uniswap.org/whitepaper-v3.pdfCurrent — concentrated liquidity math
Uniswap V3 Math Primer (Uniswap Labs blog)https://blog.uniswap.org/uniswap-v3-math-primer · https://blog.uniswap.org/uniswap-v3-math-primer-2Current
Atis Elsts — Liquidity Math in Uniswap V3https://atiselsts.github.io/pdfs/uniswap-v3-liquidity-math.pdfCurrent; the cleanest single-PDF reference for V3 math
Curve StableSwap Whitepaper (Egorov, 2019)https://docs.curve.finance/references/whitepaper/ (linked from Curve docs)Current
Aave V3 Docs — Overview, Risk, Liquidationshttps://aave.com/docs/aave-v3/overview · https://aave.com/help/borrowing/liquidationsCurrent; verify close-factor rules per your audit date [verify]
Compound III Docs — Interest Rates & Liquidationshttps://docs.compound.finance/interest-rates/ · https://docs.compound.finance/liquidation/Current
MakerDAO Technical Docs — Vaults, Rates, Auctionshttps://docs.makerdao.com/ (Liquidation 2.0: dog-and-clipper-detailed-documentation)Current; MakerDAO has rebranded toward Sky — verify which protocol generation you are auditing [verify]
EIP-4626 (Tokenized Vault Standard)https://eips.ethereum.org/EIPS/eip-4626Final; cross-link Tuan-07-Token-Standards-Integration-Risk
Euler Labs post-mortem + Omniscia analysis (Mar 2023)https://medium.com/@omniscia.io/euler-finance-incident-post-mortem-1ce077c28454 · https://www.euler.finance/blog/war-peace-behind-the-scenes-of-eulers-240m-exploit-recoveryHistorical incident; technical content current
KyberSwap Elastic post-mortem (Nov 2023)https://blog.kyberswap.com/post-mortem-kyberswap-elastic-exploit/ · https://100proof.org/kyberswap-post-mortem.htmlHistorical; tick-boundary math is general lesson
Mango Markets analysis (Oct 2022)https://www.soliduslabs.com/post/mango-hack · https://www.chainalysis.com/blog/oracle-manipulation-attacks-rising/Historical
bZx attacks — palkeo write-up (Feb 2020)https://www.palkeo.com/en/projets/ethereum/bzx.htmlHistorical; canonical flash-loan oracle case
FRAX docs — AMO overviewhttps://docs.frax.finance/amo/overviewCurrent; FRAX has shifted toward 100% collateral and Fraxtal — verify ratio at audit time [verify]
Lido / Rocket Pool / EigenLayer docshttps://docs.lido.fi/ · https://docs.rocketpool.net/ · https://docs.eigencloud.xyz/eigenlayer/Current; restaking slashing-live status [verify]
Anatomy of a Stablecoin’s Failure: Terra-Luna (Briola et al., 2022)https://arxiv.org/pdf/2207.13914Academic post-mortem on UST collapse
Trail of Bits — Building Secure Contracts: DeFihttps://github.com/crytic/building-secure-contracts (/development-guidelines/, /program-analysis/echidna/)Current
Solodit (Cyfrin)https://solodit.cyfrin.io/ — filter by AMM / lending / liquidation / stablecoinLiving archive of audit findings
Paradigm researchhttps://www.paradigm.xyz/writing (search “AMM”, “concentrated liquidity”)Current
a16z crypto stablecoin researchhttps://a16zcrypto.com/research/Current

2. AMM Math and Its Bug Surface

2.1 Why an auditor needs the math, precisely

You cannot find a rounding bug in a swap formula if you don’t know the formula. You cannot find a tick-boundary bug if you can’t write the tick-boundary condition. Most AMM auditors who never find a serious bug are people who treat the math as “the dev’s problem” and audit only the surrounding plumbing (access control, reentrancy, ERC-20 handling). The plumbing has been audited eighty times. The math has been audited once and never re-validated against the latest refactor. That’s where the bugs are.

Three AMM families dominate. We do each.

2.2 Constant product (Uniswap V2 style): x · y = k

The invariant:

After any swap: x' · y' ≥ x · y

Strict equality only in the zero-fee case; with fees, k strictly grows.

Swap derivation. Given reserves (x, y), a user puts in Δx of token X and gets out Δy of token Y. Including a fee f (e.g., 0.003 for 0.3%):

(x + Δx · (1 − f)) · (y − Δy) = x · y
Δy = (y · Δx · (1 − f)) / (x + Δx · (1 − f))

Uniswap V2 encodes this with integer math by checking the invariant after the swap rather than computing exact amounts:

// Simplified, from UniswapV2Pair.swap
uint balance0Adjusted = (balance0 * 1000) - (amount0In * 3);
uint balance1Adjusted = (balance1 * 1000) - (amount1In * 3);
require(
    balance0Adjusted * balance1Adjusted >= uint(_reserve0) * _reserve1 * 1000**2,
    "K"
);

Numerically elegant: the contract doesn’t compute the user-facing Δy exactly; the user supplies what they want and the contract checks that k does not decrease. Rounding errors always favor the pool (LP). The official audit (DappHub / Trail of Bits) confirmed there is no path in V2 where rounding violates the invariant in the swap path. (UniswapV2 audit, dapp.org.uk)

2.2.1 Bug surface even on the “safe” CPAMM

V2’s swap path is robust. The bug surface lives elsewhere:

  1. First-LP price manipulation. When a pool is empty, the first liquidity provider sets the initial ratio. If addLiquidity lets one party seed an absurd ratio (e.g., 1 wei of WETH : 1e24 of TOKEN), every subsequent swap pays the seeder’s implicit price. Mitigation: V2 burns a minimum MIN_LIQUIDITY (1000 wei) to a dead address on the first mint, raising the cost of price manipulation and avoiding a divide-by-zero in mint.
  2. Skim / sync drift. UniswapV2Pair has skim(to) (transfers excess balance to to) and sync() (forces reserves to match balance). Tokens with fee-on-transfer or rebasing can drift the cached reserve away from balanceOf(pair). Integrators that read getReserves() see stale data; a malicious user can game the discrepancy. Cross-link Tuan-07-Token-Standards-Integration-Risk.
  3. Reserves drainable via rounding in a custom CPAMM. Many forks (Sushi, PancakeSwap, hundreds of others) tweak fee math, add referral fees, change decimals — and lose the “rounding favors LP” invariant. Any custom CPAMM is suspect until you verify, with a fuzz test, that the invariant is monotonic under random user-callable sequences. This is Lab 1 in §7.
  4. balanceOf vs internal accounting: a pair that reads IERC20(token).balanceOf(address(this)) trusts the token to be honest. With donation / fee-on-transfer / rebasing tokens, this trust breaks. Always model the pair against the worst-case token in its registry.
  5. Price oracle (price0CumulativeLast, price1CumulativeLast): V2’s TWAP is a single-block-manipulable spot price unless integrators sample over a sufficiently long window. Cross-link Tuan-09-Oracle-MEV-Economic-Attack.

2.2.2 Slippage protection — what auditors actually check

Every user-facing swap function should expose:

function swapExactTokensForTokens(
    uint amountIn,
    uint amountOutMin,     // ← slippage cap
    address[] calldata path,
    address to,
    uint deadline          // ← MEV / stuck-tx protection
) external returns (uint[] memory amounts);

Audit reflexes:

  • amountOutMin enforced. A 0 or hard-coded default that the frontend never overrides = MEV sandwich liability.
  • deadline enforced and not type(uint).max. A frontend that sends deadline = block.timestamp + 1 is generally fine; one that sends MAX_UINT is letting a transaction sit in the mempool indefinitely, exploitable by anyone who can replay it after price moves.
  • Slippage applied per-hop, not just on the final output. Multi-hop swaps can be sandwiched at each hop independently.
  • Path validation. A router that accepts an arbitrary path[] may execute against attacker-controlled “tokens” that pretend to be the target. Whitelist pools / factories.

2.3 StableSwap (Curve): low-slippage at peg

For assets that should trade near parity (USDC/USDT, stETH/ETH, sUSDe/USDC), the constant-product curve is wasteful — it provides liquidity at prices nobody will trade at. StableSwap blends a constant-sum curve (perfect 1:1 swap) and a constant-product curve (deep depth at extremes), interpolated by an amplification coefficient A:

A · n^n · Σ x_i + D = A · D · n^n + D^(n+1) / (n^n · Π x_i)

where n is the number of tokens in the pool, x_i is the balance of token i, D is the “total balanced value” invariant, and A controls curve flatness. (Curve StableSwap whitepaper)

  • High A → curve is flat near the peg (low slippage), sharp away from peg (steep penalty). Stable assets.
  • Low A → behaves more like x·y=k. Tolerates imbalance better.

When D is constant, all balances x_i lie on the StableSwap curve. The contract finds D by Newton’s iteration; the swap function then finds the new y such that the invariant is preserved at the new balances.

2.3.1 Bug classes specific to StableSwap

  1. Newton’s-method convergence + reentrancy. Curve pools historically used Vyper. In July 2023, several Curve pools (CRV/ETH, alETH/ETH, msETH/ETH, pETH/ETH) were drained via a Vyper-compiler-level reentrancy lock bug — the @nonreentrant decorator silently malfunctioned on specific Vyper versions, allowing reentrancy into the pool’s remove_liquidity mid-execution. The deeper auditor lesson: a “lock present in source” is not a lock present in bytecode — verify the compiled artifact, not the source. Cross-link to Case-Curve-Vyper-Compiler-2023 (planned). [verify exact Vyper versions affected at time of audit]
  2. Read-only reentrancy via get_virtual_price(). Curve LP tokens are widely consumed as collateral in lending protocols. During remove_liquidity, the pool’s state is partially updated, and get_virtual_price() returns an interim (incorrect) value. A lending market that prices CRV LP collateral via get_virtual_price() is mis-pricing if the user is mid-removal. Cross-link Tuan-05-Vulnerability-Classes-Part-1 §2.2.4. Defenders: lock the view (Curve’s modern pools do via a check-on-entry); or, on the consumer side, never read the price during a callback.
  3. A parameter rampup. A is governance-tunable. Sudden changes shock the curve — if a pool ramps A while a large position is open, the position’s effective price moves. Most pools use a ramped (gradual) update to avoid this; an immediate setter is a finding.
  4. Imbalanced deposits / withdrawals. add_liquidity([0, 0, X]) and remove_liquidity_imbalance(...) charge dynamic fees based on how much they push the pool away from balance. Bugs here typically appear in cloned StableSwap forks that drop or simplify the imbalance fee, leaking value to whoever imbalances the pool maximally before withdrawing the other side.

2.4 Concentrated liquidity (Uniswap V3): tick math

V3’s idea: an LP can deploy capital over a price range [p_a, p_b] instead of over all prices. Inside the range, capital is effectively many times deeper. Outside, the LP holds 100% of one side. This is a step-change in capital efficiency and a step-change in audit surface.

Core representations:

  • Price stored as sqrtPriceX96: the square root of price, scaled by 2^96. Using √P instead of P makes the swap math linear in L and Δ√P. Storage is a uint160.
  • Ticks: each tick i corresponds to a discrete price p(i) = 1.0001^i. A tick is 1 basis point of price movement. Tick spacing per fee tier limits which ticks can carry liquidity (e.g., 0.05% fee → tick spacing 10; 0.3% → 60; 1% → 200).
  • Liquidity L: virtual reserves are x = L / √P and y = L · √P. Within a tick range, L is constant; √P moves with swaps.

Swap step within a tick:

Δ√P = Δy / L                   (for token1-in swap)
Δ√P = (Δx · √P · √P_after) / L (approximately, for token0-in)

A swap consumes liquidity until either:

  1. The full input is consumed within the current tick, or
  2. The swap reaches a tick boundary, at which point crossTick(...) is called: L is updated by the next tick’s liquidityNet, and the swap continues in the new range.

2.4.1 Where V3 bugs live

The hard part of V3 is exactly the tick-crossing boundary. The KyberSwap Elastic exploit (Nov 2023) is the canonical case:

  • Two functions compute “is this swap big enough to cross the tick?” with slightly different rounding between estimation and final price computation.
  • Attacker crafts an input such that swapAmount = amountSwapToCrossTick − 1 — the swap technically does not cross the boundary by one wei, but the second function uses strict equality and assumes the boundary check is conservative. Liquidity is not removed from the now-out-of-range position, yet the price is reported as if it had moved.
  • Repeating this in alternating directions, the attacker re-uses the same “ghost” liquidity multiple times and drains roughly $47M across multiple chains. (Halborn KyberSwap analysis)

Auditor’s takeaway for any V3-style pool: every place a price boundary is checked, you must ask:

  1. Are both sides of the check using the same precision?
  2. Is the check < or <= — and is the chosen direction safe for both swap directions?
  3. Under the off-by-one input boundary − 1, what happens? Walk it with concrete numbers. Read this as: strict inequalities and rounding directions are not stylistic choices; they are the contract.

Other V3 audit signals:

  • Custom pools / forks. Uniswap V3’s mainnet code is heavily audited and battle-tested. Forks (Kyber Elastic, Algebra Finance, PancakeSwap V3, SushiSwap V3 variants) rewrite or extend swap() / crossTick() — each rewrite is a new attack surface. Treat the fork as a fresh codebase.
  • Position NFT (ERC-721) accounting. V3 represents LP positions as NFTs. The mint, increaseLiquidity, decreaseLiquidity, collect flow operates on stored tokensOwed0/1. Bugs in stored-but-uncollected fees show up here.
  • Initialization and slot0. A V3 pool must be initialize(sqrtPriceX96)-d before use. Race for initialization (or unsafe re-initialization in a fork) is a flag. Mirrors the uninitialized-proxy pattern in Tuan-05-Vulnerability-Classes-Part-1 §4.4.
  • Fee tier and tick spacing constants. Hardcoded constants per fee tier; bugs occur when a fork “supports custom fee tiers” without updating tick-spacing constants in lockstep.
  • Oracle (V3 has a native TWAP). V3 maintains a ring buffer of (blockTimestamp, tickCumulative, secondsPerLiquidityCumulative) observations. The buffer length determines max TWAP window. If integrators read with secondsAgo longer than the buffer, the call reverts. Bugs: integrators read at secondsAgo = 0 (i.e., spot tick), reintroducing spot-price manipulability. Treat any V3 oracle read as if it were a spot oracle until you have verified the integrator passes a meaningful secondsAgo.

2.5 The unifying AMM audit checklist

Across all three families:

  • State the invariant explicitly, in math, then in code. Map every require(...) that enforces the invariant. Find the function that could fail it.
  • Rounding direction. Every division: does the rounding direction favor the protocol / LP, or the user? In doubt, prefer mulDivRoundingUp for charges to the user and mulDivRoundingDown for credits to the user.
  • External reads of internal state. Any view function the rest of DeFi might read as an oracle (getReserves, virtual_price, slot0, observe) must produce consistent values during the full execution of every state-changing function — including callbacks.
  • First-mint / empty-pool handling. Initial conditions; minimum-liquidity burn; price initialization race.
  • Custom fee accounting. Forks especially. Fee-on-transfer tokens. Protocol-fee carve-outs.
  • Callback / hook surface. ERC-777 callbacks (Cream), IUniswapV3SwapCallback (V3 expects the swap caller to pay tokens during the swap), pool callbacks in flash() and mint(). Each callback is an external call — see Tuan-05-Vulnerability-Classes-Part-1.

3. Lending Protocols

3.1 The model

A lending protocol is, in auditor terms, a balance sheet with rules. Stripped to essentials:

LIABILITIES                ASSETS
deposits (user supply)     borrows (user debt)
                          + idle reserves
                          + accumulated reserve fees

Solvency invariant: assets ≥ liabilities. Always.

The user-side primitives:

User actionEffect on balance sheet
supply(asset, amount)+deposits, +assets (token sits in pool)
withdraw(asset, amount)−deposits, −assets
borrow(asset, amount)+borrows, −assets (token leaves pool)
repay(asset, amount)−borrows, +assets
liquidate(victim)victim’s collateral → liquidator, victim’s debt → reduced, pool reabsorbs bad debt if any

The protocol earns interest on borrows; suppliers earn a share via the supply rate; the protocol keeps the reserve factor as protocol revenue.

3.2 Health factor, LTV, liquidation threshold

For each user, the protocol computes a single scalar — call it the health factor (Aave term; Compound calls it “account liquidity”):

HealthFactor = Σ (collateral_i · price_i · liquidationThreshold_i)
               ─────────────────────────────────────────────
                       Σ (debt_j · price_j)

If HealthFactor < 1, the account is liquidatable. Three parameters per asset:

ParameterMeaning
Loan-to-Value (LTV)Maximum fraction of an asset’s value that can be borrowed against at supply time
Liquidation Threshold (LT)Fraction at which an account becomes liquidatable. Always ≥ LTV
Liquidation BonusDiscount on collateral that incentivizes liquidators (e.g., 5–10%)

In Aave V3 these are surfaced via on-chain views (getEModeCategoryCollateralConfig, getReserveConfigurationData). The gap LT − LTV is the buffer for price movement before liquidation. (Aave Liquidations docs)

3.2.1 The auditor’s reflex on collateral parameters

Parameter setting is governance work, not engineering. But auditors must catch:

  • LT too close to LTV. Almost-zero buffer means small price moves liquidate everyone. Especially dangerous for volatile collateral.
  • LTV / LT mismatch with oracle update frequency. If an oracle has 1% deviation threshold, but LTV is 99%, normal price drift can flap accounts liquidatable / not.
  • Permissionless asset listing. Some protocols allow any token to be added as collateral. Almost-always a critical finding (Cream / Iron Bank 2021 partly relied on this for the AMP token integration with its ERC-777 hook).
  • Same risk parameters across vastly different liquidity profiles. A stablecoin with billions in liquidity and a microcap with a thin AMM pool cannot share parameters.

3.3 Interest rate models — the kink / jump

Both Aave V3 and Compound III use a piecewise-linear rate model with a utilization kink:

utilization = totalBorrows / totalSupplied
            interest rate
                ▲
                │                     ╱
                │                    ╱   slope2 (steep)
                │                   ╱
                │       ────────── ╳ ← kink (e.g., 80%)
                │      ╱
                │     ╱  slope1 (gentle)
                │    ╱
                │   ╱
                │  base
                └────────────────────────────► utilization
                                    kink     100%

Below the kink, rates climb gently. Above, they shoot up to repel further borrows and incentivize repayment (and supply inflow). Compound V3 uses two separate curves for supply vs borrow rates with independent supplyKink and borrowKink. (Compound III interest rates docs)

3.3.1 Interest accrual mechanics

Interest accrues per-second (Compound III) or per-block (older Compound, older Aave) via an index (borrowIndex, liquidityIndex). Every user-facing operation calls accrueInterest() first to bring the index up to date. The user’s actual debt is principal · currentIndex / userIndexAtBorrow.

Audit invariant: interest indices are monotonically increasing. A user’s debt today is ≥ their debt at any prior moment with the same principal. Any code path that decreases an index, or skips accrual, is a critical finding — it lets a user repay less than they owe, dropping assets < liabilities.

Common bug shapes:

  • Skipped accrual on a state-changing path (e.g., a “fast withdraw” function that doesn’t call accrueInterest).
  • Per-asset accrual with a market with two interest sources (e.g., variable + stable rate in Aave; if the path for “switch rate mode” doesn’t accrue first, debt can drift).
  • Block-number accrual on a chain with variable block times (rare but real; an L2 with 2s blocks and a contract written for 12s blocks accrues 6× too fast).
  • Time-warping cheat in tests revealing real bugs: invariant tests with vm.warp aggressively explore the rate space and find these accrual mismatches.

3.4 Liquidations

Two broad designs:

Fixed-bonus (Aave, Compound): liquidator repays up to closeFactor × debt, receives collateral at oracle price + a bonus (e.g., 5%). Simple. If many liquidators see the position, the first to land the tx wins.

Dutch / English auction (Maker, Liquity): collateral is auctioned. Maker’s modern design (Liquidation 2.0 / Clipper) uses a Dutch auction — price starts high, decreases over time; first bidder above current price wins. Inspired by the failure of Maker’s earlier cat/flip system during Black Thursday 2020 (a network congestion + zero-bid auction problem). (Maker auctions docs)

Hybrid (Liquity): a Stability Pool absorbs liquidations atomically (no auction race) — depositors gain liquidated collateral at a discount; surplus collateral is redistributed to remaining trove holders.

3.4.1 The “always-profitable” invariant

Liquidating a sub-1 health-factor position should always be profitable to a competent liquidator at current prices.

If it isn’t, liquidations don’t happen, bad debt accumulates, and the protocol becomes insolvent.

This breaks when:

  • Liquidation gas cost exceeds the bonus. For tiny positions (dust), the bonus on a 0.50, but mainnet gas at high prices can dwarf that. Most protocols enforce a minimum position size (Aave’s “dust” filter; minimum debt thresholds in Maker / Liquity). Absence of a minimum is a finding.
  • Liquidation can be partial in a way that leaves the position still unhealthy. If closeFactor = 50% is applied but the bonus exceeds 50% of the position value (rare but constructible with bad parameters), a liquidator may not be able to fully repair a bad position — bad debt accrues.
  • Oracle price the liquidator uses differs from the protocol’s oracle. Liquidator computes “will I profit?” using market price; the protocol uses oracle price. If oracle lags market in a crashing scenario, the liquidator pays oracle price for collateral that is worth less in market — they may refuse to liquidate, and bad debt accrues.
  • Liquidator is not allowed to use a flash loan to source repayment. Pre-flash-loan-era lending protocols required liquidators to hold the debt token. Modern liquidation flows route through flash loans. If a protocol’s liquidate reverts when the caller is a contract that holds the debt token only momentarily (e.g., the protocol does its own token check balanceOf(msg.sender) before/after in a buggy way), liquidations are gated.
  • MEV / private mempool: liquidations are highly contested; bots compete in priority gas auctions. If a protocol allows only a whitelisted “official” liquidator, it can be captured / colluded with. Permissionless liquidation is best practice.

3.4.2 Aave V3’s dynamic close factor

Aave V3 has a tiered close factor:

  • HF in [0.95, 1): max 50% of debt liquidatable
  • HF < 0.95 OR small dust positions: 100% liquidatable

The 50% rule preserves the position for the borrower in mild dips, the 100% rule prevents dust accumulation in severe / small cases. (Aave Liquidations docs)

Audit angle: the close-factor logic itself becomes a state machine. Verify that no sequence of partial liquidations can leave HF stuck above 0.95 with bad debt latent in the position.

3.5 Bad debt scenarios

Bad debt = the moment assets < liabilities for some asset. It compounds:

flowchart LR
  P[Price of collateral drops] --> L1[Some positions go below HF=1]
  L1 --> R1[Liquidators race in]
  R1 -->|enough liquidity, oracle matches| Healed[Position closed; protocol solvent]
  R1 -->|oracle stale OR liquidator unprofitable OR illiquid market| BadDebt[Bad debt accumulates]
  BadDebt --> Suppliers[Suppliers can't fully withdraw — bank run]
  Suppliers --> Cascade[Other markets affected; protocol-wide insolvency]

Mitigations the auditor should look for:

  • Reserve / safety module. A buffer of protocol-owned funds to absorb shortfalls (Aave’s Safety Module; Maker’s Surplus Buffer; Compound’s reserves). Size relative to TVL matters — a 0.1% buffer is theater.
  • Insurance fund auto-replenishment. Stability fees / reserve factors that route revenue into the buffer.
  • Asset-level isolation modes. Aave V3’s isolation mode and eMode limit cross-asset contagion. A risky asset can only borrow stablecoins up to a debt ceiling; a “high-correlation” eMode (e.g., stablecoins-only) raises LTV for grouped assets.
  • Circuit breakers / pause. Discussed in Tuan-14-Governance-DAO-Security.

3.6 Donation attack on internal accounting (Euler 2023 shape)

This is the protocol-level archetype every auditor should be able to recognize blind. Read this section twice.

Euler had a function donateToReserves(amount) on its eToken (collateral receipt). Semantics: burn amount of your eToken balance, increase the pool’s reserveBalance by the same amount. The intention: an altruistic / governance-driven primitive.

The flaw: donateToReserves did not perform a health check on the donor. Specifically, the donor’s debt (dToken balance) was unchanged while their collateral (eToken balance) was burned. So a donor could walk a healthy account into an underwater state by self-donation.

Combined with Euler’s self-liquidation primitive — where the borrower could liquidate their own underwater position with a built-in discount — this produced a profitable atomic attack:

sequenceDiagram
  participant Attacker
  participant Euler
  participant FlashLoan

  Attacker->>FlashLoan: borrow 30M DAI
  Attacker->>Euler: deposit 30M DAI → eDAI (collateral)
  Attacker->>Euler: borrow 195M DAI (massive over-leverage via Euler's 10x leverage primitive)
  Note over Attacker,Euler: At this point: ~195M eDAI collateral, ~190M dDAI debt, HF > 1
  Attacker->>Euler: donateToReserves(100M eDAI)
  Note over Attacker,Euler: Collateral burned, debt unchanged → position now severely underwater
  Attacker->>Euler: self-liquidate (with discount on remaining eDAI vs dDAI)
  Note over Attacker,Euler: Discount > collateral remaining → attacker pockets the surplus
  Attacker->>FlashLoan: repay

Loss: ~$197M across DAI, USDC, stETH, WBTC pools. Eventually returned by the attacker. (Omniscia post-mortem)

The audit lesson, generalized: every state-changing function on a lending protocol — every one — must check that the caller’s account remains healthy after the change. The check is conventionally checkAccountStatus() or liquidityCheck() called as the function’s last step. Missing this check on one privileged-feeling primitive (donate, transfer of internal balance, modifier hook) is the bug. There is no other safety net.

This generalizes far beyond Euler. Any “internal accounting” primitive (transfer of receipt tokens, donate, sweep, settle, compound, claim) that can change a user’s collateral / debt without a health check is the donation-attack shape.

3.7 Borrow-side oracle manipulation (bZx 2020 shape)

The earliest large oracle-manipulation attacks on DeFi. February 2020, two attacks on bZx Fulcrum totaling ~$954k. Mechanism:

  1. Flash-loan a large amount of ETH from dYdX.
  2. Swap on a thin Uniswap V1 pool to push the displayed price of WBTC (attack 1) or sUSD (attack 2) substantially up.
  3. Use that manipulated price as collateral oracle on bZx. Borrow against the inflated collateral value.
  4. Unwind: dump the manipulated asset, repay the flash loan.

Root cause: bZx’s oracle was Kyber, and Kyber priced via Uniswap reserves. A single-block large-volume manipulation moves the oracle. (palkeo bZx writeup)

The category persists. Mango Markets 2022 is structurally identical, with MNGO-PERP as the manipulated asset on a thin Solana orderbook (Solidus Labs analysis). Variations continue to appear yearly.

Defenses (full treatment in Tuan-09-Oracle-MEV-Economic-Attack):

  • TWAP with sufficient window (Uniswap V3 native TWAP; Chainlink heartbeat-based feeds).
  • Multi-source oracles (median of 3+ feeds).
  • Caps on price movement per update.
  • Restrict listable assets to those with deep, multi-venue liquidity.

The auditor’s reflex for any lending protocol: “if I could make this asset’s spot price 10× higher for one block, what could I borrow?” If the answer is “more than the asset’s real market cap”, the oracle is the bug.


4. Vault Patterns

4.1 ERC-4626 (light touch; full treatment in Week 7)

The standard interface for “deposit asset, receive share; burn share, withdraw asset”. Cross-link Tuan-07-Token-Standards-Integration-Risk. Recap of the four canonical rounding rules:

OperationDirectionWhy
previewDeposit(assets) → sharesRound downUser receives at most fair shares
previewMint(shares) → assetsRound upUser pays at least fair assets
previewWithdraw(assets) → sharesRound upUser burns at least fair shares
previewRedeem(shares) → assetsRound downUser receives at most fair assets

The first-depositor inflation attack (donate-to-vault) is the textbook example of why rounding direction matters; OpenZeppelin’s 4626 implementation uses virtual shares (offset by 1e3 or 1e6) to neutralize it.

4.2 Yield aggregator architecture

A yield vault is a thin wrapper over an underlying strategy:

flowchart LR
  U[User] -->|deposit asset| V[Vault<br>ERC-4626]
  V -->|delegate funds| S[Strategy contract]
  S -->|deposits to| P1[Aave]
  S -->|or LPs in| P2[Curve]
  S -->|or stakes| P3[Convex / Aura / EigenLayer]
  P1 -->|yield| S
  P2 -->|yield + CRV / CVX| S
  P3 -->|points + tokens| S
  S -->|harvest + report| V
  V -->|share appreciation| U

Audit surface:

  • Vault ↔ strategy trust. Strategy holds delegated capital. Compromise of the strategy = drain of the vault. Verify the strategy authority is multisig or time-locked; that strategy contract is upgradeable only via vault governance.
  • Performance fee accounting. Fees are typically charged on harvest (against net new yield). Bugs:
    • Fee charged on principal, not just yield (catastrophic).
    • Fee charged on temporarily-realized “negative yield” recovery (double-charging users for losses).
    • Fee charged on stale pricePerShare due to delayed harvest (misallocation between depositors).
  • Loss reporting. When the strategy reports a loss (e.g., impairment in underlying protocol), how is it socialized? Pro-rata across all shareholders, or only depositors-since-last-harvest? Yearn V2 explicitly handles this with a loss accounting field; many forks omit it and the first user to withdraw after a loss avoids it.
  • Withdrawal queue / shutdown. Some strategies can’t be instantly unwound (e.g., Convex with vote-locked CVX). A user-facing withdraw() that requires liquid funds breaks. Solutions: withdrawal queue (Yearn V3, Lido), bounded slippage tolerance on emergency unwind, or limit instant withdraws to a liquidityReserve.
  • Reentrancy at the strategy boundary. Strategy calls into underlying protocols; some return value via callback. CEI + nonReentrant on the vault even if “strategy is trusted”.

4.3 The Yearn V2 lesson: roles and pause

Yearn V2 separates: governance, guardian, management, strategist, harvester. Each has narrowly-scoped powers (e.g., guardian can pause but not move funds). This is the gold-standard role granularity for vault audits. When you see a vault with one onlyOwner modifier guarding everything, that is a finding — not a code bug, but a design bug.

4.4 Strategy compromise risk

Cross-protocol dependency means every protocol the strategy touches is a potential drain vector. In a vault audit, you must enumerate the dependency tree.

Example: a “boosted Curve yield” vault might:

  • Hold Curve LP tokens.
  • Stake them in Convex.
  • Compound CVX rewards into vlCVX.
  • Vote-lock with Convex’s governance contract.

A bug or governance attack in any of {Curve, Convex, vlCVX} risks the vault. The audit needs to:

  1. Enumerate the dependency tree.
  2. State trust assumptions for each dependency.
  3. Identify the worst-case loss if each is compromised.

This is the trust-seam diagram (Tuan-01-Web3-Blockchain-Crypto-Fundamentals §6.1) applied at protocol granularity.


5. Stablecoins

Stablecoins are the largest TVL category in DeFi and the most fragile design space because the entire user proposition is a peg, and the peg lives in collective belief. The auditor’s job is to model the belief mechanism, find the conditions under which it breaks, and quantify the runway between normal and broken.

5.1 Three archetypes

TypeMechanismExampleFailure mode
CDP / over-collateralizedUser locks collateral, mints stablecoin against it; collateral > debt; liquidation if collateral dropsDAI (Maker), LUSD (Liquity), GHO (Aave)Collateral cascade if liquidations fail; oracle manipulation; PSM-induced centralization
Fractional / algorithmic-hybridPartly collateralized; algorithmic AMO actions defend the pegFRAX (now 100% backed; previously fractional)Reflexive collapse if the algorithmic side gets called on at scale
Algorithmic (pure)Mint-burn arbitrage with no exogenous collateralUST/Terra (failed 2022)Death spiral if confidence breaks

5.2 CDP: MakerDAO / DAI

The auditor’s MakerDAO mental model:

flowchart LR
  U[User] -->|deposit ETH| Vault[Maker Vault]
  Vault -->|mint up to collateral / liquidation ratio| DAI[DAI]
  Vault -->|owes stability fee| SF[Stability Fee → Surplus Buffer]
  Price[Oracle: ETH/USD] --> Vault
  Vault -->|if collateral falls below liquidation ratio| Liq[Liquidation 2.0 — Clipper Dutch auction]
  Liq --> Keeper[Keeper buys collateral at decreasing price]
  PSM[Peg Stability Module: USDC ↔ DAI 1:1] --> DAI
  Surplus[Surplus Buffer] -->|deficit > buffer| Flop[MKR mint / flop auction]

Key components:

  • Collateral types. Each is a separate ilk with its own debt ceiling, liquidation ratio, stability fee. Allows risk segmentation but multiplies audit surface.
  • Stability Fee. Continuous-compounding interest on minted DAI, accruing per second. Governance-set per ilk. Revenue flows to the Surplus Buffer; excess can be used to buy MKR for burning.
  • Liquidation 2.0 (Dog + Clipper). Replaced the 2020 Cat system after Black Thursday. Dutch auctions start at top price (oracle × buf) and decay over tail seconds via a Calc (e.g., exponential or linear decay). Keepers bid by calling take. Settles in a single transaction — no zero-bid pathology. (Maker Liquidation 2.0 docs)
  • PSM (Peg Stability Module). A direct USDC ↔ DAI 1:1 swap (modulo a small fee). When DAI trades above $1, arbitrageurs deposit USDC into the PSM to mint DAI, sell DAI, and capture the spread; reverse when below. This is the most effective peg-defense tool but creates a deep dependency on centralized stablecoins.

Audit signals:

  • Permissions on Vat / Dog / Spotter / Vow / End / Cure / ESM (the Maker core modules). Each has an auth modifier; governance controls who can rely / deny. Misconfigured auth = drain of dai-of-the-protocol.
  • Stability fee accrual as monotonic per-ilk. A bug skipping accrual on a vault interaction leaks DAI to under-paying borrowers.
  • Oracle Security Module (OSM) delay: Maker uses a 1-hour delay between oracle price observation and use, preventing flash-loan oracle manipulation. A liquidation protocol without a price-update delay or TWAP is suspicious.
  • End / Emergency Shutdown. Triggered by MKR holders; freezes the system and lets DAI holders redeem collateral. Audit how the shutdown distributes residual collateral; how it handles outstanding auctions.
  • Surplus / Debt buffers. Surplus Buffer absorbs auctions that fail. If debt > surplus by more than Vow.sump, a flop auction mints fresh MKR (and dilutes holders). The MKR-mint mechanism is a backstop but also a governance lever — auditor checks that the threshold is appropriate.

Maker is now (post-2024) operating under the Sky rebrand with USDS as the headline stable. [verify which protocol generation and which oracles are active at the time of your audit]

5.3 Fractional / hybrid: FRAX

FRAX’s original design: each FRAX is CR% backed by USDC and (100-CR)% backed by FXS (governance token); the collateral ratio adjusts based on the FRAX market price. AMO (Algorithmic Market Operations Controllers) are autonomous contracts that deploy collateral into yield-generating strategies (Curve pools, Aave deposits, etc.) so long as they don’t move the FRAX peg.

The community has since voted to move FRAX to 100% collateralization. As of 2025, FRAX is functioning as a fully-collateralized stable with a continuing AMO yield apparatus, and is positioning around the Fraxtal L2. [verify CR and AMO deployment at audit time] (FRAX AMO docs)

Audit angles for any AMO-style stablecoin:

  • AMO authority. Who can deploy / withdraw capital via an AMO? Governance, time-locked? If single-EOA-controlled, it’s a centralized risk.
  • AMO yield accounting. Yield generated by AMOs is collateral that backs FRAX. Bugs in profit-taking or in marking-to-market the AMO’s positions can produce phantom backing — FRAX looks 100% backed but a stress test reveals collateral is illiquid (e.g., locked in Curve gauges).
  • AMO loss propagation. If an AMO loses money (e.g., a Curve pool exploit hits an AMO’s LP position), the stablecoin’s backing is impaired. How is the loss recognized? Is there a buffer?
  • PSM-like swap modules between FRAX and USDC (and other stables). Same drain vectors as Maker’s PSM.

5.4 Algorithmic (failed): UST / Terra

UST’s mechanism: 1 UST ↔ $1 of LUNA, atomically mintable / burnable. Arbitrage was meant to defend the peg:

  • UST > 1 of LUNA, mint 1 UST, sell for profit; expands UST supply.
  • UST < 1 of LUNA, sell; contracts UST supply.

In a calm market, this works. Under stress, it inverts:

flowchart LR
  Run[Withdrawal pressure on Anchor at 20% APY] --> Sell[Mass UST selling pushes UST < 1]
  Sell --> Mint[Arbs burn UST, mint LUNA → LUNA supply explodes]
  Mint --> LunaDrop[LUNA price crashes from supply expansion]
  LunaDrop --> Belief[Belief in LUNA collateral collapses]
  Belief --> MoreSell[More UST holders flee]
  MoreSell --> Sell
  style Run fill:#ffcccc
  style LunaDrop fill:#ffcccc
  style Belief fill:#ffcccc

In May 2022, the collapse executed in days: ~$40B in market cap was destroyed. The arbitrage mechanism didn’t fail — it worked correctly and minted unbounded LUNA, exactly as designed. The flaw was the assumption that LUNA’s market depth would absorb mint pressure faster than UST holders could exit. (Briola et al. 2022, arXiv)

The auditor’s prime takeaway: a stablecoin design is only as strong as its weakest collateral assumption under correlated stress. Test every stablecoin against:

  • Sudden 50% drop in primary collateral.
  • Coordinated exit by top-10 holders.
  • Oracle outage for 30+ minutes.
  • DEX liquidity reduced to 10% of normal.

For any algorithmic-leaning design, the answer is usually “the peg breaks”. This is not a code bug; it’s a design finding. Severity Medium-or-higher depending on size.

5.5 Depeg dynamics — cascade scenarios

Even fully-backed stables depeg. USDC depegged to ~$0.87 in March 2023 when SVB collapsed and Circle’s reserves were frozen for ~48 hours. DAI followed because of PSM-USDC concentration. stETH depegged from ETH multiple times in 2022 because of liquidity-pool imbalance, not protocol failure.

Audit signals against depeg risk:

  • Reserve composition transparency. What % is in USD-cash, Treasuries, money-market funds, crypto? Where banked?
  • Redemption flow. Is on-chain redemption 1:1 / immediate / capped? Or via an off-chain attestation that can fail (USDT historically)?
  • Cross-protocol contagion. A stablecoin used as collateral in lending; in AMM pools; as vault deposit token. Depeg cascades through each.
  • Peg-defense liquidity. Permission for the issuer to mint additional supply for market-making vs hard cap. Maker can vote-in a new collateral type; USDC can be minted/redeemed by Circle on-chain.

This is also where the auditor crosses into off-chain risk: bank counterparty, regulatory action, custodian compromise. Note these in the trust-assumptions section even when they are not “audit findings” in the strict sense.


6. Liquid Staking, Restaking, Derivatives (overview)

Each of these gets a full bonus chapter (Tuan-Bonus-Liquid-Staking-Restaking, Tuan-Bonus-Stablecoin-Economic-Modeling). Here, the auditor’s quick-reference.

6.1 Liquid Staking Tokens (LSTs)

LSTs tokenize staked ETH. Two model families:

  • Rebasing (Lido stETH): user’s wallet balance increases daily to reflect rewards. ERC-20 with non-standard balance dynamics.
  • Reward-bearing (Rocket Pool rETH): wallet balance fixed; the ETH-per-share exchange rate increases over time.

Audit angles:

  • Validator set composition. Lido has ~30 permissioned operators; Rocket Pool is permissionless (anyone can be a node operator with 8 or 16 ETH bond). Each has distinct slashing-correlation risk.
  • Withdrawal queue. Lido’s withdrawal queue under stress can extend to days. A protocol that accepts stETH as instant-exit collateral is assuming a peg that may break.
  • Rebasing-token integration. Rebasing balance changes during a transaction; many ERC-20 integrations (Uniswap pairs, wrapped tokens) don’t handle this. Lido provides wstETH (non-rebasing wrapper); always check whether protocols use stETH or wstETH and whether the choice matches their accounting assumptions. Cross-link Tuan-07-Token-Standards-Integration-Risk.
  • Slashing socialization. A slashing event on Lido validators is socialized across all stETH holders (small per-event); on Rocket Pool, the node operator’s RPL bond absorbs first, then the staker. Auditing protocols that hold these as collateral, model the worst-case impairment.
  • Oracle for the exchange rate. The protocol that reads “1 stETH = X ETH” should not read it from a Curve pool’s instantaneous price (manipulable). Use the protocol’s getPooledEthByShares view (rate from the source) or a TWAP — but be aware the source can also be manipulated under bad assumptions.

6.2 Restaking (EigenLayer)

EigenLayer lets a validator re-pledge their staked ETH (or LST) to secure additional services (AVSs). Each AVS sets its own slashing conditions. The slashing primitive went live across 2024–2025. [verify current slashing live-status and parameters] (EigenLayer slashing announcement)

The auditor’s lens on restaking:

  • Cumulative slashing risk. An operator securing N AVSs is exposed to N independent slashing conditions. If the same stake covers multiple AVSs with overlapping conditions, the cumulative attack ROI for a malicious operator can exceed the slashable stake — a profitable attack is theoretically possible. Eigen team’s mitigation: a “security budget” enforced via withdrawal delays + dispute periods. Audit the implementation.
  • AVS slashing-condition bugs. Each AVS writes its own slashing code. A bug in an AVS that slashes honest operators is a multi-million dollar loss for restakers. Audit each AVS the same way as a slashing protocol.
  • Operator selection & decentralization. AVS opt-in by operators; large operators concentrate stake. Centralization risk.
  • LRT (Liquid Restaking Token) layering. ether.fi, Renzo, Swell wrap restaked positions into yet another token. Each layer adds slashing pass-through, withdrawal delays, and dependency on the layer below. The dependency tree gets deep.

This is one of the highest-risk surfaces in 2025–2026 DeFi. Most LRT protocols have not yet been stress-tested under live slashing at scale.

6.3 Perpetual futures (perps)

Perps replicate a futures contract without expiration via a funding rate that periodically settles between longs and shorts based on the deviation between mark price (perp’s price) and index price (spot reference).

funding_rate = clamp( premium_index, ±cap )
premium_index ≈ (mark_price − index_price) / index_price

Longs pay shorts when mark > index; shorts pay longs when mark < index. Funding pulls mark toward index.

Audit angles:

  • Index oracle. If index is from a thin orderbook, a Mango-style manipulation works: open a perp, manipulate the underlying spot oracle, profit from the mark-vs-index gap. (Solidus Labs on Mango)
  • Mark-price formula. Often a TWAP of perp trades. Manipulation requires moving the TWAP; cap windows accordingly.
  • Liquidation cascade. Like lending, but the leverage on perps is typically much higher (5×–50×). A 2% adverse move at 50× leverage liquidates the position. A cluster of similarly-positioned traders liquidates in cascade; the resulting market impact accelerates the liquidations. Insurance funds absorb shortfalls; underfunded insurance funds → social loss.
  • Funding rate caps. Without a cap, a permanently mispriced perp drains one side. With caps, the perp can sit decoupled from spot for extended periods.
  • Auto-deleveraging (ADL). Some perp DEXes (dYdX, GMX style with caveats) automatically liquidate profitable traders against bankrupt positions if the insurance fund is exhausted. Auditing: how is ADL prioritized; can it be gamed?

7. Invariants Auditors Look For

This is the single most important section of the lesson. Internalize it. Carry it into every protocol audit.

For each protocol family, before reading code, write down the invariants. Then audit the code by trying to break them.

7.1 Universal invariants (every protocol)

  • Solvency: Σ(assets) ≥ Σ(liabilities) at all times, for every asset.
  • No unauthorized mint: total supply of any protocol-issued token can only increase via explicitly-authorized mint paths (deposit, harvest, mint-against-collateral). Any sequence of operations that increases total supply without an explicit mint is a critical finding.
  • No unauthorized burn: same in reverse. Symmetric to mint.
  • Conservation of shares: in any operation that exchanges shares for underlying, the ratio (or its monotonic-grow variant) holds.
  • Monotonicity of accumulators: interest indices, fee accumulators, observation cumulatives only increase. Equality allowed; decrease never.
  • Atomicity of fees vs revenue: every fee collected is either credited to a protocol-revenue accumulator or to LPs. Fees never go to attacker / random / address(0).
  • Access control: privileged functions are reachable only via the documented authority path; reversing the call graph from any privileged function leads back only to authorized callers.

7.2 AMM-specific

  • k-invariant (CPAMM): x' · y' ≥ x · y after every swap (zero-fee case: equality).
  • D-invariant (StableSwap): the StableSwap polynomial is non-decreasing across operations.
  • Tick-liquidity invariant (V3): for any tick t, sum of liquidityNet across initialized ticks ≤ t equals current liquidity at price = tickToPrice(t). The crossTick accounting is the soft underbelly here.
  • No phantom liquidity: total liquidity reported by the pool equals the sum of all active position liquidities.
  • Slippage compliance: every swap fulfills the user’s amountOutMin / amountInMax.

7.3 Lending-specific

  • Health-factor preservation: after any user-callable state-changing function, the caller’s account has HF ≥ 1, OR the operation is a liquidation that brings HF up.
  • Interest index monotonic: borrowIndex and liquidityIndex only increase.
  • Liquidation always profitable for a competent liquidator at current oracle prices.
  • No way to mint debt without backing: every dToken increase corresponds to a borrow() against sufficient collateral.
  • Bad debt explicitly tracked: any deficit between assets and liabilities is accounted for in a badDebt variable or socialized via reserves; never silently ignored.

7.4 Vault-specific

  • previewDeposit(x) ≤ deposit(x) (the actual share-mint is at least as favorable to the user as the preview, given rounding); analogously for previewMint/Withdraw/Redeem.
  • No share inflation by donation: convertToShares(1 unit) does not drop to zero when an outsider sends extra underlying directly to the vault. Virtual shares / virtual assets handle this in OZ 4626.
  • Performance fee only on positive yield: fee accumulator only increments when harvest reports positive PNL.
  • Strategy reports loss → loss reflected in share price (in the next harvest cycle, no later).
  • Emergency shutdown → users can redeem at honest pricePerShare (even if partial).

7.5 Stablecoin-specific

  • Collateralization ratio ≥ minimum (per ilk / vault).
  • Peg-defense module solvency: PSM never lets DAI mint above its USDC inventory.
  • Stability fee monotonic.
  • No double-spend of collateral: collateral locked in one vault cannot back another.
  • Emergency Shutdown converges: after End triggers, every DAI holder eventually has a path to redeem proportional collateral.

7.6 Writing invariants as Foundry tests

The mechanical translation:

// test/InvariantLending.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "forge-std/Test.sol";
import "../src/Lending.sol";
import "./Handler.sol";
 
contract LendingInvariants is Test {
    Lending public lending;
    Handler public handler;
 
    function setUp() public {
        lending = new Lending(/* config */);
        handler = new Handler(lending);
        targetContract(address(handler));
    }
 
    function invariant_assets_geq_liabilities() public view {
        // Σ deposits  vs  Σ borrows + reserves
        assertGe(lending.totalAssets(), lending.totalLiabilities());
    }
 
    function invariant_no_account_underwater_without_liq_path() public view {
        // For every user the handler has touched, either HF >= 1 or liquidation is in progress
        address[] memory users = handler.touchedUsers();
        for (uint256 i = 0; i < users.length; i++) {
            uint256 hf = lending.healthFactor(users[i]);
            if (hf < 1e18) {
                assertTrue(lending.isLiquidatable(users[i]));
            }
        }
    }
 
    function invariant_borrow_index_monotonic() public view {
        assertGe(lending.borrowIndex(), handler.lastBorrowIndex());
    }
}

The Handler exposes a narrow set of user-callable operations (supply, withdraw, borrow, repay, liquidate, donate) and records ghost variables (touched users, last index). Foundry’s invariant runner explores random sequences of those calls and asserts your invariants between calls.

Sequences that reveal bugs: typically 50–200 calls deep, well past anything a unit test would reach. Configure runs = 1000+ and depth = 100+ for protocol-level work. (Foundry invariant docs)


8. Lab — Three hands-on exercises

8.1 Lab structure

~/web3-sec-lab/wk08/
├── 01-cpamm-drain/
├── 02-lending-invariants/
└── 03-euler-donation/

Each is a Foundry project. Goal: write exploit + invariant test + a one-paragraph audit finding for each.

8.2 Lab 1 — Audit a vulnerable CPAMM for reserve drain via rounding

You will write a small CPAMM with a plausible-looking but subtly buggy fee accounting that lets a user, via repeated micro-swaps, extract more value than they put in. The bug must be a rounding bug, not a logic bomb.

Setup:

mkdir -p ~/web3-sec-lab/wk08/01-cpamm-drain && cd $_
forge init --no-commit . && rm -rf src/* test/*

Contract:

// src/BuggyCPAMM.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 
/// @notice A CPAMM with a subtly broken fee path: the fee is rounded down,
///         but the invariant check is computed without re-rounding,
///         which means many micro-swaps can leak value.
contract BuggyCPAMM {
    IERC20 public immutable token0;
    IERC20 public immutable token1;
    uint256 public reserve0;
    uint256 public reserve1;
 
    // Fee in basis points (e.g., 30 = 0.30%).
    uint256 public constant FEE_BPS = 30;
    uint256 public constant BPS_DENOM = 10_000;
 
    constructor(IERC20 _t0, IERC20 _t1) { token0 = _t0; token1 = _t1; }
 
    function addLiquidity(uint256 a0, uint256 a1) external {
        token0.transferFrom(msg.sender, address(this), a0);
        token1.transferFrom(msg.sender, address(this), a1);
        reserve0 += a0;
        reserve1 += a1;
    }
 
    /// @notice swap token0 in for token1 out — BUGGY fee math.
    function swapExactToken0ForToken1(uint256 amountIn) external returns (uint256 amountOut) {
        require(amountIn > 0, "zero in");
        // BUG: fee deduction rounds down to zero for amountIn < BPS_DENOM / FEE_BPS.
        uint256 fee = (amountIn * FEE_BPS) / BPS_DENOM; // when amountIn < ~334, fee = 0
        uint256 amountInAfterFee = amountIn - fee;
        amountOut = (reserve1 * amountInAfterFee) / (reserve0 + amountInAfterFee);
        require(amountOut > 0, "zero out");
 
        token0.transferFrom(msg.sender, address(this), amountIn);
        token1.transfer(msg.sender, amountOut);
 
        reserve0 += amountIn;
        reserve1 -= amountOut;
    }
}

Task A: write a Foundry invariant test asserting reserve0 * reserve1 >= k_initial after any sequence of swaps. The handler should call swapExactToken0ForToken1 with random amounts including dust (e.g., 1 to 100 wei).

Task B: when the invariant fails, write the corresponding exploit (a single concrete attacker contract that does the drain) and compute total profit.

Task C: patch — round fee up ((amountIn * FEE_BPS + BPS_DENOM - 1) / BPS_DENOM) and reject sub-minimum swaps. Re-run; invariant should hold.

Task D: write a one-paragraph finding as if you’d flagged this in an audit. Severity rationale: rounding bugs are typically High on AMMs because they monotonically leak the pool, even if per-call leakage is sub-dust.

This shape of bug — fee rounds to zero for dust → many small swaps drain over time — is exactly the class that audit checklists catch on first read but that custom forks routinely re-introduce. The Solodit archive has many examples.

8.3 Lab 2 — Invariants for a mock Aave-style lending protocol

Write a stripped-down lending protocol (single asset, single collateral, fixed prices for simplicity) and a comprehensive invariant suite.

Minimal lending contract:

// src/MockLending.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 
contract MockLending {
    IERC20 public immutable collat;   // e.g., WETH
    IERC20 public immutable debt;     // e.g., DAI
 
    uint256 public collatPrice;       // 1 collat = collatPrice debt (scaled 1e18)
    uint256 public ltvBps = 7500;     // 75%
    uint256 public liqThresholdBps = 8000; // 80%
    uint256 public liqBonusBps = 500; // 5%
 
    mapping(address => uint256) public deposited;
    mapping(address => uint256) public borrowed;
 
    uint256 public totalDeposited;
    uint256 public totalBorrowed;
 
    constructor(IERC20 _collat, IERC20 _debt, uint256 _p) {
        collat = _collat; debt = _debt; collatPrice = _p;
    }
 
    function setPrice(uint256 p) external { collatPrice = p; } // for tests
 
    function supply(uint256 amt) external {
        collat.transferFrom(msg.sender, address(this), amt);
        deposited[msg.sender] += amt;
        totalDeposited += amt;
    }
 
    function withdraw(uint256 amt) external {
        require(deposited[msg.sender] >= amt, "balance");
        deposited[msg.sender] -= amt;
        totalDeposited -= amt;
        require(_healthFactor(msg.sender) >= 1e18, "unhealthy");
        collat.transfer(msg.sender, amt);
    }
 
    function borrow(uint256 amt) external {
        borrowed[msg.sender] += amt;
        totalBorrowed += amt;
        require(_healthFactor(msg.sender) >= 1e18, "unhealthy");
        debt.transfer(msg.sender, amt);
    }
 
    function repay(uint256 amt) external {
        debt.transferFrom(msg.sender, address(this), amt);
        require(borrowed[msg.sender] >= amt, "overrepay");
        borrowed[msg.sender] -= amt;
        totalBorrowed -= amt;
    }
 
    function liquidate(address user, uint256 debtToCover) external {
        require(_healthFactor(user) < 1e18, "healthy");
        require(debtToCover <= borrowed[user] / 2, "close factor");
        uint256 collatSeized = (debtToCover * 1e18 * (10_000 + liqBonusBps)) / (collatPrice * 10_000);
        require(deposited[user] >= collatSeized, "collat");
 
        debt.transferFrom(msg.sender, address(this), debtToCover);
        borrowed[user] -= debtToCover;
        totalBorrowed -= debtToCover;
 
        deposited[user] -= collatSeized;
        totalDeposited -= collatSeized;
        collat.transfer(msg.sender, collatSeized);
    }
 
    function _healthFactor(address user) internal view returns (uint256) {
        if (borrowed[user] == 0) return type(uint256).max;
        uint256 collatVal = (deposited[user] * collatPrice) / 1e18;
        uint256 collatAtLT = (collatVal * liqThresholdBps) / 10_000;
        return (collatAtLT * 1e18) / borrowed[user];
    }
}

Write a handler:

// test/Handler.sol
contract Handler is Test {
    MockLending public lending;
    MockERC20 public collat;
    MockERC20 public debt;
    address[] public actors;
    mapping(address => bool) public seen;
 
    constructor(MockLending _l, MockERC20 _c, MockERC20 _d) {
        lending = _l; collat = _c; debt = _d;
        for (uint256 i = 0; i < 5; i++) actors.push(address(uint160(0x100 + i)));
    }
 
    function _track(address a) internal { if (!seen[a]) { seen[a] = true; } }
 
    function supply(uint256 actorSeed, uint256 amt) external {
        address a = actors[actorSeed % actors.length];
        amt = bound(amt, 1e15, 100e18);
        deal(address(collat), a, amt);
        vm.startPrank(a);
        collat.approve(address(lending), amt);
        lending.supply(amt);
        vm.stopPrank();
        _track(a);
    }
 
    function borrow(uint256 actorSeed, uint256 amt) external {
        address a = actors[actorSeed % actors.length];
        amt = bound(amt, 1e15, 50e18);
        try lending.borrow(amt) {} catch {}
    }
 
    // analogous wrappers for withdraw, repay, liquidate, setPrice...
}

And the invariants:

function invariant_assets_geq_liabilities() public view {
    // collateral held in contract == sum of all user deposits
    assertEq(collat.balanceOf(address(lending)), lending.totalDeposited());
}
 
function invariant_total_deposited_geq_total_borrowed_value() public view {
    uint256 collatVal = (lending.totalDeposited() * lending.collatPrice()) / 1e18;
    // In a healthy market we want collatVal >= totalBorrowed.
    // Note: in a price-crash scenario this can break; the invariant test
    // will reveal under what price moves bad debt forms.
    assertGe(collatVal, lending.totalBorrowed());
}
 
function invariant_no_user_silently_underwater() public view {
    for (uint256 i = 0; i < 5; i++) {
        address a = address(uint160(0x100 + i));
        if (lending.borrowed(a) > 0) {
            uint256 hf = lending._healthFactor(a);
            // Either healthy, OR the test framework can immediately liquidate.
            if (hf < 1e18) {
                // Check that a liquidator could profitably take this
                assertGt(lending.deposited(a), 0, "underwater & nothing to seize");
            }
        }
    }
}

Tasks:

  1. Run the invariants. They should pass under benign sequences and (intentionally) fail when the handler sets price drops large enough to cause bad debt while no liquidator is called.
  2. Add a donate(uint256) function to MockLending that increases deposited[msg.sender] without an associated collat.transferFrom (intentional Euler-style bug). Re-run. Verify the first invariant fails.
  3. Remove the bug. Add accrued interest (a per-second borrowIndex that grows). Re-run. Verify monotonicity holds.
  4. Write a finding for the donate bug.

8.4 Lab 3 — Reproduce the Euler donation-attack pattern

Extend the lending protocol from Lab 2 with:

  • An eToken (collateral receipt) and a dToken (debt receipt) as separate ERC-20-ish balances.
  • A donateToReserves(uint256) that burns the caller’s eToken and adds to a reserves accumulator — with no health check on the caller.
  • A selfLiquidate() primitive that, when caller is underwater, allows them to liquidate their own position with the standard 5% liquidation bonus.

The attack:

  1. Deposit 1000 collateral → mint 1000 eToken.
  2. Borrow 750 against it → mint 750 dToken.
  3. Call donateToReserves(400 eToken). Collateral balance: 600 eToken. Debt: still 750 dToken. Position is now underwater.
  4. selfLiquidate(): pay back some debt, seize remaining collateral at 5% discount. Math works out such that the discount on the remaining collateral exceeds the loss from the donation, net profit to attacker.

Implement this. Show that steps 3 and 4 net positive for the attacker, draining the pool.

Patch: add require(_healthFactor(msg.sender) >= 1e18) at the end of donateToReserves. Re-run; attack reverts.

Write the finding. Severity: Critical — drains the pool with attacker capital alone, no flash loan needed.

This is the cleanest reproduction of the Euler shape. Once you understand it at this scale, you’ll catch the donation pattern in any internal-accounting protocol you audit.

8.5 Stretch — multi-asset, with interest

Extend Lab 2 to:

  • Two collaterals and two debts.
  • Real per-second interest accrual.
  • Reserve factor going into a reserves accumulator.

Add invariants:

  • Σ user.eToken_i = totalSupplied_i per asset.
  • Σ user.dToken_i = totalBorrowed_i per asset.
  • For each asset: balanceOf(this) >= totalSupplied_i − totalBorrowed_i + reserves_i.

Run with runs = 5000, depth = 200 overnight. Triage any failure traces. This dataset is your audit’s deepest stateful coverage.


9. Anti-patterns — add to the audit checklist

Add to your running checklist (from prior weeks):

AMM

  • Custom fee math that diverges from “rounding favors the LP” — verify with a fuzz test.
  • First-LP path with no minimum-liquidity burn / no initial-ratio guard.
  • getReserves() / slot0() / virtual_price() reads in callbacks or nonReentrant-bypassed paths.
  • V3-style tick boundary check with different precision on the two sides of the comparison.
  • Slippage / deadline absent, or hard-coded to 0 / type(uint).max.
  • Multi-hop routing without per-hop slippage protection.
  • Custom V3 fork without verification that crossTick, swapStep, nextInitializedTick are consistent.

Lending

  • Any state-changing user function that does not end with a health-factor check.
  • Donation / sweep / transfer-of-internal-balance primitive without health check.
  • Liquidation gated by token-holding check that breaks for flash-loan-sourced liquidators.
  • No minimum debt / position size — dust accumulates and is unliquidatable.
  • Permissionless asset listing without per-asset risk parameters.
  • Oracle is a spot read from a single AMM pool.
  • Interest index can decrease.
  • Bad debt silently absorbed into healthy positions instead of explicitly tracked.

Vault

  • ERC-4626 vault without virtual shares / virtual assets offset.
  • Performance fee accruable from negative-to-positive PNL transition.
  • Strategy authority granted to a single EOA.
  • Strategy unwind requires liquid funds that the strategy can’t guarantee.
  • Cross-protocol dependency tree not documented in scope.

Stablecoin

  • Oracle price used directly for liquidation without delay (OSM) or TWAP.
  • PSM with no inventory cap.
  • Algorithmic mint primitive with no rate limit under stress.
  • Stability fee can be set negative or reverted.
  • Emergency Shutdown path not exhaustively tested for residual auction cases.

LST / Restaking / Perps

  • Protocol prices stETH from a Curve pool spot, not from the source rate or a TWAP.
  • Restaking position used as collateral without modeling slashing impairment.
  • Perp index oracle read from a thin orderbook.
  • Funding-rate cap absent or set so high that funding is meaningless.
  • ADL / insurance fund interaction not modeled for catastrophic moves.

10. Trade-offs and Open Debates

DecisionOption AOption BAuditor view
AMM math representationV2 (x·y=k)V3 (concentrated liquidity)V3 capital efficiency comes with tick-boundary bug surface; V2 is robust but inefficient. For long-tail tokens, V2 is still defensible; for blue-chip pairs, V3 has won.
Liquidation incentiveFixed bonus (Aave/Compound)Dutch auction (Maker)Fixed bonus is simpler and works at scale; Dutch auctions extract more for the protocol when liquidators are competitive but are harder to reason about under network congestion. Recall Maker’s 2020 Black Thursday fix.
Stablecoin collateralPure crypto (Liquity ETH-only)Mixed with stables (Maker w/ PSM)Pure-crypto is censorship-resistant but vulnerable to crypto-wide crashes; mixed gives peg stability via PSM but couples to centralized issuers. There is no free lunch.
Vault role modelSingle ownerMulti-role (Yearn V2 style)Multi-role every time for any non-trivial vault.
Restaking exposureNative ETH onlyLST-backedLST-backed restaking concentrates risk in the underlying LST; native restaking ties up actual validators. Latter is auditable; former adds layers of pass-through.
Perp mark priceTWAP of own venueOracle from external venueSelf-TWAP is manipulable (Mango); external-oracle is censorship-prone but harder to manipulate. Hybrid (TWAP capped by external oracle band) is the modern answer.

11. Quiz (≥80% to advance)

  1. Q: A custom Uniswap V2 fork charges a 0.25% LP fee and a 0.05% protocol fee. The fee is computed as (amountIn * 30) / 10000, then split in the swap path. Without reading more, what’s the first thing an auditor checks? A: That rounding is preserved in the “favor the LP” direction across all paths (amountIn, amountInAfterFee, the k-check). Custom fee splits introduce intermediate divisions; verify with a fuzz test that k_after >= k_before after any sequence of swaps, including dust amounts.

  2. Q: Curve’s get_virtual_price() is consumed by an external lending protocol as the price of CRV LP collateral. The Curve pool has a standard @nonreentrant('lock') on remove_liquidity. Is the lending protocol safe from read-only reentrancy? A: Not necessarily. The Vyper nonreentrant lock protects state-changing functions but historically did not protect get_virtual_price views. During remove_liquidity, the user receives ETH via an external CALL before virtual_price is finalized; if that ETH receiver re-enters get_virtual_price and the lending protocol prices CRV LP at that moment, it reads an interim value. Curve added protection on newer pools; verify by checking the specific pool’s bytecode + the consumer’s read path.

  3. Q: A Uniswap V3 fork claims to add a “0.1% fee tier”. What two implementation details must be audited? A: (1) The new fee tier’s tickSpacing constant — wrong spacing breaks tick-crossing math. (2) Every internal call that switches on fee (swap, mint, position arithmetic) — incomplete propagation means some paths use stale fee values. The KyberSwap class of bug lives in mismatched precision between estimation and execution at boundaries.

  4. Q: An Aave-V3-style lending protocol has a function redeemRewardsToCollateral(amount) that converts user-accrued rewards directly into aToken balance. The function does not call validateHF() at the end. Why is this a finding? A: It is a Euler-shape donation primitive in reverse: it changes the user’s collateral side without revalidating health. While it increases collateral (less obviously dangerous than a decrement), if there’s any conversion-rate flexibility (e.g., the reward token has a price oracle), an attacker could combine this with other operations to manipulate accounting. Even when “harmless”, an unchecked state-changing path is a class-1 audit smell — protocols evolve, and the next refactor will weaponize it.

  5. Q: A protocol’s documentation says “liquidations are always profitable for liquidators by a 5% bonus”. Under what realistic conditions is this false? A: (i) Oracle price differs from market price during a crashing asset — liquidator pays oracle price for collateral worth less in market. (ii) Gas cost on mainnet exceeds 5% of small positions — dust unliquidatable. (iii) Liquidator must source debt token; if debt token has limited liquidity (e.g., locked-up LST), the liquidator’s effective cost is higher. (iv) Liquidation requires multiple transactions or a specific calldata format that adds infrastructure cost.

  6. Q: Why is the “donation-attack” class on internal accounting (Euler) categorically different from the “donation-attack” class on ERC-4626 first-deposit? A: They share the name but exploit different invariants. ERC-4626 first-deposit inflation manipulates share-price for future depositors; Euler-donation manipulates the donor’s own account health to exploit the protocol’s liquidation logic. ERC-4626 mitigation is virtual shares; Euler mitigation is mandatory health checks on every account-changing function. An auditor’s checklist should have both items separately.

  7. Q: A vault uses harvest() to claim rewards from an underlying farm and re-deposit. The harvest function is public and has no guard. Why might that be OK and why might it be catastrophic? A: OK if harvest is idempotent, has no parameters chosen by the caller, and the strategy is robust to being called at any block (e.g., MEV bots can call it; this just speeds up yield realization). Catastrophic if the function takes caller-chosen slippage parameters, or if the strategy unwinds positions and is sensitive to oracle state at call time — in which case harvest becomes a sandwichable external action and the strategy can be drained via repeated bad harvests.

  8. Q: MakerDAO’s PSM lets anyone mint DAI 1:1 by depositing USDC. Walk through how this concentrates centralization risk in DAI, and one defensive design choice Maker has made. A: Each PSM-minted DAI is collateralized 1:1 by USDC held by the protocol. If USDC freezes or depegs (Mar 2023 SVB scenario), the corresponding DAI is also impaired. Defensive choices: (a) per-PSM debt ceilings (caps exposure to any single stable); (b) on-the-fly stability-fee adjustments; (c) the broader ecosystem move toward USDS/Sky governance with diversified collateral. The structural risk remains: PSM is a peg-defense tool that creates a trust assumption you can’t audit on-chain. [verify current PSM debt ceilings]

  9. Q: A perpetual futures DEX uses its own internal mark-price TWAP for liquidation, and a Chainlink feed for the index price. An attacker controls 1% of the trading volume. Is manipulation possible? A: Yes, in principle. The mark-price TWAP is constructed from on-venue trades — 1% volume control might be enough to perturb the TWAP if the window is short or the venue’s depth is shallow. The Chainlink feed for index is much harder to manipulate. The attack pattern is: open a perp position, perturb mark-price via internal trades, trigger liquidations of opposing positions, profit from the spread. Mitigations: cap how far mark can diverge from index; require depth thresholds; longer TWAP windows. (Mango Markets case)

  10. Q: You are auditing a new lending market that lists a permissionless addAsset(asset, ltv, lt, liqBonus) call. The call is gated by onlyGovernance and goes through a 2-day timelock. Is this safe enough? A: It’s a defense-in-depth start, but on its own no. Important gaps: (i) a 2-day timelock is too short for the community to react to a malicious / mistaken addition of a thin-liquidity asset; (ii) governance can be captured (flash-loan governance attacks, Tuan-14-Governance-DAO-Security); (iii) the parameter checks at the function are static — they should also verify oracle availability, minimum liquidity in DEXes, and trading volume thresholds before allowing the asset to be listed. The audit finding is Medium: insufficient asset-listing controls, with concrete recommendations: longer timelock, on-chain oracle health checks, isolated debt ceiling on first listing.


12. Week 08 Deliverables

  • Lab 1 — vulnerable CPAMM: invariant fails, exploit PoC works, patched version passes invariants.
  • Lab 2 — lending invariants: handler-based stateful fuzz suite covering at least five canonical invariants from §7.
  • Lab 3 — Euler donation reproduction: working exploit on a stripped lending protocol; written finding with severity rationale.
  • Personal “AMM math cheat sheet” (constant product / StableSwap / V3 sqrtPriceX96 + tick math) — one page, hand-written.
  • Trust-seam diagram for a real DeFi protocol of your choice (Aave / Compound / Spark / Morpho / Curve / Uniswap V4 / Frax) — annotate each seam with one realistic failure mode.
  • One-paragraph written answer to each quiz question in ~/web3-sec-lab/wk08/notes.md.
  • Master audit checklist updated with all §9 items.

13. Where this leads

Next week: Tuan-09-Oracle-MEV-Economic-Attack. We zoom into the single most underestimated attack surface in DeFi — the price of an asset. You’ll model oracle architectures (push vs pull, Chainlink vs UMA vs Pyth, on-chain vs off-chain), compute the cost of a TWAP manipulation, study the full MEV pipeline (mempool → builder → relay → proposer under PBS), and reproduce a flash-loan + oracle attack end-to-end. The bZx, Harvest, and Mango shapes touched here will return in full detail.

Then Tuan-10-Bridge-Cross-Chain-Security takes the protocol-level mental model and applies it across chains, where finality assumptions (Week 01 §3) become attack surface.

The arc of Phase 3 is one continuous theme: at protocol scale, every audit finding is an invariant the developer didn’t write down. The tools that find them are also continuous: invariants → handlers → stateful fuzzing → formal specification (Phase 5). What you build this week will be reused for every protocol audit you do afterward.


Last updated: 2026-05-16 See also: Roadmap · References · MOC-Web3-Security-Mastery · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-06-Vulnerability-Classes-Part-2 · Tuan-07-Token-Standards-Integration-Risk · Tuan-09-Oracle-MEV-Economic-Attack · Case-Euler-Finance-2023 · Case-KyberSwap-Elastic-2023 · Case-bZx-Price-Manipulation-2020 · Case-Harvest-Finance-2020 · Tuan-Bonus-Stablecoin-Economic-Modeling · Tuan-Bonus-Liquid-Staking-Restaking