Lesson 06 · The Risk Engine

Turning the graph into risk numbers

Why we built a graph at all: "if this fails, which tokens lose value — and how much?" ~12 min.

Builds on: Lesson 5 Anchor: DeFi failure modes New: AT_RISK cells New: exposure BFS

Everything so far built and maintained a graph. The risk engine is the reason it exists. It reads the structural graph and computes the question Forta actually cares about: if some failure point — an admin key, an oracle, a vault — breaks, which focus tokens lose value, and by how much? That's not a lookup; it's a graph traversal. This lesson is the concepts and where they live (we keep the heavy math light, per your mission).

You already think this way
As a DeFi person you instinctively reason: "if Chainlink mis-prints this price, these lending markets get drained"; "if this multisig is compromised, it can rug these vaults"; "if rsETH's single required DVN fails, cross-chain attestation breaks" (the real Kelp exploit shape). The risk engine formalises that instinct as a computation over the graph's edges. Same reasoning — made quantitative and automatic.

1 · Focus tokens: what risk is measured for

Risk is always relative to focus tokens — the assets we care about protecting (read from graph metadata: tokens, risk_metrics_focus_tokens, token_runs). Every risk number ultimately answers "how much of a focus token's value is exposed to some failure."

Source: pkg/risk/at_risk.gofocusMetadataKeys, extractFocusTokensFromMetadata.

2 · The AT_RISK model: failure source → victim token

The engine's flagship output is the AT_RISK edge you met in Lesson 2 as the lone ANALYTICAL edge type. Recall its shape:

FAILURE SOURCE
admin key · multisig · oracle · vault · market contract
— AT_RISK →
VICTIM FOCUS TOKEN
the asset that loses value if it fails

One inbound AT_RISK edge is stamped per attribution, so: a token's own-risk = the sum over its inbound AT_RISK edges. Each edge carries the value at risk and why (target role, failure source, scope market…).

The unit of computation: a "cell"
Internally the engine does cell decomposition (at_risk_cells.go, a port of the Python at_risk.py::_decompose_cells). A cell is one attribution: (entity, target token, failure-source) → value. The engine walks each focus token through 5 cell-construction paths (e.g. admin control, oracle dependency, vault custody, lending collateral, exit-liquidity) and emits a cell per (entity, target, failure-source). Cells are then mirrored as the inbound AT_RISK edges. Want to change what counts as "at risk"? You're editing a cell path.

Source: pkg/risk/at_risk_cells.go (cell decomposition), at_risk_edges.go (cells → AT_RISK edges, FORTA-2985), at_risk_scheduler.go (orchestration). Note userHolderSubtypes: a hack of a Bitfinex hot wallet is "a Bitfinex problem," not the token's at-risk surface — so user/custody holders are excluded as venues.

3 · Exposure BFS: how value flows through edges

How does the engine know a failure "reaches" a token several hops away? Breadth-first search over a specific subset of edges. From a starting node it propagates a weight outward, edge by edge, until the contribution falls below a threshold or it hits the depth cap:

// pkg/risk/bfs.go + exposure.go
const MaxBFSDepth = 6      // don't traverse deeper than 6 hops
const minDelta   = 1e-6   // drop sub-threshold contributions (keeps the frontier bounded)

// Only these edge types carry value/exposure — note: structural, not "control" edges:
var RiskEdges = []types.EdgeType{
    EdgeHolds, EdgePoolAsset, EdgeVaultAsset,
    EdgeLendingCollateral, EdgeWrapUnwrap, EdgeVaultAllocation,
}

The two bounds — MaxBFSDepth=6 and minDelta=1e-6 — are what keep this tractable on a huge graph. They're the BFS cousin of Lesson 2's "bound depth, no full scans" rule. Notice RiskEdges deliberately uses the value-bearing CONTAINS/COLLATERALISED_BY families — not every edge propagates value.

FOCUS TOKEN
USDC
← HOLDS
hop 1
a vault
← VAULT_ALLOCATION
hop 2
a market
← LENDING_COLLATERAL
hop 3…
(until depth 6 or < minDelta)

4 · Incremental: it recomputes only what changed

Critically, the engine is not a nightly batch job. It's an Engine driven by the same graph deltas the indexer produces (Lesson 4's BuildDelta). When edges change, it recomputes metrics only for the affected nodes:

// pkg/risk/exposure.go
func (e *Engine) ProcessDelta(ctx context.Context, delta *types.GraphDelta) error {
    affected := AffectedNodes(delta)   // which nodes' risk could have moved?
    if len(affected) == 0 { return nil }
    // recompute exposure / at_risk for just those, write *_json back to Memgraph
}

This mirrors the whole system's philosophy from Lesson 1: push-based and incremental, not batch. A single new edge ripples out to just the risk numbers it could affect.

5 · The family of metrics (orientation only)

AT_RISK + exposure are the headline. Several siblings live in pkg/risk/, each writing a *_json property back onto nodes. You don't need the math today — just know they exist and where to look:

MetricQuestion it answersFile
ExposureHow much focus-token value flows through this node?bfs.go, exposure.go
AT_RISKWhich failure sources threaten which tokens, and for how much?at_risk_*.go
Node risk scorePer-node governance & longevity risk (EOAs = 0; they are the key).node_risk_score.go
Admin riskHow much value an admin/governance role controls.admin_risk.go
Vault concentrationHHI — is a vault dangerously concentrated in one asset?vault_concentration.go

Source: pkg/risk/ headers. Schedulers (token_scheduler.go, at_risk_scheduler.go) run these incrementally + periodically. The README's risk-engine box lists the full set (centrality, leverage, impact/cascade/RCS).

Contributor reality check
This is the codebase's most active and most subtle area — recent tickets (FORTA-2985, FORTA-3000/3002) are nearly all at_risk work, and it has strict parity requirements against the legacy Python (PARITY_NOTE.md, the Graph-Parity dashboard). A change here that's "obviously right" can silently diverge from Python. Don't touch a cell path without reading its parity test first. This is exactly why your mission deferred the heavy risk-math — respect that boundary until you've shipped simpler changes.

Check yourself

1. A token's "own risk" is computed as…
2. What is an AT_RISK "cell"?
3. Why does the exposure BFS cap at MaxBFSDepth=6 and drop contributions below minDelta?
4. The risk engine recomputes metrics…
5. Why is AT_RISK classified as an ANALYTICAL edge (Lesson 2), not architectural?
6. You're asked to change what counts as "at risk" for oracle failures. Where do you start — and what must you check first?
↳ Ask your teacher
Try: "Walk me through the 5 cell-construction paths," · "Show me a real AT_RISK edge's properties," · "How does the exposure BFS weight propagate, concretely?" · "What does a parity test look like and why does it matter?"

What you can now do

🏁 The full system, end to end
block-ingest → indexer (decode·filter·coalesce·atomic write) → enrichment (classify·discover) → risk engine (exposure BFS · AT_RISK cells, incremental on deltas). You've now seen every layer, from an RPC block to a quantified risk number. That's the whole machine.

Grounded in: pkg/risk/at_risk.go, at_risk_cells.go, at_risk_edges.go, bfs.go, exposure.go, node_risk_score.go, vault_concentration.go, PARITY_NOTE.md; pkg/types/schema.go (EdgeAtRisk). Heavy math intentionally deferred per the mission. Verify against source — the code is the truth.