A reported phishing wallet (DEMO.md trace 6). Its money reaches a probable Binance deposit address in one hop — the address a lawful request should name — while another branch puts 10 ETH into Tornado Cash, where the trace stops rather than invent a trail.
| flagged addresses screened live on five chains |
address-poisoning wallets found by on-chain scans |
exchange addresses incl. 24,025 Bitget and Binance customer deposit addresses |
governments' sanctions and seizure lists |
addresses Tether has frozen |
hack, phishing and scam wallets |
chains, 14 assets | tests, no network |
Built for Smart India Hackathon 2026, problem statement SIH26183 (Ministry of Home Affairs).
A victim reports one wallet address. Exchequer follows the money outward hop by hop, screens every address it passes against sanctions lists, mixers and known theft, flags laundering shapes along the way, identifies the exchange where the funds landed — down to the customer deposit address where it can — and produces a report an investigator can attach to a legal request.
Why Ethereum, BNB Chain and Tron. The choice follows the published evidence rather than a hunch: Chainalysis measured stablecoins at 63% of illicit transaction volume in 2024 and 84% in 2025; TRM Labs measured 58% of 2024 illicit volume on Tron (a share that halved in 2025); and the UN Office on Drugs and Crime describes USDT on Tron as the "preferred choice" of the Southeast-Asian cyber-fraud operations that target Indian victims. No published source measures the cash-out rails for Indian scam proceeds specifically, so this README does not claim one. Every figure is quoted from its source in RESEARCH.md.
Polygon and Arbitrum came later, and cheaply: they are the two chains besides Ethereum that the free
Etherscan tier serves, so they run on the same key and the same code. Their labels are carried over from
Ethereum — an exchange's hot wallet is one private key, and one key controls the same address on every EVM
chain — and each address was kept only if it has no contract code on the new chain and has sent a
transaction there, which only the key holder can do (scripts/port_evm_labels.py). That gives 139 wallets of
53 exchanges on Polygon, 8 of them CoinDCX's, and 106 of 43 on Arbitrum, plus the sanctioned accounts that
pass the same test (15 and 9). The same check, read through NodeReal, added 146 wallets to BNB Smart Chain's
38. Base and Optimism need a paid plan and are not covered.
Old wallets on BNB Smart Chain. BSC's free provider (NodeReal) reads history in 100,000-block windows, and at today's ~0.45 s blocks the default reach is under three days. A reported wallet quieter than that is found through its nonce — the count of transactions it has sent, readable at any past block — and each wallet it paid is searched forward from the block the money arrived in. A month-old wallet traces in about a minute rather than a few seconds; a case shows the reported wallet's own statement (latest credits and debits) either way, with address-poisoning lookalikes marked.
The design principle is auditability. Every attribution is an exact match against a published address in a data file you can open and read. Every laundering finding is a handful of arithmetic comparisons that prints the thresholds it applied. There is no model, no clustering and no proprietary score — a conclusion that reaches a courtroom has to be one a human can re-check by hand.
| Follows the money | Breadth-first, both directions: where did it go and who paid this wallet. One asset per trace — ETH, BNB, TRX, USDT or USDC — so every amount in a graph is comparable. |
| Names the exchange | Exact match against 25,508 exchange addresses on five chains, including CoinDCX and Delta Exchange — and 24,025 per-customer deposit addresses at Bitget and Binance, each of which names one account. |
| Infers the deposit address | An unlabelled wallet whose every outflow sweeps to one exchange wallet is reported as that exchange's probable deposit address — the address a request has to name — and scored lower than a label match. |
| Screens every hop | Against the sanctions and seizure lists of the US, UK, EU, Israel, Japan and France; every address Tether has frozen on USDT, read from the contract itself; mixer pools (Tornado Cash, Typhoon, Privacy Pools); wallets tied to hacks (WazirX, Bybit, BingX, Ronin…) and reported phishing; and a 32-million-address label store: hack proceeds, scams, stablecoin freezes and 31.9M address-poisoning wallets found by on-chain scans, each cited with its source, a sample transaction and an evidence tier. |
| Stops at a mixer | A mixer pays out from a commingled pool, so the trace ends there and says so instead of manufacturing a trail. |
| Flags laundering shapes | Peel chains and amount splits, as fixed rules that print their thresholds — measured on 88 real wallets, and never allowed to move the score. |
| Follows the money through a swap | A transfer into a known DEX router is read from its receipt; if it came back as USDT or USDC, that stablecoin is traced onward from the moment of the swap, as its own linked trace. |
| Links complaints | Stored cases whose money converges on the same wallets are shown as one operation, with no new API calls. |
| Drafts the request | One click drafts the letter to the exchange — the address to ask about, every hop with amounts, times and transaction hashes, the evidence hash — and, where USDT or a Tether freeze is involved, the letter to Tether. The officer's details and the legal provision are left blank to complete. |
| Seals the evidence | Every provider response is hashed as it arrives; the report carries the manifest and a content hash, so a changed byte is detectable. |
reported address
│
▼
┌───────────────┐ Picks the provider for the chosen chain + asset
│ chain_data.py │ Ethereum → Etherscan BSC → NodeReal Tron → TronGrid
└───────┬───────┘ All rate-limit aware, retrying with backoff
▼
┌───────────────┐ BFS, depth-limited (default 4 hops), in either direction
│ graph_ │ outgoing → where the money went (default)
│ builder.py │ incoming → who funded this address
└───────┬───────┘ Highest-value branches first; drops dust; time-windowed
▼
┌───────────────┐ Exact lookup against that chain's own label file
│ exchange_ │ (labels are never shared between chains)
│ matcher.py │ + deposit_inference.py for probable deposit addresses
└───────┬───────┘
▼
┌───────────────┐ Six governments' sanctions/seizure lists, Tether's
│ risk_ │ USDT freezes, then mixer pools and hack/phishing
│ matcher.py │ wallets. Government first; always stops at a mixer.
└───────┬───────┘
▼
┌───────────────┐ Peel chain + amount split, as explainable fixed rules
│ pattern_ │ swap_detection.py reads receipts at DEX routers
│ detection.py │
└───────┬───────┘
▼
┌───────────────┐ hop proximity (40%) + amount correlation (35%)
│ scoring.py │ + match directness (25%)
└───────┬───────┘
▼
graph + exchange + confidence + screening + flags ──► SQLite ──► sealed report
Prerequisites: Python 3.11+, Node.js 20+, and free API keys — Etherscan (Ethereum, required), NodeReal (BNB Smart Chain, optional), TronGrid (Tron, optional; keyless requests are throttled to about one every 1.2 seconds) and TronScan (optional; lets Tron traces read TronScan's tags live).
# backend
cd backend
python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # paste your keys
python -m scripts.check_etherscan # prints live transactions if the key works
uvicorn app.main:app --reload --port 8000
# frontend, in a second terminal
cd frontend
npm install
npm run dev # → http://localhost:5173The first screen is the front door. At the investigator's door, officers sign in with the account their unit's
admin made on the Admin page; a new account's temporary password is changed at the first sign-in. Anyone else
can still look around as a demo, as an investigator (the tracing console) or a citizen (a page still to be built,
with no accounts yet), and the demo says so on every page. A public deployment can ask for a demo code before
live tracing (EXCHEQUER_DEMO_CODE, see docs/operations.md).
Getting an Etherscan key, and other setup notes
- Go to https://etherscan.io/apis and create a free account.
- Sign in, open API Keys in the left sidebar, click Add, and copy the key into
backend/.env.
The free tier allows ~5 requests/second and 100,000/day. Exchequer self-throttles below that and backs off
automatically, so a demo will not die mid-trace. It uses Etherscan's V2 multi-chain endpoint
(api.etherscan.io/v2/api?chainid=1); the old V1 per-chain hosts are retired.
- Check the backend: http://127.0.0.1:8000/chains — Ethereum must show
"ready": true. Interactive API docs: http://127.0.0.1:8000/docs. - Use
localhost, not127.0.0.1, for the frontend: Vite binds to IPv6[::1]by default. Runnpm run dev -- --host 127.0.0.1if you need IPv4. - The frontend calls
/api/*, which Vite proxies to port 8000, so nothing needs configuring. - Serving the UI from FastAPI instead (one origin, no Vite):
cd frontend && VITE_API_BASE="" npm run build. A plainnpm run buildbakes in the dev-mode/apiprefix, which only the Vite proxy rewrites. The Dockerfile sets this already.
A tour of the interface
The interface answers first and shows evidence on demand. The home page, the Console, opens on one field for
the victim's payment hash or the wallet they paid, a chain and asset choice, and three recorded DEMO.md cases
to try; below it sit the recent cases (what each reached, and how sure), the exchange wallets on file per chain,
and the watchlist. The full form, with every DEMO.md trace and option, is on New trace. A case opens as: a red
banner if any address is sanctioned, a mixer or a known thief's; one finding bar — where the money went, the
address to cite, how sure (the score with its three inputs; the working one click away); then the money flow
drawn by hop, the reported address on the left and whatever it reached on the right, the attributed path the
one bold line, every other transfer thin (the force-directed bubble view is a toggle); beneath it only the
transfers that matter, with the full list one click away; and on the right, one folded section each for
inferred deposit addresses, patterns, swaps, related cases, funders and scope. Present (or the P key) shows
the case as four projector screens, stepped with the arrow keys; Esc returns to the console. Every address
copies itself when clicked. Dark console skin by default; a paper case-file skin is one click away.
The sign-in page is a globe of the world's exchanges: every one CoinGecko lists against a country (304 of
965 — decentralised venues have none), placed at that country's main financial city and coloured by when it was
founded. It turns on its own and by hand; hovering an exchange names it, with its founding year and country.
Beside it, the jurisdictions exchanges register in (hover one and the globe turns to it) and what the tool can
see, read live from GET /chains. Clicking the mark in the top bar opens the sign-in: the chequer flies into the
globe, the globe collapses into it, and the card assembles from its squares. The exchange points are rebuilt
with backend/scripts/build_exchange_globe.py.
The mark is a chequer. The Exchequer was named for the chequered cloth on which the Crown's money was counted, square by square; one square is red — the sum being traced.
Real mainnet addresses that produce real attributions — pick the chain and asset to match the row. DEMO.md has ten more, each re-verified cold with what to say about it.
| Chain / asset | Address | What you get |
|---|---|---|
| Ethereum · ETH | 0x60d02e0956e2f3795167c15ba61ab452c85c2533 |
Best demo. 48 addresses at 4 hops, ~28 s / 31 requests cold; a probable Bitget deposit address in 1 hop (0.875), with Binance 14, OKX 24 and Bitget 6 as exact matches at 2 hops; one swap detected |
| Ethereum · ETH | 0x00000000072d54638c2c2a3da3f715360269eea1 |
A reported phishing wallet: probable Binance deposit address in 1 hop, and 10 ETH into Tornado Cash, where the trace stops |
| Ethereum · ETH | 0x21b8d56bda776bbe68655a16895afd96f5534fed |
Sanctioned by three governments (US, UK, Israel); funds reach a probable Bybit deposit address |
| Ethereum · ETH | 0x536c4921d1aafde6a5cda882fb5ca046f3601c65 |
The attribution that moves: eight-plus changes on live data, each explained (9 Nov: Coinbase at 0.46, then Kraken at 0.45). On 27 Sep it was a probable Binance deposit address 1 hop out (0.875) after the wallet sent it 2.01 ETH on 27 Sep; two days earlier it was OKX at 0.39 with pro-rata at ≈ 1.65 of 951 ETH |
| Ethereum · USDT | 0x0b2fdf416cf2951499de9a1adac65c8e9907c8c2 |
Stablecoin cash-out — Binance 14 in 1 hop, 102.8M USDT, confidence 1.00 |
| BSC · USDT | 0x32d03f46ba2857c8e6a920ab3fed1f24d35d85d1 |
BNB Smart Chain — Binance Hot Wallet 6 in 1 hop, 19.3M USDT |
| Tron · USDT | THWYhwUQnBcKpwSxaXjqv18RPtSoK4C5Ph |
Tron — Binance-Hot 7 in 1 hop, under a second, confidence 1.00 |
| Tron · USDT | TUVNGw2z3Gt8SDNukoj8GqSStKrve5i3ts |
Frozen by Tether on 11 Sep 2026, as was the next wallet; about 635 of its 9,891 USDT reached Binance-Hot 7 at 2 hops (6.4%, mixed into 45,968 USDT), so 0.49, low |
Start at 2–3 hops for a demo: those return in roughly 5–20 seconds cold, and instantly once cached.
Addresses at the same depth are fetched concurrently (TRACE_CONCURRENCY, default 6); the clients' own
throttles still cap the request rate, so this shortens a trace without increasing load on the API.
{ "address": "0x...", "chain": "bsc", "asset": "USDT", "max_depth": 4,
"direction": "outgoing", "time_budget_seconds": 90 }| Field | Values |
|---|---|
chain |
ethereum (default), bsc, tron — never inferred from the address, since an EVM address is valid on every EVM chain |
asset |
ETH/USDT/USDC on Ethereum, BNB/USDT/USDC on BSC, TRX/USDT on Tron |
direction |
outgoing — where did the victim's money go? · incoming — who sent money to this address? |
max_depth |
1–6 hops (default 4) |
follow_swaps |
default true: a swap into USDT/USDC is traced onward as a linked follow-on trace |
time_budget_seconds |
optional, 10–600: stop expanding once spent and return what was reached |
One asset per trace, deliberately. 1 BNB and 1 USDT are not comparable quantities, so a graph mixing them could not be scored — the amount-correlation component would be meaningless.
A reverse trace turns one complaint into a picture of a campaign: if the reported address belongs to an
offender, the addresses that paid it are candidate victims, each of whom may hold a separate FIR. The field
is named direct_senders rather than "victims" on purpose — a sender may equally be the offender's own
wallet, an exchange withdrawal or an unrelated payment, and the response marks the exchanges among them.
Edges always point the way the money moved, whichever direction the walk ran.
With time_budget_seconds, addresses reached but never read are counted in addresses_unexpanded_by_time,
the truncation reason names the budget, and seconds_elapsed says how long it took. The interface offers it
as a Stop after 1:30 switch, off by default.
{
"case_id": "uuid",
"graph": { "nodes": [...], "edges": [...] },
"exchange": "Binance",
"confidence": 0.9,
"flags": ["peel_chain", "amount_split"],
"risk_matches": [{ "category": "stolen", "entity": "Phishing wallet", "source": "...", "depth": 0 }],
"hop_count": 2
}The response also carries confidence_detail, findings, trace_path, matches, inferred_deposits,
swaps, risk_notes, truncation_reasons and warnings.
| Status | Meaning |
|---|---|
400 |
Malformed address, unknown chain, or an asset that chain does not carry |
429 |
Provider rate limit survived all retries — wait and retry |
502 |
Could not reach the blockchain data provider |
503 |
No API key configured for the requested chain |
A wallet with no outgoing transfers, or one that reaches no known exchange, returns 200 with
exchange: null and a plain-English message — those are findings, not errors.
| Endpoint | Purpose |
|---|---|
GET /trace/{case_id} |
Retrieve a stored case |
GET /trace/{case_id}/report?format=text |
The investigator's report (format=json for structured): summary, finding and basis, the path hop by hop with amounts, times and hashes, every pattern with its thresholds, every inference with its evidence, screening hits with their sources, limitations, and an appendix of every address and transaction hash |
GET /trace/{case_id}/letters |
Which request letters the case supports: the urgent freeze letters first, then the information letters (to the exchange, to Tether) |
GET /trace/{case_id}/letter?to=exchange |
A draft request letter filled from the case, for the officer to complete (to=tether for Tether; to=exchange-freeze or to=tether-freeze for the urgent "hold these funds now" letter) |
GET /cases |
History of past traces |
GET /cases/correlate |
Intermediaries shared by two or more of your stored cases (plus the demos) — the campaign view (?case_id= narrows it) |
GET /exchanges?chain=bsc |
What that chain's exchange labels cover |
GET /risk-labels?chain=bsc |
What that chain's screening covers, by category: sanctioned, mixer, stolen |
GET /ledger?chain=ethereum |
The labelled addresses the sign-in backdrop draws |
GET /health |
Up or down, and whether live tracing is open to anyone ("access": "open"), needs the demo code ("code") or is off ("closed") |
GET /chains |
Per-chain readiness, assets and label counts, with exchange wallets and customer deposit addresses counted apart |
GET /health/detail |
Operator only (bearer EXCHEQUER_HEALTH_TOKEN): key status, per-chain detail, API budget, warm-cache status |
GET /access |
Whether this site asks for a demo code before live tracing |
POST /access/check |
Checks a demo code (sent as X-Exchequer-Demo-Code) and returns a pass to send as X-Exchequer-Demo-Pass instead; 403 if wrong, 429 after 10 wrong tries in 15 minutes (a code of 22+ characters still lets the right code through; a shorter one locks it out too) |
Both pattern rules live in backend/app/pattern_detection.py, with every threshold a named constant in one
PatternConfig dataclass. Each finding reports the thresholds it applied, so it can be re-checked by hand.
Where the rules come from, stated honestly: the shapes come from the literature, the numbers do not. The peel chain was named by Meiklejohn et al. in 2013 (A Fistful of Bitcoins, IMC'13), who warned in the same paragraph that the shape "extends well beyond criminal activity". The fan-out shape is what Elliptic's 2025 pig-butchering typology describes as moving funds "through dozens of intermediary wallets". Every numeric threshold below is this project's own choice and comes from no paper. How those choices behave on real wallets is measured in Validation; the sources are in RESEARCH.md.
When the trace reaches a wallet, it knows the moment the traced funds landed there. Only transfers that wallet
made at or after that moment can carry the victim's money, so anything it sent earlier is left out as the
wallet's own prior business. Walking backwards the rule mirrors. Without this, a busy wallet that once received
the victim's funds would have its entire earlier history reported as where the victim's money went. Every node
carries window_start (or window_end) and excluded_by_time, and the total is reported as
transfers_excluded_by_time, so the effect of the rule is visible rather than silent.
Stolen funds walked through a series of throwaway wallets, with a little skimmed off at each hop.
- Wallets with one source and one destination within the trace, in at most 3 transfers within it.
- Consecutive such wallets form a run; keep runs of 2 or more.
- Amounts must never grow along the run (2% tolerance for gas).
- The run must end lower than it started — that is the peel.
- Each hop must forward at least 50% of what it received; keeping half is a split, not a peel.
One address receives a sum and fans it out, multiplying the paths an investigator must follow. (This is sometimes called "structuring", but that is a legal term about currency reporting thresholds, of which a public blockchain has none — the rule describes a shape, not an offence.)
- Sent to 4 or more distinct recipients.
- Between 90% and 102% of what came in went back out.
- No single recipient took more than 60%.
- The address is not itself a known exchange.
These were tightened on 15 September 2026 from 3 / 50–110% / 90% after the measurement below showed the originals flagged 40% of ordinary high-volume wallets.
Most cash-outs do not land on a labelled hot wallet. The customer is given a deposit address, the money lands
there, and the exchange later sweeps it to a hot wallet. That unlabelled deposit address is the one a lawful
request has to name — a hot wallet receives from thousands of customers and identifies nobody.
backend/app/deposit_inference.py calls an unlabelled wallet a probable deposit address of exchange E when:
- It is not the reported address and has no label of its own — no exchange label and no risk label (a poisoning lookalike, a scam wallet, a freeze, a warning tag).
- It made at least 2 outgoing transfers, each worth more than dust in the traced asset (0.001 ETH, 0.005 BNB, 2 POL, 10 TRX, 1 USDT or USDC per transfer, not in all).
- Every one went to the same labelled E wallet (never a labelled deposit wallet) and nowhere else.
- It passed on what it received: after the traced funds arrived it sent that E wallet at least half of what it received along the trace. A wallet that kept the victim's 5,000 USDT and sent a few dollars on is holding the money, not an exchange's deposit. The cost: a deposit not yet swept when the trace ran is not inferred.
- Where cheap, its current balance is recorded as evidence.
An inference is scored as a weaker attribution: match_directness is 0.5 instead of 1.0. The response says
attribution_inferred: true, the sidebar reads "Probable Binance deposit address (inferred — see evidence)",
and the report prints the evidence and the sentence that would confirm it. It can be wrong — an exchange's own
consolidation wallet has the same shape (harmless: it still belongs to that exchange), and a person who really
did send everything, twice, to one hot wallet would be misnamed. On the validation corpus it fired on 0 of 48
control wallets. Where Exchequer holds the explorer's own deposit-address label — the 24,025 Bitget and Binance
addresses — the match is exact instead.
A trace follows one asset, so when a wallet sends 10 ETH to a Uniswap router the ETH trail ends there — but the
wallet got the money back as USDT and carried on. When an outgoing transfer's recipient is in
router_labels*.json, the trace reads that transaction's receipt and its ERC-20 Transfer events. The edge
carries swap: {router, asset_in, amount_in, asset_out, amount_out, tx}.
When the output is a stablecoin the chain carries (USDT or USDC), the money is followed. A follow-on trace
starts from the wallet that swapped, in the swap's output asset, over the hops the original trace had left, and
the time rule applies to that wallet from the swap onward — anything it sent in that asset before the swap was
not the swapped money. (On a real Uniswap swap of 0.15 ETH into 426 USDC, that excluded 197 earlier USDC
transfers.) Both traces are stored in one case and drawn one under the other; each keeps its own amounts and its
own score, because amount correlation across 1 ETH → 2,400 USDT would be meaningless. If the original asset
reached no exchange but the follow-on did, the case's finding is the follow-on's, labelled "after a swap to
USDT" with the confidence stated as within that trace. At most two follow-ons per trace, one level deep;
"follow_swaps": false turns it off. What it cannot do: follow a swap into the native coin (the receipt shows no
token output), a swap into a token the chain is not configured to trace, or a bridge to another chain.
| Component | Weight | What it measures |
|---|---|---|
| Hop proximity | 40% | Fewer hops = less room for the trail to be broken by a wallet we cannot see |
| Amount correlation | 35% | How much of the reported funds likely arrived, measured pro-rata (below) |
| Match directness | 25% | 1.0 for an exact label, 0.5 for an inferred deposit address |
No exchange match scores 0.0 and reports "no attribution" — never a low-but-nonzero number that might be over-read. Patterns, screening hits and truncation are reported as caveats rather than folded into the number, so the score stays reproducible.
Pro-rata: how much of it is the victim's money. A wallet in the middle of a path may be moving other people's money at the same time, so "1,001.50 USDT reached Binance" says nothing about how much of it was the victim's 40. At each wallet on the path the tool reads every transfer into it between the traced funds arriving and the wallet sending on — not only the ones the trace followed — and carries the victim's share forward: share = victim funds in ÷ all funds in; victim funds on = amount sent on × share. The victim amount can only shrink along the path. The case, the report and Present mode show the result as its own figure — "≈ 9.42 of 40 USDT of the reported funds likely arrived (pro-rata); 98 arrived in total, the rest from other sources" — and a share under 25% at any wallet is called out. "All funds in" is new money only: the route's own transfer counts once, and a wallet paying itself or getting back coins it had just paid out (W → X → W) is netted out and said so. Money a wallet already held before the funds arrived is not counted; counting it would lower the estimate. Where a wallet received too much in the window to read in full, the case says "not estimated" instead of guessing, and the amount earns no credit in the score. On the demos it changed nothing where the money moved cleanly, and cut the scores of the paths it should: two of DEMO.md trace 10's three phishing wallets (0.58 and 0.50), and a real BSC wallet whose 40 USDT was 9.6% of what the next wallet received (0.73 → 0.47).
The weights are judgement, not calibration. 40/35/25 and the per-hop decay were chosen to be explainable, not fitted to labelled outcomes — there is no public ground-truth set of traced Indian fraud cases to fit them to. Read the score through its three components and caveats, which the case and report always show.
Every address in a trace is checked against four kinds of list (five on Tron), with different authority behind them, and every hit names the list it came from.
| Category | Meaning | Source | Effect on the trace |
|---|---|---|---|
sanctioned |
On a government sanctions list | US, UK, EU, Japan, France | Flagged; the trace continues — its transfers mean what they say |
seized |
Named in a government seizure or forfeiture order: one government's measure, often time-limited, not a designation | Israel (NBCTF orders) | Flagged; the trace continues; a dated order stops matching after its last day |
mixer |
A tumbler's pool or entry router | Etherscan / BscScan tags | The trace stops here |
frozen |
Tether has frozen the address's USDT | The USDT contract's own blacklist events | Flagged; the trace continues |
stolen |
A wallet a tag names as the perpetrator of a hack or exploit | Etherscan / BscScan tags | Flagged; the trace continues — where the thief moved the money is the point |
scam |
Reported by a published scam or phishing list | ScamSniffer, Etherscan's Phish/Hack label | Flagged as that list's report; the trace continues |
reported |
TronScan's own warning tag ("Suspicious", "Scam", a phishing tag) | TronScan, read live during a Tron trace | Flagged as the explorer's warning — the weakest of the five; a label-file entry always outranks it |
| Chain | Sanctioned | Seizure orders | Mixers | Frozen by Tether | Stolen funds | Scam lists |
|---|---|---|---|---|---|---|
| Ethereum | 127 | 3 | 40 | 2,580 | 398 | 7,979 |
| BNB Smart Chain | 4 | 2 | 14 | — | 31 | 529 |
| Polygon PoS | 14 | 1 | — | — | — | — |
| Arbitrum One | 7 | 2 | — | — | — | — |
| Tron | 337 | 481 | — | 6,800 | — | — |
Counts are after overlap: an address on a government list is reported as sanctioned or seized, and a mixer pool Tether also froze stays a mixer. Before overlap the freeze list holds 2,710 Ethereum and 7,588 Tron addresses.
Government lists are loaded first and always win: where an address is also on an explorer's list, the government entry is the one reported, and an address several governments list names all of them — "designated by the US and the UK, and named in an Israeli seizure order" is a stronger finding than any one alone.
- OFAC SDN list (18 September 2026) — built straight from Treasury's XML, keeping the programs and exact
idTypeof every entry: 124 Ethereum, 334 Tron, 1 BSC.scripts/import_ofac_addresses.py - Five more governments — the UK Sanctions List and the EU consolidated list, read directly (their addresses
sit in free text, each filed under the chain the text names before it:
ETH:,BNB:,TRX:); Israel's NBCTF counter-terror seizure orders, Japan's Ministry of Finance and France's DG Trésor, through OpenSanctions. Only measures still in force count — a wallet named only in lifted Israeli seizure orders is left out, and an order with an end date stops matching after its last day, whether or not the list has been refreshed. A seizure order is one government's measure against a wallet, not a designation, so a wallet only Israel's orders name is reported asseized. Israel's orders in force alone name 488 Tron wallets, almost all USDT. Canada, Australia, Switzerland and the UN publish no crypto addresses; all 95 official lists OpenSanctions carries were checked.scripts/import_intl_sanctions.py
Tether's freeze list is read from the chain itself. Tether can blacklist an address on its USDT contract — after
a sanctions match, a law-enforcement request or a theft — and every freeze is an AddedBlackList event, every
release a RemovedBlackList. Replaying them in order gives the addresses frozen today, with no dataset between the
contract and the label; a random sample is checked against the contract's own isBlackListed() and the import
refuses to write on any disagreement. Tether's list also holds the zero address and other tiny system addresses
where tokens are burned; those are not wallets and are left out, so a burn in a trace is never read as a freeze.
So are the USDT contract itself, DEX routers and other token contracts it names (app/never_flag.py): a swap
through a router is never read as a freeze either.
A freeze says the issuer acted, not why — and a request to Tether can ask.
BNB Smart Chain's USDT is a Binance-issued peg with no Tether blacklist. scripts/import_tether_freezes.py
The mixer rule is a correctness rule. A tumbler pays out from a commingled pool, so transfers leaving it have
no established relationship to the deposit the trace arrived on. Following them would manufacture a trail and
hand an investigator a confident-looking graph built on a false premise. Tornado Cash left the SDN list in March
2025 (Van Loon v. Treasury), and every mixer OFAC still lists is a Bitcoin service — so a sanctions-only screen
would never stop at a mixer on these chains. The mixer category therefore comes from the explorer's own tags:
only the pools and routers of Tornado Cash, Typhoon and Privacy Pools, never their governance, token or vesting
contracts. The label store follows the same rule, and only on Ethereum, the chain its mixer tags were read on.
A name in a list is not enough to cut a trail, so a store row stops a trace only at a pool or router kept in the
repository (the threat file's mixer entries and backend/data/mixer_pools.json); any other store row named like
a pool is shown as a mixer tag, and the trace goes on past it.
Stolen funds are the wallets Etherscan tags as the perpetrator of a theft — the WazirX, Bybit, BingX, Ronin,
Multichain and other exploiters; whitehat, rescue and bot tags are left out. Scam-list wallets are the reported
phishing and scam wallets from ScamSniffer's published blacklist and Etherscan's Phish/Hack label, filed as scam:
a report, not a finding that the wallet holds a theft's proceeds. Every one was checked to exist and to have been
used on chain, and filed under each chain it has been used on (1,604 with no history on Ethereum or BSC were left
out). A wallet on a scam list is that list's accusation, and the finding says whose.
scripts/import_threat_labels.py [--scam-lists]
Screening degrades safely. With a label file missing, the trace still runs and still attributes an exchange — it reports what it could not screen.
GET /risk-labelsreportsscreened, so a clean result is never ambiguous.
Labels are per chain and never shared: Binance's hot wallet on Ethereum and on BSC are different addresses, and matching one chain's address against the other's labels would manufacture an attribution.
| File | Coverage |
|---|---|
exchange_labels.json |
25,020 addresses / 92 exchanges — 957 exchange wallets (the largest: Huobi/HTX, Coinbase, Binance, Kraken, Bitfinex, Nexo, OKX, Bithumb, KuCoin, CoinDCX, Bitget, Poloniex) plus 24,063 customer deposit addresses (19,027 Bitget, 4,998 Binance, 38 at other exchanges) |
exchange_labels_bsc.json |
184 addresses / 56 exchanges — the original 38 (Binance, MaskEX, Gate.io, KuCoin, Huobi/HTX, MEXC, BitMart, Hotbit, CoinDCX, AscendEX, Crypto.com, Azbit, FixedFloat) plus 146 Ethereum-labelled exchange accounts confirmed to have sent on BNB Smart Chain (scripts/port_evm_labels.py) |
exchange_labels_tron.json |
41 addresses / 19 exchanges — read live from TronScan's own address tags |
A deposit address is the most useful label there is: it names one customer account, which is exactly what a lawful request asks the exchange about. The 19,027 Bitget and 4,998 Binance addresses come from Etherscan's "Bitget Dep" and "Binance Dep" tags, and every one was confirmed on chain to have at least one transaction or token transfer — all 19,027 Bitget addresses passed; 21 of 5,012 Binance addresses had never been used and were left out.
Every address proves itself against live chain data before it enters a file. For an exchange wallet the test is inbound value, not nonce — a deposit wallet receives constantly and may almost never send, whereas zero lifetime value means the address is not handling customer funds. The 22 September import checked 724 Ethereum candidates and accepted 690; seven more were then removed when a tightened filter caught EigenLayer AVS-operator and domain-registry tags carrying an exchange's name. On BSC, 8 of 21 candidates were active. A second pass checks for contract bytecode, because a hot wallet is an externally-owned account — that pass once removed four addresses carrying an exchange's name but doing something else entirely:
| Address | Labelled | Actually |
|---|---|---|
0x75231f58…42a86c |
OKX | OKB token contract |
0x056fd409…6d5cd |
Gemini | GUSD token contract |
0xed03ed87…9464aa |
Bitfinex | Tether MXNt token contract |
0x3b3ae790…fb6790 |
OKX | OKX DEX aggregation router |
The router mattered most: funds passing through a swap router have been traded, not deposited.
Tron labels are read from TronScan's own API — the label comes from the explorer that assigns it — and each must have no contract bytecode and at least one received transfer.
Tron attribution beyond the file. A fixed file can only hold the Tron wallets someone imported — 41. So a Tron
trace also asks TronScan, for each wallet it reaches that no file names, what the explorer tags it (up to 60 wallets
per trace, the most valuable first, the last hop included). A tag that names a known exchange attributes the wallet —
labelled "(TronScan tag, read live)" so a reader always knows the source — and the trace stops there as it would
at any exchange; a tag naming a treasury, bridge, token or scam never counts, and one not recognised is listed, not
guessed at. Every response is hashed into the evidence manifest, and the report states how many wallets were asked
and what was found. A failed lookup costs an attribution, never the trace. It needs a free TronScan API key
(TRONSCAN_API_KEY); without one it is off and the report says so. Wallets beyond the nearest exchange the file
already found are not asked about, since they could not give a nearer answer. On its first live run it attributed
wallets paying Flipster, WestWallet and ONUS — none of them in the file — at confidence 1.00, one lookup each. Router labels (20 on Ethereum, 4 on
BSC) must, the other way round, carry bytecode.
CoinDCX is covered on Ethereum (29 wallets) and BSC, and Delta Exchange on Ethereum, each from the explorer's own labels and verified on chain. WazirX and ZebPay remain absent: their hot wallets could not be sourced to a standard appropriate for an attribution tool, and guessing one would mean falsely naming a real company in a law-enforcement report. (The WazirX exploiter wallets are in the stolen-funds list.) On Tron, no TronScan tag names an Indian exchange among the largest accounts; the importer looks for them and would keep one.
cd backend
python -m scripts.import_exchange_labels # Ethereum, original dataset
python -m scripts.import_eth_labels # Ethereum + BSC, newer dataset
python -m scripts.import_eth_labels --deposits Binance # per-customer deposit addresses (resumable)
python -m scripts.seed_bsc_labels # BSC
python -m scripts.seed_tron_labels # Tron, from TronScan tags
python -m scripts.seed_router_labels # DEX routers
python -m scripts.verify_labels # re-check the Ethereum fileOne complaint gives one trace; the problem statement's real ask is the campaign. Every trace is stored with its
full graph, so GET /cases/correlate is a query over what has been traced — no API calls, milliseconds.
An intermediary is any address in a case's graph — or in its follow-on traces after a swap — other than the reported address and anything with a name of its own: an exchange wallet, a DEX router, a sanctioned entity, a mixer. A wallet a list flags without naming its holder — a Tether freeze, a stolen-funds tag, a TronScan warning — still counts, and the related-cases panel says which flag it carries: two complaints meeting at a frozen wallet is a stronger lead, not a weaker one. One reached by traces of two or more different reported addresses on the same chain is a point of convergence, returned with the cases that reach it, their distance and the value that arrived. Exchange hot wallets are excluded on purpose: two victims whose money both ended at Binance 14 share a bank, not an offender.
Correlation reads only the caller's own cases, the demo cases and the one whose link they hold. Without accounts every
visitor is a stranger, so complaints traced in different browsers do not meet; correlation across officers returns
with accounts and units (see backend/app/ownership.py).
Three wallets on Etherscan's phishing list (0x000000000532…, 0x0000000009324…, 0x00000000bf02…) share 60 and 46
intermediaries with the third, four of them inferred Binance deposit addresses — and all three reach the same reported
phishing wallet three hops out. That is one operation run from several wallets, which three separate complaints
would never have shown. The caveat: the rule can only exclude what the label files know, so an unlabelled public
contract can appear as a "shared intermediary". Read a cluster with its amounts.
A finding can be re-derived from the report — the arithmetic is on the page. What cannot be re-derived is the input: the provider's answer to each query, at the moment it was given. So every response is hashed (SHA-256) as it arrives, with the request that produced it (credential removed, parameters in a fixed order) and the UTC time, and stored with the case as an evidence manifest. The text report prints the manifest and ends with a content hash over every byte above it:
head -c -N report.txt | shasum -a 256 # everything above the CONTENT HASH lineThat establishes the bytes hashed are the bytes the tool used. It does not make a response tamper-proof; it makes
tampering detectable. A response served from cache is recorded with its original hash and time and marked
cached, never presented as a second retrieval.
A threshold that was chosen rather than derived has to be shown to behave. Two corpora of real Ethereum wallets,
every address fetched by script from a named source (provenance in backend/data/validation/corpus.json):
- 40 positives — documented fraud or theft proceeds: 20 OFAC-listed, 17 on Etherscan's Phish / Hack label, 3 WazirX-hack attacker addresses.
- 48 controls — investment funds, mining-pool payouts, charities, payment processors, OTC desks, trading firms, treasuries and airdrop distributors — the wallets most likely to trip the amount-split rule.
Each was traced at depth 3 with the real pipeline (1,416 provider requests); a rule fired if it produced a finding anywhere in the graph.
| Rule | Thresholds | Positives (of 32) | Controls (of 47) |
|---|---|---|---|
| Peel chain | defaults | 2 | 2 |
| Amount split | previous defaults: 3 / 50–110% / 90% | 12 | 19 |
| Amount split | current: 4 / 90–102% / 60% | 6 | 5 |
| Deposit-address inference | 2 sweeps, 100% to one labelled wallet | — | 0 of 48 seeds |
Neither rule separates documented-illicit wallets from ordinary high-volume ones within three hops, and no point in the 68-point threshold grid brought control firings to zero while still firing on a positive. Read a pattern finding as the tool always asks you to: a reason to look closer, never a verdict. Only Ethereum / ETH was measured.
cd backend && python -m scripts.validate_patterns # offline, from the committed snapshotFull threshold grid
| Rule | Thresholds | Positives (of 32) | Controls (of 47) |
|---|---|---|---|
| Peel chain | 3 transfers, 2 intermediates, 50% retention (defaults) | 2 | 2 |
| Peel chain | any retention 0.3–0.9, any activity cap 2–6 | 2 | 1–2 |
| Peel chain | 3 intermediates | 0 | 0 |
| Amount split | previous defaults: 3 recipients, 50–110% forwarded, 90% cap | 12 | 19 |
| Amount split | 3 recipients, 90–102%, 60% cap | 8 | 9 |
| Amount split | 4 recipients, 90–102%, 90% cap | 7 | 8 |
| Amount split | current defaults: 4 recipients, 90–102%, 60% cap | 6 | 5 |
| Amount split | 5 recipients, 90–102%, 60% cap | 3 | 5 |
Both peel-chain control hits are mining-pool payouts — the legitimate shape Meiklejohn et al. warned produces it. The measurement does not test recall against ground truth, since a wallet documented as holding stolen funds is not thereby documented as laundering them in one of these shapes.
Almost none of it is Exchequer. The graph walk, rules and scoring take milliseconds; a trace's wall time is very nearly the number of provider requests it makes, and on a free key those arrive at about two per second. Measured against a live Etherscan free key, the documented "5 requests/second" is not what is granted:
| Spacing | Offered | Refused | Useful throughput |
|---|---|---|---|
| 0.20 s | 4.8/s | 67% | 1.61/s |
| 0.34 s | 2.7/s | 54% | 1.21/s |
| 0.50 s | 1.6/s | 4% | 1.56/s |
| 0.70 s | 1.3/s | 0% | 1.28/s |
So ETHERSCAN_MIN_INTERVAL defaults to the measured optimum, the pacer widens on refusal and narrows on success,
and responses are cached per credential for a day — in memory and on disk. On-chain history is append-only, so a
cached answer can lag the newest blocks but never contradict them:
| Requests | Time | |
|---|---|---|
| First trace of an address | ~35 | ~20 s |
| Either direction, again | 0 | 0.03 s |
With EXCHEQUER_WARM_CACHE=1 (for a host with a lasting disk; the free Render plan leaves it off and serves
recorded demos instead) the backend traces the DEMO.md addresses in the background at startup, so demos never go
cold on a hosted instance. GET /health/detail (operator token) reports
api_budget and warm_cache.
Stated plainly, and repeated in every exported report:
- One asset per trace, one follow-on level. A swap into USDT or USDC at a labelled router is followed as a linked trace; a swap into the native coin or another token, a swap sent to a pool rather than a router, a second swap inside a follow-on, or a bridge to a chain Exchequer does not cover still ends the trail. A follow-on follows everything the wallet sent in the new asset after the swap, which can include money that was not the swap's output — the swap's amount is shown beside it for comparison.
- An exchange match identifies where funds arrived, not who controls the account. Only the exchange can link a deposit address to a customer identity, via a lawful request.
- Screening is only as complete as its lists. An address absent from them is not thereby legitimate. A foreign designation carries no automatic force in Indian law, and an explorer or scam-list tag is that publisher's attribution — each is a lead and an escalation trigger, not a verdict.
- A trace stops at a mixer and cannot resume past one. The link between a tumbler's deposits and payouts does not exist in the transaction data.
- The graph is a sample. Only the 10 highest-value counterparties of each address are followed, and only the
newest 200 transfers of each address are read (
TRACE_MAX_TXS_PER_ADDRESS). A launderer who splits into eleven or more parts, or hides the real money as the eleventh-largest transfer, defeats the walk. - BSC covers a recent window. NodeReal caps a query at 100,000 blocks, so history is walked back 500,000 blocks
(~17 days) by default (
NODEREAL_LOOKBACK_BLOCKS). - Internal contract transactions are read on Ethereum only, for addresses with no signed outflow; on BSC, Tron and token traces, contract-moved value is invisible. A contract that pays more than 3 addresses is a service (WETH, a pool) and is not expanded.
- Pattern rules see only the traced path — "single-use" means within the trace; the amount split has no time window; amount correlation is capped at 100%.
- The time rule resolves to the second, not the position within a block; the reported address itself is not time-windowed, because the trace does not know when the victim's funds arrived.
direct_sendersare addresses that funded the reported one, not confirmed victims.- Attribution is only as good as the label files. A null result may mean the exchange is absent — check
GET /exchanges.
Every label is generated by a script from a named, pinned source, and every entry records that source. The full provenance — what each source is, what it establishes and what it does not, with quoted passages — is in RESEARCH.md § Where the label data comes from.
| Data | Source | Terms |
|---|---|---|
| OFAC sanctioned addresses | U.S. Treasury SDN list (XML) | U.S. government publication |
| UK sanctioned addresses | FCDO UK Sanctions List | Official government publication |
| EU sanctioned addresses | EU consolidated financial sanctions list | Official EU publication |
| Israel, Japan, France | NBCTF seizure orders, Japan MOF, France DG Trésor — via OpenSanctions | CC BY-NC 4.0 — non-commercial use |
| Exchange, mixer and exploiter tags | Etherscan / BscScan labels via dawsbot/eth-labels and brianleect/etherscan-labels, pinned | MIT (the datasets); the labels are the explorers' |
| Tron exchange tags | TronScan public API | TronScan's terms |
| Tether USDT freezes | The USDT contracts' own AddedBlackList / RemovedBlackList events on Ethereum and Tron |
Public on-chain data |
| Phishing / scam wallets | ScamSniffer scam-database | GPL-3.0 |
| Phishing / hack wallets | Etherscan Phish/Hack label via dappcenter/etherscan-labels and Forta labelled datasets, pinned | MIT |
Before any commercial use: the Israeli, Japanese and French entries come through OpenSanctions under a non-commercial licence, and the ScamSniffer-derived entries in
threat_labels*.jsoncarry ScamSniffer's GPL-3.0 terms. A commercial deployment needs an OpenSanctions data licence (or the governments' own documents read directly) and a decision on the GPL-derived entries.
The rules that decide what an investigator is told are pure functions over a graph, so the suite needs no API key, no network and no database — and runs in CI on every push.
cd backend && pip install -r requirements-dev.txt && python -m pytestCoverage is deliberately two-sided: every heuristic is tested both for firing on the shape it describes and for staying silent on ordinary activity, because a false accusation is the expensive failure here.
What each test file pins down
| File | What it pins down |
|---|---|
test_pattern_detection.py |
Peel chain and amount split, their thresholds, the exchange exemption, graph tagging |
test_scoring.py |
Each weighted component, band boundaries; patterns and truncation add caveats without moving the number |
test_exchange_matcher.py |
Both label-file shapes, case-insensitive lookup, closest-match preference, degrading to "no attribution" |
test_risk_matcher.py |
All four categories; a mixer ends a trace and keeps ending it whatever else lists it, a sanctioned, frozen or stolen-funds address does not; a government listing outranks an explorer tag; several governments are all named; unknown categories dropped |
test_threat_import.py |
Thief and phishing tags accepted; "hackerspace" charities, AVS operators and hack victims rejected; only mixer pools and routers count, never governance or token contracts |
test_request_letters.py |
The exchange letter names the address and every hop with its hashes; the legal basis is left for the officer; no exchange means no exchange letter; an attribution after a swap is written from the follow-on; the Tether letter lists each freeze; an ETH trace touching no USDT offers none |
test_tron_tags.py |
Only a tag naming a known exchange attributes; treasuries, bridges and scam tags never do; a labelled wallet is never asked about; the last hop is swept; lookups are capped; no key means no lookups; a failed lookup leaves the trace intact; the report says what TronScan named |
test_follow_swaps.py |
A swap into a stablecoin is traced to the exchange from the moment of the swap; transfers before it are excluded; the original trace keeps its asset and score; a time budget covers the follow-on too; a swap into an untraced token is not followed; the switch turns it off; the report prints it |
test_tether_freezes.py |
The replay: a release unfreezes, a re-freeze after a release counts, order within a block follows the log index, Tron addresses convert for the contract call |
test_intl_sanctions_import.py |
The chain named before a free-text address decides where it is filed; a token name alone picks no EVM chain; a wallet named only in lifted seizure orders is left out |
test_ofac_import.py |
Chain assignment from OFAC's own idType; a Bitcoin address is skipped rather than misfiled |
test_graph_builder.py |
All six traversal brakes, both directions, fetch failures, depth stability, the time budget |
test_deposit_inference.py |
The sweep shape fires; one deposit, a wallet that also spends, dust, the seed and unexpanded wallets stay silent |
test_swap_detection.py |
Swap outputs from a real 1inch receipt; native-coin outputs reported as unreadable, never guessed |
test_correlation.py |
Two complaints on one unlabelled wallet cluster; a shared hot wallet, router or screened address never does |
test_internal_transactions.py |
Contract-moved value merged and tagged at exactly one extra request |
test_report.py · test_evidence.py |
Every report section present; the manifest prints; the content hash verifies and breaks on one changed byte |
test_api_budget.py · test_etherscan_cache.py · test_warmup.py |
Cache and pacer behaviour; the warm-up traces every demo and stores no case |
test_tron_client.py · test_nodereal_client.py |
Pagination bounded by records read, not kept |
test_api.py |
Endpoints, per-chain health, label and screening coverage, the ledger |
One Docker image builds the frontend with Node and serves it from FastAPI — one origin, no CORS, no second deployment.
docker build --build-arg LABEL_STORE_REQUIRED=0 -t exchequer .
docker run -p 8000:8000 --env-file backend/.env -v exchequer-data:/data exchequerThe label store comes from a private release and needs the label_store_token build secret; by default a
build without it fails, so a deploy never ships without its labels unnoticed. LABEL_STORE_REQUIRED=0 builds
an image that screens against the label files only.
The same image runs on Render, Railway, Fly or any VM. The live demo runs on Render's free plan from
render.yaml (Dashboard → New → Blueprint), which deploys every push to main. That plan has no disk, so set
DATABASE_URL to a Postgres connection string (a free Neon project works) to keep cases across restarts;
elsewhere, mount a volume at /data and the SQLite store and API cache survive a redeploy. See
docs/operations.md.
Hosting publicly: the free API tiers allow roughly two requests per second across the whole deployment, and one trace makes 12–60 calls, so a public instance suits evaluation, not concurrent use.
Project structure
exchequer/
├── backend/
│ ├── app/
│ │ ├── main.py FastAPI app shell: startup, routers, frontend
│ │ ├── pipeline.py run_trace: graph → screening → attribution → score
│ │ ├── routes/ coverage.py (/health…) · cases.py (/trace) · evidence.py (reports) · casebook.py (/cases)
│ │ ├── config.py chains, assets, label files, environment
│ │ ├── chain_data.py picks the provider for a chain + asset
│ │ ├── etherscan_client.py Etherscan V2 (native, ERC-20, internal)
│ │ ├── nodereal_client.py NodeReal for BNB Smart Chain
│ │ ├── tron_client.py TronGrid for Tron (TRX + TRC-20)
│ │ ├── tron_tags.py TronScan tags read live during Tron traces
│ │ ├── graph_builder.py BFS traversal, both directions, six brakes
│ │ ├── exchange_matcher.py exact-match attribution
│ │ ├── deposit_inference.py probable exchange deposit addresses
│ │ ├── risk_matcher.py sanctions, mixer and stolen-funds screening
│ │ ├── pattern_detection.py peel chain + amount split
│ │ ├── swap_detection.py swaps at DEX routers, from receipts
│ │ ├── correlation.py cross-case convergence
│ │ ├── scoring.py confidence score
│ │ ├── evidence.py hash-and-timestamp of every provider response
│ │ ├── report.py the investigator's report
│ │ ├── request_letters.py draft letters to the exchange and to Tether
│ │ ├── models.py SQLite (SQLAlchemy) case store
│ │ ├── api_budget.py shared pacer + response cache
│ │ └── warmup.py traces the demos at startup
│ ├── data/
│ │ ├── exchange_labels*.json exchange wallets + Bitget/Binance deposit addresses, per chain
│ │ ├── risk_labels*.json OFAC SDN addresses
│ │ ├── intl_sanctions*.json UK, EU, Israel, Japan, France
│ │ ├── frozen_labels*.json addresses Tether has frozen on USDT
│ │ ├── threat_labels*.json mixer pools, hack and phishing wallets
│ │ ├── router_labels*.json DEX routers
│ │ ├── demo_traces.json the DEMO.md traces, as requests
│ │ └── validation/ corpus, snapshot and results of the rule measurement
│ ├── scripts/
│ │ ├── import_ofac_addresses.py OFAC SDN XML → risk_labels
│ │ ├── import_intl_sanctions.py five more governments → intl_sanctions
│ │ ├── import_tether_freezes.py USDT blacklist events → frozen_labels
│ │ ├── import_threat_labels.py mixers, exploiters, scam lists → threat_labels
│ │ ├── import_exchange_labels.py Ethereum exchange wallets (original dataset)
│ │ ├── import_eth_labels.py Ethereum + BSC (newer dataset), --deposits
│ │ ├── seed_bsc_labels.py BSC exchange wallets
│ │ ├── seed_tron_labels.py Tron, from TronScan tags
│ │ ├── seed_router_labels.py DEX routers
│ │ ├── audit_label_contracts.py strips non-custody contracts
│ │ ├── verify_labels.py re-checks the Ethereum file
│ │ ├── build_validation_corpus.py builds the measurement corpus
│ │ ├── validate_patterns.py measures the rules, offline
│ │ ├── scout.py tries live-demo candidates, storing nothing
│ │ ├── prune_cases.py reduces the case store, with a backup
│ │ ├── seed_hosted.py replays the demos against a hosted instance
│ │ └── check_etherscan.py live API smoke test
│ └── tests/ backend tests, no network or keys (560+ with the frontend's)
├── frontend/src/
│ ├── routes/ SignIn.jsx · Citizen.jsx · Home.jsx · TraceView.jsx
│ ├── api/ client · trace · evidence · platform (api.js re-exports them)
│ └── components/ TraceForm · FlowView · GraphView · Present · Ledger · Mark · ExportButton
├── docs/ proposal and research report PDFs, README assets
├── Dockerfile Node builds the UI, FastAPI serves it
├── DEMO.md verified demo traces, with what to say
├── RESEARCH.md every claim, its source, the passage, a verdict
└── JUDGE_QA.md answers to the questions judges ask
Troubleshooting
| Symptom | Fix |
|---|---|
/chains shows Ethereum "ready": false |
backend/.env is missing or has no key. Copy .env.example, add the key, restart. |
| Frontend says "Cannot reach the Exchequer backend" | The backend is not running on port 8000. |
| The UI loads from port 8000 but every request fails | The bundle was built without VITE_API_BASE. Rebuild with VITE_API_BASE="" npm run build. |
http://127.0.0.1:5173 refuses to connect |
Vite binds to IPv6. Use http://localhost:5173. |
| Trace returns 429 | Provider rate limit. Wait a few seconds; reduce the hop count. |
| BSC shows "unavailable" | NODEREAL_API_KEY is not set. Ethereum is unaffected. |
| BSC trace finds nothing on an active address | Its activity may predate the lookback window. Raise NODEREAL_LOOKBACK_BLOCKS. |
| Traces feel slow | Expected on a free key. Check api_budget in /health/detail: climbing rate_limit_penalties means the key is being refused; re-running an address should be instant from cache. |
| A trace returns stale data | Responses are cached for API_CACHE_TTL_SECONDS (a day). Lower it, or delete backend/provider_cache.db. |
Exchange is null on a real address |
The funds may not have reached an exchange within the hop limit, or it is not in the label file. Check GET /exchanges. |
Each significant design choice has a one-page record in docs/adr/: what was decided,
what was turned down and what it costs, from serving the UI and API from one image to never guessing a token's
decimals. For a guided walk through the code, open the repository in VS Code with the
CodeTour extension and start
How a trace works, end to end (.tours/): fifteen stops from the Trace button to the request letter.
- Accounts and roles — officer, supervisor, analyst, auditor; cases belong to a police unit, replacing today's demo mode.
- A tamper-evident audit log — every trace, view, export and letter recorded in a hash chain.
- Hosting in India — a pilot on a university server or an Indian cloud region, with a tested backup drill.
- Bitcoin — a UTXO model, so its own adapter rather than another entry in the chain registry.
- More chains — Etherscan's paid tier unlocks Optimism, Base and Avalanche, and full BNB Chain history, through the code already present.
Attribution identifies an exchange, never a person. Only the exchange can link an address to a customer, on a lawful request.




