Skip to main content
This page documents Aegis’s risk-scoring algorithm end-to-end. Every endpoint that returns a 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..100 risk_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 isBlacklisted on 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 to label_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 calling isBlackListed(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 triggerconstantcontract on USDT.
  • EVM addresses → Etherscan V2 eth_call on USDT and USDC.
Each call is bounded by a 2-second per-RPC budget; failure falls through to Tier 1+2 without blocking the verdict. Verdict: any contract returning 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: the v_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_levelnone / low / medium / high / critical / sanctioned.
  • risk_score — 0..100 as the source itself assigned it.
  • risk_category_ids — canonical taxonomy ids (drives categories[] in API).
  • source_id — FK to label_sources with trust_weight 50..100.
  • last_seen_at — when the source last re-confirmed.
The materialize step weighs claims by 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 with trust_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.
Two or more sources agreeing on a category bypass the cap (the cap is single-source, not category-level).

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+2 risk_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:
contribution = severity * haircut_share * hop_decay * direction_weight * time_decay
Each factor:
  • severity — 0..100 from v_any_address_label, the base risk score for the counterparty.
  • haircut_sharetx_value_to_counterparty / total_value_to_address, the proportion of dollars that touched the counterparty.
  • hop_decay — 1.0 at hop 1, industry-standard 0.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_decayexp(-age_days / 365). A 1-year-old tx keeps 37% of the signal, 2-year-old 14%, 3-year-old 5%.
Aggregation across categories: for each category that any neighbour carried, sum its contribution over all matching neighbours, then cap at 100. This per-category total is added to the overall via Fuzzy-OR:
overall = (1 - (1 - a/100) * (1 - b/100)) * 100
where 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:
  1. Fetch outbound and inbound counterparties from the latest transactions (capped at 100 per hop per direction).
  2. Apply the spam filter (dust, micro-transfers, deny-listed mixer inputs) — see Noise discipline below.
  3. For each labelled counterparty found at hop N, compute contribution = severity * haircut_share * (0.7 ** (N - 1)) * direction_weight * time_decay with the same factor values as Tier 3, plus:
  4. Cross-chain bridge crossings multiply by bridge_decay = 0.85 each. LayerZero / Stargate / Wormhole / Synapse / deBridge / Across are recognised by tx-hash to bridge-event lookup.
  5. 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).
Aggregation: same Fuzzy-OR as Tier 3. The Tier 4 result combines with the running overall: overall = (1 - (1 - overall/100) * (1 - bfs_score/100)) * 100.

Score to risk_level mapping

  • risk_score 75 or higher → risk_level = "critical".
  • risk_score 50..74 → risk_level = "high".
  • risk_score 25..49 → risk_level = "medium".
  • risk_score 1..24 → risk_level = "low".
  • risk_score 0 → risk_level = "none".
The level is bumped UP after each tier — Tier 0 / 0b can pin to sanctioned, Tier 3 / 4 can promote medium to high — but no tier ever DOWN-grades the level set by an earlier tier.

Time decay

The exponential factor exp(-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.
At 5 hops the inherited signal is below a quarter of the source — which combined with the time decay typically drops below the 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.
The asymmetry follows from the actor model:
  • 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.
Spam / dust attacks flood inbound but not outbound — but our spam filter (next section) catches those before the direction weighting applies.

Noise discipline

Tier 4 BFS runs spam filtering before adding a node to the frontier:
  • Dust threshold — drop tx with value_usd below 0.01.
  • Micro-transfer denial — drop tx with value_usd below 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 via tiers_run and tiers_skipped:
{
  "risk_level": "sanctioned",
  "risk_score": 100,
  "tier0b_applied": true,
  "tier3_applied": false,
  "tier4_applied": false,
  "tiers_run": ["sdn", "tier0b_stablecoin", "consensus"],
  "tiers_skipped": [
    {"name": "one_hop", "reason": "tier0b_stablecoin_terminal"},
    {"name": "bfs",     "reason": "tier0b_stablecoin_terminal"}
  ]
}
This is the read-out you want for compliance reporting — auditors want to see why a verdict was reached, not just what it was.

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 (env EXPOSURE_TIME_DECAY_TAU_DAYS) — exponential time decay tau.
  • HOP_DECAY = 0.7 (env EXPOSURE_HOP_DECAY) — per-hop multiplier.
  • DIR_WEIGHT_INBOUND = 1.0 (env EXPOSURE_DIR_INBOUND).
  • DIR_WEIGHT_OUTBOUND = 0.4 (env EXPOSURE_DIR_OUTBOUND).
  • BRIDGE_DECAY_DEFAULT = 0.85 — cross-chain bridge crossing.
  • NOISE_FLOOR = 0.1 (env EXPOSURE_NOISE_FLOOR) — per-path drop threshold.
  • BFS_MAX_HOPS = 2 (request param tier4_max_hops, 1..5) — BFS depth.
  • BFS_BUDGET = 200 (request param tier4_budget, 10..2000) — BFS node-visit cap.
  • BFS_TX_FETCH_LIMIT = 100 (env EXPOSURE_TX_FETCH_LIMIT) — per- frontier-pop tx fetch.
  • EDGE_TX_CAP = 500 — per-edge tx detail cap (for tooltip).

Versioning

This methodology page targets schema_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).
For the formal scoring formula source code, see services/aegis-api/src/check.py and services/aegis-api/src/explorer.py in the internal repo.