risk_score runs some subset of the tiers
below; understanding which tier produced a verdict tells you how to
read it.
The methodology is implementation-faithful — formulas and constants
match the running production code byte-for-byte. If you find a
divergence between what an endpoint returns and what this page says,
the endpoint is the source of truth and this page has a bug. Please
file an issue.
The 4-tier engine
Aegis composes five independent risk signals into a single 0..100risk_score. Each tier is cheap-then-expensive: Tier 0 is one
indexed DB lookup; Tier 4 is graph BFS up to 5 hops deep. Higher
tiers are skipped early when a lower tier already produces a strong-
enough verdict.
- Tier 0 — Direct OFAC / UK OFSI / EU SDN lookup against
sdn_addresses. Trivial cost (one indexed query). Terminal: match pins the verdict at sanctioned/100. - Tier 0b — Live stablecoin contract probe. Calls
isBlacklistedon the issuer’s smart contract right now (USDT on TRON + ETH, USDC on ETH). Under 2 s per RPC. Terminal: hit pins the verdict at sanctioned/100 and writes through tolabel_claims. - Tier 1+2 — Multi-source consensus across roughly 44 OSINT
label sources. One SELECT on
v_any_address_label. Not terminal. - Tier 3 — 1-hop counterparty inheritance via the haircut formula. Medium cost (~50 ms — one graph query). Not terminal.
- Tier 4 — BFS-haircut graph traversal, 1..5 hops, configurable budget. Heavy (~500 ms — 5 s). Not terminal.
Tier 0 — Direct sanctions lookup
Source of truth:sdn_addresses table — OFAC SDN, UK OFSI,
EU CFSP financial-sanctions lists, refreshed daily.
Logic: lowercase-EVM / base58-TRON exact match on the input
address. Single B-tree index hit; latency under 5 ms.
Verdict: any match sets risk_level = "sanctioned",
risk_score = 100, and adds ofac-sdn to risk_categories.
Tiers 3 and 4 are skipped (already terminal). Tier 1+2 still runs
to populate label and categories_breakdown for UI display, but
its risk_level and risk_score are ignored once SDN has fired.
OFAC strict liability — there is no haircut or decay at Tier 0. A
direct hit is the same in 2026 as it was in 2024.
Tier 0b — Live stablecoin contract probe
Why it exists: the issuer can freeze an address on-chain by callingisBlackListed(address) on the stablecoin contract. Our
nightly ingest cron picks freezes up the next day — but a freeze in
the gap between two cron runs leaves the address unflagged in our
DB until the next pass. Tier 0b closes that gap by reading the
contract directly at request time.
Logic:
- TRON addresses → TronGrid
triggerconstantcontracton USDT. - EVM addresses → Etherscan V2
eth_callon USDT and USDC.
true sets
risk_level = "sanctioned", risk_score = 100, and adds
enforcement_action to risk_categories. Tiers 3 and 4 are
skipped. The hit is also written through to label_claims so the
next call on the same address resolves from DB without an RPC
round-trip.
Cache: 1h in-process per address+network to bound TronGrid and
Etherscan rate-limit pressure when many callers query the same hot
address.
Tier 1+2 — Multi-source consensus
Source of truth: thev_any_address_label view — a materialised
consensus across roughly 44 OSINT label sources, refreshed weekly via
the Saturday materialize cron.
Per source, Aegis stores in label_claims:
label— human-readable, e.g."Tornado Cash 0.1 ETH".category— e.g."mixer","scam","sanctioned".risk_level—none/low/medium/high/critical/sanctioned.risk_score— 0..100 as the source itself assigned it.risk_category_ids— canonical taxonomy ids (drivescategories[]in API).source_id— FK tolabel_sourceswithtrust_weight50..100.last_seen_at— when the source last re-confirmed.
label_sources.trust_weight,
applies the single-source severity guard (a single low-trust source
can’t push severity above 70), deduplicates by category, and emits
one consensus row per address.
Verdict: consensus risk_level and risk_score carry through to
the response when neither Tier 0 nor Tier 0b fired.
Single-source severity guard (P2.5)
A single source withtrust_weight under 95 cannot push any category
above severity 70, regardless of what that source claims. This
prevents a typo’d or biased forensic-community claim from pinning a
clean address to “critical”. Sources affected today:
- High-trust (95+, no cap): OFAC SDN, Chainalysis Sanctions API, Tether and Circle on-chain blacklists, UK OFSI, EU CFSP.
- Mid-trust (80..94, no cap): GraphSense tagpacks, tron-super-reps.
- Low-trust (under 80, capped at severity 70): CryptoScamDB, brianleect, forensic-community submissions.
Compliance carve-out (direct sanctions)
When any 1-hop neighbour has a sanctions-class slug (sanctioned,
ofac-sdn, enforcement_action) AND was claimed by a source with
trust_weight 80 or higher, the verdict is forced to
risk_level = "sanctioned", risk_score = 100, regardless of the
haircut and decay calculation. This matches Chainalysis Reactor’s
“Direct Sanctions Exposure” treatment — OFAC strict-liability
guidance treats direct (1-hop) exposure as automatically flaggable.
This carve-out is surfaced via direct_sanctions_applied: true and
direct_sanctions_slug (the slug string of the matched category) in
the response.
Tier 3 — 1-hop inheritance (haircut)
Trigger: Tier 1+2risk_score under 50 AND Tier 0 / Tier 0b did
not fire. When the verdict is already high, traversing the graph
one hop deeper is wasted compute.
Algorithm: fetch every confirmed transaction with the queried
address as from_address or to_address in the last 90 days
(capped at 100 per direction). For each labelled counterparty at
one hop, compute:
severity— 0..100 fromv_any_address_label, the base risk score for the counterparty.haircut_share—tx_value_to_counterparty / total_value_to_address, the proportion of dollars that touched the counterparty.hop_decay— 1.0 at hop 1, industry-standard0.7 ** (hops - 1)in general.direction_weight— 1.0 inbound, 0.4 outbound. Received-from a bad actor is 2.5x stronger signal than sent-to.time_decay—exp(-age_days / 365). A 1-year-old tx keeps 37% of the signal, 2-year-old 14%, 3-year-old 5%.
contribution over all matching
neighbours, then cap at 100. This per-category total is added to
the overall via Fuzzy-OR:
a is the Tier 1+2 score and b is the Tier 3 inherited
score. Fuzzy-OR is the natural extension of “either signal counts”
to continuous values — two 50s combine to 75, not to 100.
Trust gate for sanctions: the compliance carve-out also runs at
Tier 3 — if any 1-hop neighbour fires a sanctions slug from a
trust_weight 80+ source, the verdict is pinned at sanctioned/100
without running the haircut.
Tier 4 — BFS haircut
Trigger: Tier 3 overall score under 50. Tier 4 is the most expensive (~500 ms — 5 s); we run it only when the cheaper tiers haven’t already concluded. Algorithm: BFS from the queried address with a priority queue ordered by remaining contribution. Each frontier expansion:- Fetch outbound and inbound counterparties from the latest transactions (capped at 100 per hop per direction).
- Apply the spam filter (dust, micro-transfers, deny-listed mixer inputs) — see Noise discipline below.
- For each labelled counterparty found at hop N, compute
contribution = severity * haircut_share * (0.7 ** (N - 1)) * direction_weight * time_decaywith the same factor values as Tier 3, plus: - Cross-chain bridge crossings multiply by
bridge_decay = 0.85each. LayerZero / Stargate / Wormhole / Synapse / deBridge / Across are recognised by tx-hash to bridge-event lookup. - Stop conditions:
- Terminal nodes — once we hit a CEX deposit cluster, mixer, or labelled address we record the evidence and don’t traverse past it (mixers tagged category=mixer severity=95).
- Hop cap — default 2 hops via
/v2/check-address, up to 5 via the internal Tier 4 endpoint; further hops compound noise exponentially. - Budget cap — default 200 nodes visited; bigger budgets give more thorough Tier 4 but linearly slow the response.
- Noise floor — per-path contribution below 0.1 units is dropped (peel-chain noise).
overall = (1 - (1 - overall/100) * (1 - bfs_score/100)) * 100.
Score to risk_level mapping
risk_score75 or higher →risk_level = "critical".risk_score50..74 →risk_level = "high".risk_score25..49 →risk_level = "medium".risk_score1..24 →risk_level = "low".risk_score0 →risk_level = "none".
Time decay
The exponential factorexp(-age_days / tau), with tau = 365 days
by default:
- 30 days old → factor 0.92. Near-full signal.
- 90 days → 0.78. Still strong.
- 365 days → 0.37. Moderate decay.
- 730 days → 0.14. Weak signal.
- 1095 days → 0.05. Near-noise.
tau is env-tunable via EXPOSURE_TIME_DECAY_TAU_DAYS. Setting
tau = 0 disables time decay entirely (every tx weighs the same —
useful for historical investigations).
Hop decay
hop_decay = 0.7 per industry standard (Chainalysis Reactor, TRM
Labs, Elliptic Navigator all use 0.7 ± 0.05). Per-hop application:
- 1 (direct counterparty) → 1.00.
- 2 hops → 0.70.
- 3 hops → 0.49.
- 4 hops → 0.34.
- 5 hops → 0.24.
NOISE_FLOOR = 0.1 contribution unit and gets dropped.
Direction weighting (inbound vs outbound)
Inbound (you received from a labelled address) is 2.5x stronger signal than outbound (you sent to a labelled address):DIR_WEIGHT_INBOUND = 1.0.DIR_WEIGHT_OUTBOUND = 0.4.
- Received-from a sanctioned entity → direct benefit to you, strict-liability under OFAC guidance, no plausible deniability.
- Sent-to a sanctioned entity → intent question; could be a reported scam, a hack-victim sending stolen funds, a probe tx. Still actionable, but the signal is contextual rather than determinative.
Noise discipline
Tier 4 BFS runs spam filtering before adding a node to the frontier:- Dust threshold — drop tx with
value_usdbelow 0.01. - Micro-transfer denial — drop tx with
value_usdbelow 0.001% of source amount (peel-chain dilution stop). - Deny-listed mixer outputs — Tornado Cash, Wasabi, JoinMarket, Railgun, Sinbad. We tag the mixer itself (category=mixer severity=95) and do NOT traverse further out from it. FATF-cited empirical false-positive rate over 95% post-mixer.
- CEX deposit clusters — major exchange hot wallets are terminal. Beyond the CEX boundary the exchange controls commingled flow; further traversal is meaningless.
What you get back per tier
Every endpoint response shows you which tiers ran viatiers_run
and tiers_skipped:
Defaults summary
TIER3_TRIGGER_OVERALL = 50— Tier 3 skips when Tier 1+2 is 50 or higher.TIER4_TRIGGER_OVERALL = 50— Tier 4 skips when overall is 50 or higher.SANCTIONS_MIN_TRUST_WEIGHT = 80— min source trust to invoke the compliance carve-out.TIME_DECAY_TAU_DAYS = 365(envEXPOSURE_TIME_DECAY_TAU_DAYS) — exponential time decay tau.HOP_DECAY = 0.7(envEXPOSURE_HOP_DECAY) — per-hop multiplier.DIR_WEIGHT_INBOUND = 1.0(envEXPOSURE_DIR_INBOUND).DIR_WEIGHT_OUTBOUND = 0.4(envEXPOSURE_DIR_OUTBOUND).BRIDGE_DECAY_DEFAULT = 0.85— cross-chain bridge crossing.NOISE_FLOOR = 0.1(envEXPOSURE_NOISE_FLOOR) — per-path drop threshold.BFS_MAX_HOPS = 2(request paramtier4_max_hops, 1..5) — BFS depth.BFS_BUDGET = 200(request paramtier4_budget, 10..2000) — BFS node-visit cap.BFS_TX_FETCH_LIMIT = 100(envEXPOSURE_TX_FETCH_LIMIT) — per- frontier-pop tx fetch.EDGE_TX_CAP = 500— per-edge tx detail cap (for tooltip).
Versioning
This methodology page targetsschema_version 5 (Aegis 4-tier with
Tier 0b live stablecoin probe, shipped 2026-05-14). Earlier schema
versions (1..4) lack Tier 0b; if you’re reading a verdict with a
schema_version below 5, the Tier 0b row does not apply.
Material algorithm changes (new tier, formula rewrite, constant
defaults shifted by over 10%) get a major schema_version bump plus
a changelog entry. Minor changes (new env override, new source
added to Tier 1+2) ship as additive — same schema_version, noted
in the changelog.
References
- Chainalysis Reactor — Direct Sanctions Exposure model (industry reference for the 1-hop trust gate).
- TRM Labs Foundations whitepaper — haircut formula derivation.
- Möser-Narayanan 2019 (An Empirical Analysis of Linkability in the Monero Blockchain) — academic basis for the spam filter.
- Weber et al. 2019 — Elliptic GNN dataset, mixer-cluster taxonomy.
- FATF guidance on mixer false-positive rates (2023).
- UK courts, AA v Persons Unknown (2019) — FIFO model adoption in litigation (we don’t use FIFO; reference only).
services/aegis-api/src/check.py and
services/aegis-api/src/explorer.py in the internal repo.
