Lesson 19 · at_risk Aggregation · Deeper Track

From cells to a number

How thousands of attack cells collapse into one honest at-risk dollar figure. ~12 min.

Builds on: L6 · L13 · L16 Anchor: overlapping DeFi venues New: value_pool_id collapse New: max-in / sum-across + caps

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.

Your anchor: the same dollars, reachable five ways
Take WBTC sitting in Aave V3. An attacker who wants those exact dollars can come through the aWBTC receipt vault, the lending-market node, the Chainlink price feed, or its proxy adapter — four or five distinct cells, every one of them draining the same pool of value. If the engine counted each separately, a single compromised market would look like multiple, overlapping catastrophes. The whole aggregation pipeline exists to count shared dollars once and independent dollars fully. (Real motivation, verbatim from 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.")

1 · Two numbers live on every cell

Before aggregating, know what you're aggregating. Each Cell (pkg/risk/at_risk_types.go) carries two dollar values, and they answer different questions:

FieldQuestion it answersHow 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"
    }
}
The deployer-fallback guard — an $8B lesson
One special case is worth its own line. A cell whose only "admin" is the contract deployer EOA, with no real capabilities, gets 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.")

2 · The collapse key: value_pool_id

The 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_ideven 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."

3 · The aggregation: max-within, sum-across

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:

  1. Drop 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.)
  2. MAX within a pool. Several cells hit the same dollars — the worst single path is the honest credit. Adding them would double-count the shared value. This is the 451% fix.
  3. SUM across pools. Different pools are independent attack classes — losing the Aave position and the Compound position are separate losses, so the rollup is additive.

Worked example — WBTC (illustrative numbers)

pool: aave-wbtc
aWBTC vault cell   $1.0B
lending-market cell   $1.0B
oracle feed cell   $1.0B
→ MAX = $1.0B
contributes$1.0B
+
pool: compound-wbtc
market cell   $0.4B
oracle cell   $0.4B
→ MAX = $0.4B
contributes$0.4B
=
token total
naive sum of cells
$4.4B (overcounts!)
effective at-stake$1.4B

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.

4 · The caps: three hard ceilings

Even after collapse, the raw aggregate can exceed reality. applyEffectiveCaps (at_risk_aggregate.go) imposes three final clamps. Order matters.

#CapRuleWhy
1capped_at_supplyeff_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".)
2extractable ≤ at_stakeeff_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.
3capped_at_exiteff_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.
Two subtleties the code is deliberate about
(a) The cap flags trigger on the RAW aggregate with 0.1% slack (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.

5 · Where this sits in the whole at_risk run

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
Verify against source: the doc says "5 paths," the code has 6
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 output: an AT_RISK edge per cell

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.)

Check yourself

1. Why does aggregateEffective take the MAX of cells within one value_pool_id instead of summing them?
2. And why does it SUM across different pools?
3. What's the difference between at_stake_usd and extractable_usd on a cell?
4. A cell's only admin is the contract's deployer EOA with no real capabilities. Its extractable_usd becomes…
5. Raw effective at-stake = $1.4B; token supply = $5B; exit liquidity = $0.3B. What's the effective extractable?
6. Why is capped_at_supply computed against the RAW aggregate with a ×1.001 factor, not the clamped value?
7. The file comment says "5 cell-construction paths"; decomposeCells calls six emit functions. The right takeaway is…
8. A token's "own-risk" in the graph is computed as…
↳ Ask your teacher
Try: "Walk me through one Path-4b oracle sub-cell from emit to edge." · "How exactly is value_pool_id derived in deriveValuePoolID?" · "What does multisig expansion do to the partial flag?" · "Show me how exit_v2_total_usd is built (computeExitLiquidityV2)." · "Why KeyedSum / Neumaier instead of a plain += ?"

What you can now do

at_risk is now end-to-end transparent
You can trace one token from focus-discovery → six construction paths → cells (with at_stake & extractable) → value-pool collapse → max-in/sum-across → three caps → summary → AT_RISK edges. With L6 (cells), L13 (field math), L16 (exposure), and now L19 (the rollup), there is no part of the at_risk number you can't account for.

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.