How thousands of attack cells collapse into one honest at-risk dollar figure. ~12 min.
L6 told you cells exist. L13 gave you the per-field math. L16 showed how risk propagates. But we never answered the question that actually produces the flagship output: once you have hundreds of cells for one token, how do they become a single, trustworthy "$X at risk"? Sum them and you get nonsense — WBTC once scored 451% of its own supply. This lesson is the machine that fixes that.
at_risk_value_pool.go: "an Aave V3 WBTC market is
reachable via at least 5 distinct cells … all of which would extract the SAME dollars … WBTC raw 451% of supply on the 260517 run.")
Before aggregating, know what you're aggregating. Each Cell (pkg/risk/at_risk_types.go)
carries two dollar values, and they answer different questions:
| Field | Question it answers | How it's set |
|---|---|---|
at_stake_usd | "How much value is exposed if this thing fails?" | Stamped at emit time — e.g. the token's full SupplyUSD for an admin on the token contract. |
extractable_usd | "How much can the attacker actually walk away with?" | Computed later (at_risk_extractable.go): bounded by real exit liquidity / borrowable depth. |
The gap between them is the difference between "this much is at risk in principle" and "this much could be drained in practice." Extractable is the smaller, harder number — it's clamped by where you could actually sell or borrow the stolen value:
// at_risk_extractable.go — per target_role, sum the applicable exit mechanisms, // SELF-SUBTRACTING the failed venue (you can't drain a contract THROUGH itself). availableExit := 0.0 for _, mech := range exitMechanismsByRole[role] { if role == TargetRoleFocusToken { availableExit += mechTotal(mech) // issuer failure: no self-subtract } else if delta := mechTotal(mech) - mechSubtract(mech); delta > 0 { availableExit += delta // "what's LEFT after draining this venue" } }
extractable_usd = 0 — for every role. The discovery layer emits last-resort
ADMIN_CTRL edges to deployers of immutable contracts, but a deployer who can't actually do anything isn't
a threat. Skipping this guard, an audit found, falsely attributed $8B+ across aWETH / aUSDC / aWBTC to
Aave's deployer key. (at_risk_extractable.go, Phase 2.7 hotfix; sets
discovery_gap = true — "an admin was found, but it's not a real one.")
value_pool_idThe trick that tames the 451% problem is one string stamped on each cell:
value_pool_id (at_risk_value_pool.go, Phase 2.7). Cells that drain the
same downstream value pool get the same value_pool_id — even when their
target_role and target_id differ. The aWBTC vault cell, the lending-market cell, and the
oracle cells all collapse onto one id, because compromising any of them drains the same WBTC.
When no collapse rule applies, it falls back to "<role>:<target_id>" — the pre-2.7 behaviour, i.e.
"each target is its own pool."
Here's the heart of it — aggregateEffective (at_risk_aggregate.go). It runs once for
at_stake_usd and once for extractable_usd:
func aggregateEffective(cells []*Cell, field cellUSDField) float64 { byPool := make(map[string]float64) for _, c := range cells { if c.Partial { continue } // 1. DROP partial (multisig signer derivatives) pid := c.ValuePoolID if pid == "" { pid = role + ":" + tid } // fallback grouping v := field(c) if existing, ok := byPool[pid]; !ok || v > existing { byPool[pid] = v // 2. MAX within each pool } } return floats.KeyedSum(byPool, ...) // 3. SUM across pools (order-independent) }
Three moves, and each encodes a real claim about the world:
partial cells. Multisig signers each produce a derivative cell sharing the
anchor's pool id; keeping them would multiply one threat by its signer count. (More on multisig expansion below.)Naive cell sum: $4.4B. After value-pool collapse: max-in each pool ($1.0B, $0.4B), then sum-across = $1.4B. Same discipline as L13's max-over-channels and L16's max-over-paths — units that overlap combine by MAX; units that are independent combine by SUM.
Even after collapse, the raw aggregate can exceed reality. applyEffectiveCaps
(at_risk_aggregate.go) imposes three final clamps. Order matters.
| # | Cap | Rule | Why |
|---|---|---|---|
| 1 | capped_at_supply | eff_at = min(raw_eff_at, supply) |
You can't put more value at risk than the token's entire supply. (Ties to CLAUDE.md's ">$1T = bug".) |
| 2 | extractable ≤ at_stake | eff_ex = min(eff_ex, eff_at) |
You can't extract more than is at stake. Runs after the supply cap, so it inherits that clamp. |
| 3 | capped_at_exit | eff_ex = min(eff_ex, exit_v2_total) |
All attackers share one exit pool — the market's real liquidity is the hard ceiling on what's drainable. |
capSlackTolerance = 1.001): cappedAtSupply = supply > 0 && rawAt > supply*1.001. The flag means
"the cap genuinely fired," not "the value happens to equal the cap" — the slack absorbs float rounding so you don't get
spurious cap alerts. (b) Step order is load-bearing: the at-stake invariant (step 2) runs before the exit cap
(step 3); when both bind, the lower one wins as the final ceiling. There's a test named for exactly this:
TestApplyEffectiveCaps_ExitCapBindsAfterAtStakeInvariant.
So you can place the pipeline end-to-end. EnrichAtRisk (at_risk.go) is the entry; it
walks each focus token through decomposeCells, then the calculations you just learned:
focus tokens // from Redis tokens:{chain} / metadata └─> decomposeCells // the SIX construction paths (next box) └─> multisig expansion // anchor cell + partial signer cells └─> computeExtractable // at_stake → extractable (exit-liquidity bound) └─> stamp value_pool_id └─> applyEffectiveCaps // drop-partial→max-in→sum-across→caps └─> summarizeCells // AtRiskSummary stamped on the node └─> mirror to AT_RISK edges
decomposeCells calls six emit functions — Path 1 (focus_token), Path 2 (oracle
via token), Path 2b (value-defining oracle), Path 3 (vault/pool/bridge via HOLDS), Path 3b (MetaMorpho
VAULT_ASSET-only vaults), and Path 4+4b (lending-market + per-oracle sub-cells). The file's own header comment still
says "the 5 cell-construction paths." Same refrain as L13's node_risk_score: the code is the truth; field docs and
even neighbouring comments drift. (at_risk_cells.go decomposeCells + the
-- Path N -- section markers.)
The aggregated numbers stamp an AtRiskSummary on the node, but the cells themselves are also mirrored as
graph edges (at_risk_edges.go, FORTA-2985): (entity)-[:AT_RISK]->(focus_token), tail =
failure point, head = victim token. So a token's own-risk is the sum of its INBOUND AT_RISK edges — exactly the
projection you met in L6. The writer also issues an unconditional supersede that deletes any inbound edge not in the current
run, keyed on the same $runAt — making the final edge set a pure function of the current cells, replay-safe.
(84,802 contract-failure cells vs 7,390 admin cells on a recent run, per the file header.)
aggregateEffective take the MAX of cells within one value_pool_id instead of summing them?at_stake_usd and extractable_usd on a cell?extractable_usd becomes…capped_at_supply computed against the RAW aggregate with a ×1.001 factor, not the clamped value?decomposeCells calls six emit functions. The right takeaway is…at_stake_usd vs extractable_usd — and why extractable is the harder, smaller, exit-liquidity-bounded number.value_pool_id as the collapse key that makes overlapping venues count their shared dollars once.Grounded in: pkg/risk/at_risk_aggregate.go (aggregateEffective drop-partial→max-in→sum-across, applyEffectiveCaps supply/at-stake/exit caps, capSlackTolerance=1.001), at_risk_value_pool.go (value_pool_id collapse, 451%-of-supply motivation), at_risk_extractable.go (computeExtractable, self-subtract, deployer-fallback guard), at_risk_cells.go (decomposeCells six paths), at_risk_types.go (Cell / AtRiskSummary), at_risk_edges.go (AT_RISK mirror, own-risk = inbound). Verify against source — the code is the truth.