Attribute an unknown wallet address to a likely VASP (Virtual Asset Service Provider / exchange) with a calibrated confidence score and a human-readable evidence chain. Built for SIH problem statement 26182 (law-enforcement crypto tracing).
The differentiator is explainability. There is no LLM in the core attribution path — attribution is heuristics + a gradient-boosted classifier (calibrated), and every score traces to concrete on-chain evidence. The system can also explicitly say "insufficient evidence" rather than force-attribute every wallet.
Status: all six phases complete.
.\run.ps1 # setup if needed, start API + frontend (SQLite, no Docker), open the browser
.\run.ps1 -Stop # stop both serversSee RUNBOOK.md for the 90-second judge walkthrough (unknown wallet → graph → attribution → why → risk → case → PDF).
Every wallet/case/report/live-trace endpoint requires an authenticated officer —
there is no unauthenticated access to investigation data. run.ps1 seeds a demo
account automatically:
username: i4c.analyst
password: Chain@2026
- Login issues a JWT (
POST /auth/login); the frontend attaches it as a Bearer token and redirects to/loginif it's missing or a request comes back 401. - Every wallet query, report, disclosure request, and live-trace is written to an
audit log (
AuditLog: officer, action, target, timestamp) — who looked up what, and when, which real investigative use requires. - The SAHYOG disclosure request auto-fills the signed-in officer's name and badge number instead of a placeholder.
- Passwords are hashed with salted PBKDF2 (stdlib, no native dependency). The
default
JWT_SECRETis a demo fixture — override it via the environment for any non-demo deployment. - Add more officers via
app/auth/seed_officer.pyas a pattern, or the ORM directly; there's no self-signup UI (an investigative tool doesn't want one).
The pipeline consumes the standard Etherscan txlist schema, so traversal +
attribution run on real data unchanged — demonstrated offline, without depending
on live hot-wallet traversal:
Live "trace any wallet": paste any real Ethereum address in the UI — if it
isn't cached, POST /wallets/{addr}/live-trace fetches its transactions live from
Blockscout (keyless), imports them, and the same pipeline attributes it. Results
are cached, so a wallet is fetched once and then works offline. The six seeded
cases remain the offline demo spine.
Stablecoin tracing: the live path also imports ERC-20 transfers (USDT/USDC/…), so laundering that moves in stablecoins is traced too — the graph shows the asset per edge.
SAHYOG disclosure request: GET /wallets/{addr}/disclosure-request turns an
attribution into a ready lawful information-request draft for the attributed
VASP, in the format an authorized officer files via India's SAHYOG Portal (I4C,
MHA) — subject wallet, attributed VASP + confidence, on-chain evidence, risk, and
the KYC/beneficial-owner/freeze items requested. Clearly marked a draft with no
legal effect until filed.
Multi-chain (Tron/USDT-TRC20): the same provider/import/traversal/attribution
pipeline also ingests Tron (app/providers/tron.py, keyless via TronGrid) —
the chain most used for stablecoin transfers involving Indian VDA fraud. Search a
Tron address (T…, case-sensitive/base58) the same way as an Ethereum one; a real
committed snapshot (tron_kraken_depositor_TVYuaXdh.json) resolves to Kraken.
Wallets carry a chain column so Ethereum and Tron data coexist without collision.
Exchange clustering: GET /vasps/{name}/cluster returns a VASP's known
deposit-sweep cluster (rebuilt after every import by
app.attribution.cluster_builder) — the set of deposit addresses already observed
consolidating into that VASP's hot wallet, one cluster per chain.
Laundering typology + high-risk alerting: the risk score now also returns
typology_tags (e.g. LAYERING — funds pass through a mixer/sanctioned
intermediary before reaching a VASP) and a flagged / flag_reason pair so a
dashboard or an alerting layer can pick out high-risk wallets automatically
(app/attribution/typology.py).
make import-realchain # replay the bundled Etherscan-schema sample
# or snapshot a real low-degree wallet once (needs ETHERSCAN_API_KEY), then replay offline:
python -m app.ingest.chain_import --address 0x... --save data/realchain/case.json
python -m app.ingest.chain_import --file data/realchain/case.json
# Tron is keyless — auto-detected from a T... address, or pass --chain tron explicitly:
python -m app.ingest.chain_import --address T... --chain tron --save data/realchain/case.jsonOne-click investigator PDFs, generated server-side with reportlab (pure Python, offline — no headless browser):
GET /wallets/{addr}/report— verdict, candidates, full evidence chain with per-signal contributions, independent risk section, and a drawn transaction-graph snapshot.GET /cases/{id}/report— case summary, investigator, and all findings/notes.
The frontend exposes a ↓ report (PDF) button on the wallet and case pages.
Next.js 14 (App Router) + TypeScript + Tailwind + React Flow. Dark, dense, terminal-adjacent, data-first. Verified end-to-end against the live API.
/— wallet search + offline demo wallets/wallets/{address}— attribution panel (probability bars + expandable evidence with per-signal TreeSHAP contributions), independent risk panel, React Flow transaction graph (root/VASP/mixer nodes colour-coded, click a node to trace it), and attach-to-case/cases,/cases/{id}— create cases, attach wallets, add findings/notes
The backend gained a small cases API (/cases…) and CORS. Run:
cd frontend && npm install && cp .env.example .env.local && npm run devBounded multi-hop traversal via a single WITH RECURSIVE CTE (verified to render
on both Postgres and SQLite), cycle-free through a path string carried in the
recursion. Every traversal is bounded and reports exactly why it stopped.
SqlGraphRepository(behind theGraphRepositoryinterface) —traverse()andshortest_paths().TraversalBounds:max_hops,min_value_wei,since/until,max_nodes, anddirection(FORWARD / REVERSE / BOTH — REVERSE powers deposit-sweep).PruneInforeportspruned+ concretereasons(MAX_HOPS,MAX_NODES,MIN_VALUE,TIME_WINDOW) — each detected with a bounded EXISTS check so the flag is honest, never a guess.
Endpoints:
GET /wallets/{addr}/graph?depth=&min_value=&since=&until=&direction=&max_nodes=
-> { root, nodes[], edges[], prune }
GET /wallets/{addr}/paths-to-labeled?depth=&direction=&limit_per_target=
-> [ { target, label_name, vasp_name, shortest_hops, paths[][] } ]
Four deterministic, fully offline scenarios — these are the demo. Each
exercises one attribution outcome. make seed-demo loads them (and the bundled
real labels) into the DB with zero network access; re-running is idempotent.
| Scenario | Unknown wallet resolves to | Expected outcome |
|---|---|---|
ransomware_to_exchange |
Binance (deposit-sweep) | CLEAN (~90%+) |
peel_chain |
Kraken (5-hop peel) | MODERATE (~70%) |
dead_end |
— (no VASP linkage) | INSUFFICIENT ("I don't know") |
two_exchanges |
Binance vs Coinbase | AMBIGUOUS (split) |
The generator (app/synthetic/) is deterministic (stable addresses/hashes/
timestamps) and doubles as the Phase 4 classifier's labeled training data: every
scenario declares its unknown_wallet, ground_truth VASP (or None), and the
expected outcome. build_fixture_provider() turns the same scenarios into an
offline ChainProvider for tests and later phases.
- Backend: Python 3.11, FastAPI, Pydantic v2, SQLAlchemy 2.0 (async)
- DB: PostgreSQL (app + transaction store), Redis cache. Graph traversal via
recursive CTEs in Postgres, behind a
GraphRepositoryinterface (Neo4j-swappable). - ML (Phase 4): scikit-learn + XGBoost with
CalibratedClassifierCV. - Frontend (Phase 5): Next.js 14 (App Router), TypeScript, Tailwind, React Flow.
backend/
app/
config.py # Pydantic settings from env
enums.py # shared domain enums (incl. Evidence SignalType)
logging.py # structlog config
main.py # FastAPI app (+ /health)
db/ # async engine/session + declarative base
models/ # wallets, transactions, labels, vasps,
# clusters(+members), cases, findings
schemas/ # AttributionResult / Evidence (hard req #1, #2),
# GraphResult / PruneInfo (hard req #5)
repositories/ # GraphRepository interface + TraversalBounds
providers/ # ChainProvider protocol + offline FixtureProvider
ingest/ # offline label ingestion (idempotent)
cache/ # Redis JSON cache (degrades gracefully)
alembic/ # async migrations (initial schema committed)
data/labels/ # bundled offline label sources
tests/ # pytest, offline (SQLite), no network
docker-compose.yml # postgres + redis
- Synthetic generator does double duty (Phase 2): it produces both the demo fixtures and the classifier's labeled training data. This means Phase 4's reported calibration curve / Brier score are measured against the synthetic distribution — the README will state that plainly rather than imply real-chain calibration.
- Calibration (Phase 4): train both isotonic and sigmoid, pick by holdout Brier. Isotonic overfits at small N; we won't hard-commit to it.
insufficient_evidencevsambiguousare distinct states. No candidate above the floor →insufficient_evidence. Multiple credible candidates, none dominant →ambiguous(two split candidates, not "I don't know"). Both are first-class fields onAttributionResult.- Deposit-sweep detection needs reverse traversal. Consolidation is a
property of a candidate hot wallet's inbound edges, so
TraversalBoundscarries adirection(FORWARD / REVERSE / BOTH). - Phase 1 ships one offline provider. The live Etherscan/RPC provider + failover are deferred — they are not on the offline demo path, and live data against a real hot wallet is a demo-reliability risk.
- Risk score and VASP attribution stay separate (different questions; conflating them is a correctness bug).
Six signals, each a small class with a common interface, emit one classifier
feature and a human-readable Evidence object. The expensive graph
derivations run once in build_graph_facts, so training and inference compute
features identically (no train/serve skew).
| Signal | Feature | What it measures |
|---|---|---|
HOP_PATH |
hop_closeness | shortest-path proximity to the candidate |
DEPOSIT_SWEEP |
sweep_intensity | many→one consolidation into the hot wallet |
COUNTERPARTY_OVERLAP |
counterparty_overlap | shared associates with the deposit cluster |
TEMPORAL_CORRELATION |
temporal_correlation | deposit timing vs sweep timing |
KNOWN_LABEL |
label_confidence | reaches a labeled wallet (× source confidence) |
PATTERN_SIMILARITY |
pattern_similarity | cosine to a canonical exchange fingerprint |
Pipeline: feature_vector → XGBoost → isotonic/sigmoid calibration (the
lower-Brier of the two on a held-out split is kept). Per-prediction feature
contributions come from XGBoost's built-in TreeSHAP (pred_contribs) and set
each Evidence's weight. Hop distance is a feature, never a hard-coded
confidence (requirement #4).
Calibration report (held-out test, make train, seed 42):
| calibration | Brier score |
|---|---|
| uncalibrated | 0.075 |
| isotonic (chosen) | 0.074 |
| sigmoid | 0.078 |
Splits: 3600 train / 1200 calib / 1200 test; positive rate 0.61. Calibration
curve: backend/models/calibration_curve.png.
Honesty note (from the spec review): the classifier is trained on the synthetic generator (
app/synthetic/training.py), with labels drawn from an explicit logistic ground truth over the features — so the Brier score and calibration curve measure calibration against that synthetic distribution, not real-chain data. This is stated plainly rather than implied otherwise.
The engine assembles calibrated candidates + evidence into AttributionResult
and decides the outcome: a dominant candidate above threshold → single
attribution; two close credible candidates → ambiguous; nothing above the
floor → insufficient_evidence. Demo behaviour:
| Scenario | Result | Detail |
|---|---|---|
| ransomware_to_exchange | single | Binance ~0.99 (risk MEDIUM: upstream mixer) |
| peel_chain | single | Kraken ~0.64 (moderate) |
| dead_end | insufficient_evidence | no candidate cleared the floor |
| two_exchanges | ambiguous | Binance vs Coinbase, split |
Risk is computed separately from attribution (app/attribution/risk.py) —
exposure to sanctioned/mixer/scam entities by proximity in both directions —
because risk and VASP attribution are different questions.
Endpoints:
GET /wallets/{addr}/attribution?depth= -> AttributionResult (candidates+evidence)
GET /wallets/{addr}/risk?depth= -> RiskResult (score, level, indicators)
The trained model artifact is committed under backend/models/ so the demo runs
without retraining; regenerate with make train.
# 1. install backend deps (into a venv)
cd backend && python -m pip install -e ".[dev]"
# 2. start postgres + redis
docker-compose up -d # from repo root
# 3. copy env and apply migrations
cp .env.example .env
cd backend && alembic upgrade head
# 4. load bundled labels (offline)
python -m app.ingest.labels
# 5. run the API
uvicorn app.main:app --reload # http://localhost:8000/healthA Makefile wraps these (make install, make up, make migrate,
make ingest-labels, make dev).
cd backend
python -m pytest -q # 15 tests, in-memory SQLite
ruff check . # lint
mypy # strict type-check on app/- Structured evidence, not just a number —
schemas/attribution.py. - Can say "I don't know" —
insufficient_evidenceonAttributionResult. - Deposit-sweep is first-class (Phase 4; reverse traversal supported now).
- No hard-coded confidence percentages — hop distance etc. are model features (Phase 4), never lookup tables.
- Bounded traversal —
TraversalBounds+PruneInfo(pruned+ reasons).
- Python is pinned to 3.11 for deployment (Render); local dev on 3.13 works.
- The initial Alembic migration was generated offline; it uses portable
SQLAlchemy types and applies on both Postgres and SQLite. Regenerate against
Postgres with
make revision m="..."if you prefer PG-native rendering. - Real
.envis gitignored;.env.exampleis committed.