How exit_v2_total — the number every extractable formula leans on — is actually measured. ~12 min.
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.
at_risk_exit_liquidity.go.)
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:
Swap T for its counter-token in venues holding T directly. What you walk away with is the other side of the pool.
Same swap, but reached via a 1-hop permissionless WRAP_UNWRAP derivative of T (e.g. wstETH for stETH).
Withdraw collateral already deposited as T — max(available_liquidity, max_borrow_capacity).
Deposit fresh T as collateral and borrow an other asset, capped by supply caps + the LTV haircut.
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
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.)
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:
seenVenues means a venue counted in the direct pass won't be re-counted in the bridge pass — no double credit.sort.Strings order. Map order is random and float addition is non-associative, so an unsorted walk would drift by a ulp per run — breaking byte-parity with Python. (The final cross-mechanism sum uses floats.KeyedSum / Neumaier for the same reason — the recurring parity-hygiene discipline you saw in L19's aggregation.)lending_borrow: where a real bug livedThe 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_status | borrow contribution |
|---|---|
paused / exhausted | 0 — you can't deposit more T, so no fresh borrow. |
finite (with remaining headroom + other-borrowable) | min(other_borrowable, depositCap(scr) × LTV-haircut) |
unlimited / legacy | other_borrowable preferred, else in-market fallback (if cap not exhausted) |
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.)
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:
eff_extractable = min(eff_extractable, exit_v2_total) — the hard per-token upper bound, because all attackers share the one exit pool.by_venue totals feed computeExtractable, which self-subtracts the failed venue from the applicable mechanisms ("what's left to exit through after draining this contract").min(at_stake, exit_v2_total), or honest-$0 when exit is off-chain only.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.
pool mechanism, what dollars get counted at a venue holding T?sort.Strings order during the bridge pass?supply_cap_status = "exhausted". Its lending_borrow contribution is…exit_v2_total connect to L19's aggregation?computeExtractable (L20) uses a mechanism's by_venue totals, it self-subtracts the failed venue. Why?supply_cap_status dispatch and the LTV-haircut bug ($2.85B Spark false positive) it fixes.exit_v2_total into L19's exit cap and L20's self-subtracting extractable.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.