Lesson 21 · Exit Liquidity v2 · Deeper Track

The drainable ceiling

How exit_v2_total — the number every extractable formula leans on — is actually measured. ~12 min.

Builds on: L19 · L20 Anchor: cashing out a stolen position New: four exit mechanisms New: counter-token apportionment

L19 and L20 kept invoking one number as the final ceiling: exit_v2_total. The exit cap in the rollup, the min(at_stake, exit_total) for NAV oracles, the borrow caps — all of them clamp against it. But what is it? It's the answer to a brutally practical question: "if you stole this token, where could you actually turn it into something you keep — and how much?" Stealing $5B of a token nobody will buy is worth $0. Exit-liquidity v2 measures the real cash door.

Your anchor: the cash-out problem
Every DeFi exploit post-mortem has a second act — the attacker has the tokens, now they have to get out. They dump into pools (and crash the price), they bridge to a wrapped form and dump that, they withdraw lent collateral, or they deposit-and-borrow a stable they can keep. Exit-liquidity v2 enumerates exactly those four routes and sums the dollars each can absorb. (Phase 2.6: "single metric, true upper bound" — at_risk_exit_liquidity.go.)

1 · Four mechanisms, one total

For each focus token T, computeExitLiquidityV2 decomposes the exit into four buckets, each with a total_usd and a by_venue breakdown. The token-level total_usd is their sum:

pool

Swap T for its counter-token in venues holding T directly. What you walk away with is the other side of the pool.

wrap_bridge

Same swap, but reached via a 1-hop permissionless WRAP_UNWRAP derivative of T (e.g. wstETH for stETH).

lending_in_market

Withdraw collateral already deposited as T — max(available_liquidity, max_borrow_capacity).

lending_borrow

Deposit fresh T as collateral and borrow an other asset, capped by supply caps + the LTV haircut.

The unifying idea: counter-token, not the token
Notice three of the four mechanisms hand you a different asset (the pool's other side, the borrowed stable). This is the same lesson as L20's oracle attack: you can't exit a token into itself. Exit liquidity counts the counter-token dollars — the cash on the other side of every trade — never T's own face value.

2 · Counter-token apportionment (the pool/wrap math)

Here's processVenue for a swap venue. For a pool holding T, it sums the USD of every other token in that venue — that's what a seller of T receives:

// at_risk_exit_liquidity.go — processVenue
holds := holdsByVenue[venueID]
var otherTotal float64
for _, h := range holds {
    if _, inWrap := state.wrapClass[h.tokenAddr]; inWrap {
        continue                       // skip T and its wrapped forms — not a real exit
    }
    otherTotal += h.usd                  // counter-token cash you'd receive
}
mech := MechPool
if bridgeVia != "" { mech = MechWrapBridge }   // reached via a derivative → wrap_bridge bucket
state.mechanismTotals[mech] += otherTotal
Two traps the code guards against
(a) The derivative-as-venue mirage. If a venue is itself in T's wrap class, it's skipped entirely (inWrap → return 0) — a wrapped form of T is not an exit for T; you'd just be holding T in a different coat. (b) Counter-token filtering. Inside a venue, any holding that is T or a wrap-class member of T is excluded from otherTotal — a stETH/wstETH pool offers you no real exit, since both sides are "the same risk." (buildWrapClass: T's wrap class = {T} ∪ direct WRAP_UNWRAP neighbours where permissionless == true.)

3 · The bridge pass — and why it's sorted

After the direct pass, computeExitForToken runs a bridge pass: for each permissionless derivative of T, it credits that derivative's venues to the wrap_bridge mechanism (an attacker can wrap T → dump the wrapped form). Two details worth seeing:

4 · lending_borrow: where a real bug lived

The lending-borrow bucket is the subtle one, because "how much can you borrow against fresh T?" depends on supply caps and the LTV haircut. computeBorrowValue dispatches on supply_cap_status:

supply_cap_statusborrow contribution
paused / exhausted0 — you can't deposit more T, so no fresh borrow.
finite (with remaining headroom + other-borrowable)min(other_borrowable, depositCap(scr) × LTV-haircut)
unlimited / legacyother_borrowable preferred, else in-market fallback (if cap not exhausted)
The $2.85B haircut bug — why the LTV field name matters
Pre-fix, the code hardcoded attrs["ltv"] / 10000. But Compound V3, Morpho, Silo, and Euler V2 store the LTV under different field names (borrow_collateral_factor, lltv, max_ltv) — so attrs["ltv"] returned 0, the if ltv > 0 guard skipped the haircut, and the deposit cap was counted unhaircut. On collateral-only markets (LTV genuinely 0, e-mode) it was even worse. Spark USDC alone added $2.85B of phantom borrow capacity. The fix (PR #311) switched to a field-priority normalizeLTV dispatch with an explicit "LTV present but ≤ 0 means collateral-only → no borrow" branch. (at_risk_ltv.go ltvFieldPriority; the Euler V2 borrow_collateral_factor support is literally the most recent commit on this branch.)

5 · Where it plugs back in

The output is stamped on the focus-token node as exit_liquidity_v2{total_usd, by_mechanism, derivative_bridge}. Then it becomes the ceiling everywhere you've already seen:

The whole extractable story now closes
L20 told you extractable is "bounded by real exit liquidity" and left exit_v2_total as a given. Now you can derive it: four mechanisms, counter-token cash only, wrap-class exclusions, supply-cap + LTV-haircut on borrow. Every dollar in an at_risk extractable figure traces back to a venue that could actually absorb the stolen value.

Check yourself

1. What does exit-liquidity v2 fundamentally measure?
2. For the pool mechanism, what dollars get counted at a venue holding T?
3. A venue is itself in T's wrap class (e.g. a wstETH wrapper for stETH). It contributes…
4. Why are derivatives iterated in sort.Strings order during the bridge pass?
5. A lending market has supply_cap_status = "exhausted". Its lending_borrow contribution is…
6. The $2.85B Spark false-positive came from…
7. How does exit_v2_total connect to L19's aggregation?
8. When computeExtractable (L20) uses a mechanism's by_venue totals, it self-subtracts the failed venue. Why?
↳ Ask your teacher
Try: "Show me buildWrapClass and what permissionless means here." · "How is market_other_borrowable_usd computed upstream?" · "What's in derivative_bridge.bridged_exit_usd used for?" · "Walk the Phase 2.8.a HOLDS-based bridge pass (native ETH)." · "How does computeDepositCap apply the LTV haircut?"

What you can now do

Grounded in: pkg/risk/at_risk_exit_liquidity.go (computeExitLiquidityV2 / computeExitForToken four mechanisms + total_usd, processVenue counter-token apportionment + derivative-as-venue skip, buildWrapClass permissionless 1-hop, sorted bridge pass + seenVenues dedup + KeyedSum, processLendingVenue / computeBorrowValue supply_cap_status dispatch), at_risk_ltv.go (ltvFieldPriority / normalizeLTV, the #311 haircut fix). Verify against source — the code is the truth.