> ## Documentation Index
> Fetch the complete documentation index at: https://docs.aegis-kyt.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Methodology

> How Aegis computes risk — the 4-tier engine, scoring formula, and decay model.

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_level` — `none` / `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_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-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_decay` — `exp(-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`:

```json theme={null}
{
  "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](/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.
