Why we built a graph at all: "if this fails, which tokens lose value — and how much?" ~12 min.
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).
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.go — focusMetadataKeys, extractFocusTokensFromMetadata.
The engine's flagship output is the AT_RISK edge you met in
Lesson 2 as the lone ANALYTICAL edge type. Recall its shape:
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…).
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.
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.
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.
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:
| Metric | Question it answers | File |
|---|---|---|
| Exposure | How much focus-token value flows through this node? | bfs.go, exposure.go |
| AT_RISK | Which failure sources threaten which tokens, and for how much? | at_risk_*.go |
| Node risk score | Per-node governance & longevity risk (EOAs = 0; they are the key). | node_risk_score.go |
| Admin risk | How much value an admin/governance role controls. | admin_risk.go |
| Vault concentration | HHI — 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).
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.MaxBFSDepth=6 and drop contributions below minDelta?AT_RISK classified as an ANALYTICAL edge (Lesson 2), not architectural?(entity, target, failure-source)→value attribution.RiskEdges, bounded by depth 6 + minDelta.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.