Lesson 22 · Multisig Expansion · Deeper Track

Where the partial flag is born

One Safe-controlled cell fans out into an anchor + one cell per signer. ~11 min.

Builds on: L19 · L20 Anchor: Gnosis Safe k-of-N New: signer fan-out New: partial = anchor / threshold

L19's very first aggregation step was "drop partial cells — multisig signer derivatives." We took that on faith. This lesson is where those cells come from. The story is small and elegant: most DeFi admin power doesn't sit behind one key, it sits behind a Gnosis Safe — a k-of-N multisig. The engine takes a cell pinned to the Safe and fans it out to the individual signers, so you can see which human keys carry the risk — without ever double-counting the dollars.

Your anchor: the Safe behind the protocol
You know the pattern — a protocol's "admin" is rarely an EOA; it's a 3-of-5 or 4-of-7 Gnosis Safe holding the upgrade keys, the treasury, the pause switch. The real question for risk forensics isn't just "the Safe is dangerous," it's "whose signing keys, if phished, move the needle?" Multisig expansion answers that by attributing a fractional share of the Safe's power to each owner.

1 · The fan-out

expandMultisigAnchors (at_risk_multisig.go) scans every existing cell. When a cell's entity is a node of kind multisig, it becomes an anchor, and the engine spawns one signer cell per Safe owner:

ANCHOR cell · entity = Safe · at_stake = $900M · 3-of-5
┌──────┬──────┬──────┬──────┐
signer A
$300M · partial
signer B
$300M · partial
signer C
$300M · partial
signer D
$300M · partial
signer E
$300M · partial

Each signer cell is a copy of the anchor with three changes:

frac := c.AtStakeUSD / float64(threshold)   // k-of-N → divide by k (the THRESHOLD, not N)
&Cell{
    Entity:    owner,                       // the signer key, not the Safe
    AtStakeUSD: frac,                        // fractional share
    Partial:   true,                        // ← THE flag L19 drops
    AnchorOf:  strPtr(best.anchorID),        // back-pointer to the Safe
    ViaRole:   strPtr("signer"),
    NSigners:  intPtrFromInt(best.nOwners),  // N (for context)
    // role/caps/severity/scope_market/oracle_ltv all inherited from the anchor
}
Why divide by threshold (k), not owner count (N)?
A k-of-N Safe needs k signers to collude to act. So each signer's marginal share of the attack is at_stake / k — and exactly k of them recombine to the full anchor value (k × at_stake/k = at_stake). Dividing by N would under-credit the keys: in a 3-of-5, any 3 keys suffice, so each is worth a third, not a fifth. If safe_threshold is missing or nonsensical (≤ 0 or > N), the code falls back to threshold = N. (readSafeThreshold / the frac calc in at_risk_multisig.go.)

2 · The point of partial: forensics without double-counting

Here's the resolution of L19's loose end. Those five signer cells share the anchor's value_pool_id (they're keyed identically to the anchor, just with the owner swapped in — see dedupCellKeyForSigner). So in the rollup:

If partials were summedWhat actually happens (L19 drop-partial)
cells in the Safe's poolanchor $900M + 5 × $300Monly the anchor $900M survives
pool total (MAX-within)$2.4B — inflated 2.67×$900M — correct
So why spawn them at all, if aggregation drops them?
Because the token total and the per-key forensic view are different products. The aggregate must not double-count, so partials are dropped. But each signer cell is still mirrored to an AT_RISK edge (L19) — so a query like "show me every focus token this one signing key endangers, and by how much" works. partial=true + anchor_of + via_role="signer" are exactly the labels that let a reader tell a derived signer share from a real direct authority. The flag isn't noise to discard — it's the provenance that makes the cell safe to drop in one view and useful in another.

3 · The override: when a signer is also a direct admin

One real edge case. A Safe owner might also hold a direct admin cell on the same target (they're personally a key-holder and a Safe signer). The engine doesn't blindly add a second cell — the signer key already exists in the cell map. Instead:

if best.frac > existing.AtStakeUSD {     // only if the signer share is LARGER
    existing.AtStakeUSD = best.frac
    existing.Partial   = true             // downgrade the direct cell to a derivative
    existing.AnchorOf  = strPtr(best.anchorID)
    existing.ViaRole   = strPtr("signer")
    overridden++
}

The direct authority stays visible, but gets correctly relabeled as a signer derivative when the Safe share dominates. Otherwise (direct authority is the bigger, more dominant signal) it's left untouched. And when a signer sits on multiple anchors for the same key, the code keeps the largest fractional share — the worst case, the same max-discipline as everywhere else.

4 · Oracle anchors expand too (the L20 tie-in)

Remember L20's per-(oracle, market) oracle cells with scope_market, oracle_ltv, and oracle_other_borrowable_usd? If such a cell's entity is a Safe, its signer cells inherit all three, so extractable recomputes against the same consuming market (min(at_stake × LTV, other_borrowable)) — just with the signer's fractional at_stake. And because oracle cells use the 5-tuple key (L20), the signer key is built the same way, preserving the value-defining-ness. The two subsystems compose cleanly:

// the Safe at 0x21f73D…73CA aggregates ~$4.5B at_stake across 7 RWAs;
// post-#89, its 9 signers each get a partial-authority share —
// today the full at_stake attributes to the Safe as one 'admin' entity.
at_risk is now exhaustively covered
Six cell-construction paths (L19/L20), the extractable math + its exit-liquidity ceiling (L20/L21), multisig fan-out (here), the value-pool collapse + max-in/sum-across + caps rollup (L19), exposure propagation (L16), and the per-field math (L13). Every number in the flagship AT_RISK output now has a derivation you can trace to source.

Check yourself

1. A cell's entity is a 4-of-7 Gnosis Safe with at_stake = $700M. Each signer cell gets at_stake =
2. Why divide by the threshold k rather than the owner count N?
3. Signer cells carry partial = true. What happens to them in L19's aggregation?
4. If signer cells are dropped from the aggregate, why spawn them at all?
5. A Safe owner ALSO has a direct admin cell on the same target. When does the engine downgrade that direct cell to partial?
6. anchor_of and via_role = "signer" on a signer cell are there to…
7. When a Safe controls an L20-style oracle cell (with scope_market + oracle_ltv), the spawned signer cells…
8. A signer appears on two different anchors for the same cell key. The engine keeps…
↳ Ask your teacher
Try: "Show me multisigOwners and where safe_threshold gets stamped." · "Walk dedupCellKeyForSigner vs the anchor's key, including the 5-tuple oracle case." · "What's protocol_admin_seed.go's transitive fan-out (#89)?" · "Could a nested Safe-of-Safes recurse here? (Hint: anchors are snapshotted first.)"

What you can now do

Grounded in: pkg/risk/at_risk_multisig.go (expandMultisigAnchors fan-out, frac = at_stake/threshold, partial/anchor_of/via_role/n_signers stamping, override-when-larger, largest-share-wins, oracle-anchor inheritance, readSafeThreshold fallback to N, dedupCellKeyForSigner 5-tuple-aware key). Verify against source — the code is the truth.