How 1F assigns a 0–100 risk score to an Ethereum address. Implementation: RiskScorer.cs:153 ComputeScore.
| Input | Source | Cardinality |
|---|---|---|
| Sanctions registry | OFAC SDN crypto-assets list, daily refresh (SanctionsRefreshService) | ~600 addresses |
| KnownAddresses | Hand-curated + scraped (CoinGecko, DefiLlama, Uniswap Sybil) | ~19,000 labels |
| BridgeRegistry | 22 bridge contracts across 6 chains | per-chain |
| CEX list | Curated subset of KnownAddresses (category == "cex") | ~56 |
| Mixer list | Tornado Cash deposit/withdrawal pools, Sinbad, ChipMixer remnants | ~15 |
| Graph topology | GraphIndex (static) + HotGraph (live ingestion) | global |
The total score is the sum of four components, capped at 100:
total = mixerExposure(0–40) + sanctionedProximity(0–30) + patternFlags(0–20) + addressAge(0–10)
mixerRatio = mixer-touching txs / total txs
mixerExposure = min(40, mixerRatio * 200) # 20% mixer-tx == 40 points
Counts inbound *and* outbound. A direct deposit *or* withdrawal both count. We do not weigh deposit vs withdrawal differently — both are signal.
score = 100, bypasses the formula.We stop at 2 hops on purpose: every popular CEX hot wallet is N-hops from *something* sanctioned, so deeper traversal becomes pure noise.
Heuristic detectors:
Each flag adds 4–8 points; cap at 20.
Younger-than-7-days addresses with non-trivial value activity score the full 10. The premise: brand-new, freshly funded addresses interacting with high-value flows are statistically over-represented in fraud cases. This component decays to 0 by ~90 days.
| Case | Score | Why |
|---|---|---|
| Address on OFAC SDN list | 100, CRITICAL | Sanctions are binary |
MEV searcher (category == "mev") | 15 | Active but legitimate; flagged for visibility, not blocked |
| Bridge contract | 10 | Bridges are not actors |
| CEX deposit address | 5 | Custodial, KYC'd at withdrawal |
| Address not in graph | 15, LOW | Limited history → mild caution |
score >= 80 → CRITICAL
score >= 60 → HIGH
score >= 40 → MEDIUM
score >= 20 → LOW
score < 20 → MINIMAL
When a caller asks for the *exposure profile* of an address (used by /api/forensics/cex-exposure, /api/forensics/mixer-correlate), we don't compute a single score — we return per-counterparty-class buckets:
{
sanctionedExposure: { count, totalValueWei },
mixerExposure: { count, totalValueWei, byMixer: {...} },
cexExposure: { count, totalValueWei, byExchange: {...} },
bridgeExposure: { count, totalValueWei, byBridge: {...} },
}
totalValueWei is summed from edge-level realValueWei (same field used for the heatmap and PageRank tie-breaks). For ERC-20 transfers this is the wei-equivalent at our normalization (see BlockIngestionService.NormalizeTokenValue); pre-receipts ingestion this can be off for fee-on-transfer tokens.
Transfer log events. Fee-on-transfer and tax tokens will mis-report realValueWei. Mixer/CEX exposure ratios are computed off edge counts (which are correct), so this distortion is bounded.0xA and 0xB are clustered as the same entity by AddressCluster, scoring still treats them independently. The cluster surfaces in /api/forensics/cex-exposure results but does not change the per-address risk number.
POST /api/forensics/screen # bulk batch screen, ≤1000 addrs
GET /api/forensics/cex-exposure/{address}
GET /api/forensics/mixer-correlate/{address}
For the underlying score per address, callers can hit the existing /api/risk-score/{address} endpoint which returns the full breakdown (component scores, flagged reasons).