Lesson 20 · Oracle Attack Path · Deeper Track

One path, end to end: the oracle attack

How a focus token's price feed becomes cells — the gnarliest of the six construction paths. ~13 min.

Builds on: L19 · L6 Anchor: oracle manipulation New: the OTHER-asset attack New: NAV-redemption drain

L19 named the six cell-construction paths but treated them as a black box: "decompose the token, out come cells." Now we open one — the oracle path — all the way down. It's the richest, because a single target_role = "oracle" hides two completely different attacks, and because the most counter-intuitive idea in the whole engine lives here: an oracle attack usually doesn't steal the focus token at all.

Your anchor: oracle manipulation
You know the playbook — Mango Markets, the Compound/CRV scares, every "the oracle printed a bad price" post-mortem. An attacker doesn't break the token; they break the number that prices the token, then exploit a contract that trusts that number. risk-graph-indexer models exactly this, and splits it by who trusts the price: a third-party lending market, or the token's own redemption mechanism.

1 · Two attacks wearing one target_role

Both emit cells with target_role = "oracle", but they're different threat models with different code paths, different extractable math, and different value pools:

A · DeFi-borrow oracle (Phase 2.8.b)

emitOracleCellsViaToken

Oracle overprices T → a lending market that holds T as collateral lets you borrow other assets against the inflated value → you walk off with the other asset, not T.

Needs: a market that BOTH consumes the oracle AND holds T as collateral.

B · NAV / value-defining oracle (Path 2b, #88)

emitValueDefiningOracleCells

For RWA tokens (BUIDL, USTBL, USYC…) whose price is a NAV feed. Publish a bad NAV → redeem/mint T against the issuer's reserve at the wrong rate → drain the whole supply.

Consumer is the token itself — no third-party market needed.

The key insight: the OTHER-asset attack
In model A the attacker extracts a different asset. If WBTC's feed is inflated 2×, you don't steal WBTC — you deposit WBTC, borrow all the USDC the market will hand you against the fake valuation, and leave. So the extractable value is measured in USDC terms (the borrowable depth), not in WBTC supply. This is why oracle cells need their own extractable math — every other path drains the token itself; this one doesn't.

2 · How model A builds cells

emitOracleCellsViaToken walks every oracle attached to the focus token, then finds eligible markets — the ones that both consume that oracle and hold T as collateral:

for oracleID := range obs.OraclesPerToken[tok.Addr] {
    for _, marketAddr := range oracleConsumerMarkets[oracleID] {
        info := lending[tok.Addr][marketAddr]
        if info == nil { continue }              // market doesn't hold T → not eligible
        stake := math.Max(info.Avail, info.MaxBorrow)  // at_stake = borrowable surface
        ...
    }
    if len(eligible) == 0 { /* honest-zero sentinel */ }
}

For each eligible market it emits two cells — the same admin/contract pairing you saw in L19:

Each carries scope_market = <market>, the per-market oracle_ltv, and oracle_other_borrowable_usd — the inputs the extractable step needs.

Model A extractable: min(at_stake × LTV, other_borrowable)

// at_risk_extractable.go — the Phase 2.8.b branch
switch {
case scopeMarket == "":  ex, reason = 0, "oracle_no_consumer_market"
case ltv <= 0:        ex, reason = 0, "oracle_market_no_borrow_ltv"  // e-mode / collateral-only
default:
    ex = math.Min(at*ltv, otherBorrowable)   // borrow surface ∧ actual cash in market
}

Two real ceilings, multiplied and clamped: at_stake × LTV is the legitimate borrow the fake price unlocks; other_borrowable is the actual other-asset cash sitting in the market to walk away with. You can't borrow more than the LTV allows, and you can't take more than is actually there.

3 · How model B builds cells (NAV oracles)

emitValueDefiningOracleCells fires off a single node attribute — T.attrs.value_defining_oracle — stamped by the enrichment layer (navlink_refresher.go). The cell shape is simpler: the consumer is the token, so at_stake = T.SupplyUSD (the whole supply), scope_market = nil, contributing_venues = [token_addr], and observed = true unconditionally (the attack surface is live as long as the token has supply).

// extractable for a value-defining (NAV) cell
if exitTotal > 0 {
    ex = math.Min(at, exitTotal)
    reason = "nav_oracle_onchain_redemption_capped"
} else {
    ex = 0
    reason = "nav_oracle_offchain_redemption_not_modeled"   // BUIDL/WTGXX: bank-wire KYC redemption
}
Honest-zero, by design
Look at that else. For tokens like BUIDL or iBENJI, redemption is off-chain (KYC bank wire) — the on-chain metric genuinely can't measure it, so it reports $0 with an explicit reason rather than silently emitting nothing. Before #88 these tokens produced no oracle cell at all; the surface was invisible. The honest-zero sentinel in model A (at_stake = 0 when no eligible market exists) is the same philosophy: a visible $0 beats a silent gap. (at_risk_cells.go / at_risk_extractable.go.)

4 · Telling the two apart — and why they don't collide

Downstream code must know which model a cell came from. The single source of truth is isValueDefiningOracleCell (at_risk_cells.go), a pure predicate on cell shape:

func isValueDefiningOracleCell(c *Cell) bool {
    return c.TargetRole == TargetRoleOracle &&
        c.ScopeMarket == nil &&              // model A pins a market; model B doesn't
        len(c.ContributingVenues) == 1 &&
        c.ContributingVenues[0] != c.TargetID  // venue is the TOKEN, not the oracle (≠ sentinel)
}

And here's the subtle part from L19's dedup story: oracle cells use a 5-tuple key that omits target_id (at_risk_types.go CellKey doc). That's deliberate — it consolidates model A's per-(oracle,market) cells with model B's value-defining cells so the same oracle isn't counted twice. And in the rollup, NAV cells collapse to value_pool_id = "focus_token:<addr>" — the same pool as a focus-token admin compromise, because both ultimately drain the issuer's reserve. Max-within-pool (L19) then refuses to double-count those dollars.

Why a single mispriced WBTC feed isn't five disasters
Now L19's value-pool collapse clicks: the oracle feed, its proxy adapter, the aWBTC vault, and the market node all emit cells, all sharing one value_pool_id. Model A's oracle cells scope to the market; model B's NAV cells scope to the token. Both routes to the same dollars are collapsed by MAX. The oracle path is precisely where the overlap problem is worst — and precisely why the collapse machinery exists.

Check yourself

1. In the DeFi-borrow oracle attack (model A), what does the attacker actually walk away with?
2. For a model-A oracle cell, extractable_usd is…
3. What makes a market "eligible" for a model-A oracle cell?
4. A NAV oracle (model B) for an RWA token whose redemption is off-chain (bank-wire KYC) gets extractable_usd =
5. Why does at_stake = T.SupplyUSD (whole supply) for a value-defining NAV cell, but only the market's borrowable surface for a model-A cell?
6. Both models emit two cells per target. What distinguishes them?
7. isValueDefiningOracleCell keys on scope_market == nil and contributing_venues[0] != target_id. Why centralize this predicate?
8. A NAV cell collapses to value_pool_id = "focus_token:<addr>" — the same pool as a focus-token admin compromise. The effect in the rollup is…
↳ Ask your teacher
Try: "Where do oracle_ltv and other_borrowable_usd get populated?" · "What's in OracleObservability / active_oracles, and what does observed mean?" · "Show me Path 4b — the market→oracle sub-cells — vs Path 2." · "Why a 5-tuple cell key for oracles specifically?" · "How does navlink_refresher stamp value_defining_oracle?"

What you can now do

Grounded in: pkg/risk/at_risk_cells.go (emitOracleCellsViaToken Phase 2.8.b, emitValueDefiningOracleCells #88, isValueDefiningOracleCell, eligibility = consume∧hold, admin+contract cell pairing, honest-zero sentinel), at_risk_extractable.go (oracle branch: min(at×ltv, other_borrowable); NAV: min(at, exit_total) / nav_oracle_offchain_redemption_not_modeled), at_risk_types.go (oracle 5-tuple CellKey), at_risk_value_pool.go (NAV → focus_token pool). Verify against source — the code is the truth.