Follow stolen funds downstream, and say where the trail actually ends.
The aircraft is long gone. The trail is still there.
Give it an address from an exploit; get back a value-flow graph, the exact amounts, and a classification of every endpoint the money reached — exchange deposit, bridge, mixer, or dead end.
The endpoint classification is the point. Exchanges can freeze deposits, so "3,090 ETH reached an exchange deposit address, 90 minutes after the exploit, here are the transaction hashes" is something an incident responder acts on within the window where acting still helps. A graph on its own is not.
contrail — ethereum
seed 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
outcomes
EXCHANGE — freezable, contact compliance
3,090 ETH → 0xeeee…eeee Example Exchange 3 (deposit)
BRIDGE — continues on another chain
1,040 ETH → 0x2222…2222 Example Bridge
flow
0xaaaa…aaaa
└─ 4,200 ETH → 0xbbbb…bbbb
├─ 2,100 ETH → 0xcccc…cccc
│ └─ 2,090 ETH → 0xeeee…eeee [Example Exchange 3 (deposit) EXCHANGE]
└─ 2,050 ETH → 0xdddd…dddd
├─ 1,040 ETH → 0x2222…2222 [Example Bridge BRIDGE]
└─ 1,000 ETH → 0xeeee…eeee [Example Exchange 3 (deposit) EXCHANGE]
stats 4 expanded · 7 transfers · 6 nodes · 6 edges · depth 3
pruned 1 below share threshold · 0 beyond fan-out cap
Synthetic addresses, for illustration.
npm install
npm run build
cp .env.example .env # add an Alchemy key; the free tier is enough
node dist/src/index.js trace 0x… --depth 5 --to-block 19000000
node dist/src/index.js trace 0x… --json | jq '.edges[] | select(.depth == 1)'
node dist/src/index.js trace 0x… --dot | dot -Tsvg > trace.svgPin --to-block for anything you intend to reproduce. Results for a pinned range are cached permanently and are byte-identical across runs; open-ended traces get a short TTL because the chain moves under them.
Amounts are bigint, never floats. The convenience value field in an indexer response is a JSON number and loses precision above 2^53 — which ordinary ETH amounts exceed when denominated in wei. Raw hex is authoritative; where only the float exists, the amount is reconstructed and marked approximate all the way into the report. A tracer that reports 1000.0000000000001 ETH is not one anyone will trust with an incident.
Internal transfers are load-bearing. Value moved by a contract call rather than a top-level transaction is where a trace goes to die, and attackers route through contracts constantly. On chains whose index does not expose internal transfers, the walk says so in its warnings rather than quietly returning a short graph that looks like a dead end.
The dust threshold is proportional and per-asset. An outgoing transfer is followed only if it carries at least --min-share of the value of that asset which arrived at that node. A flat floor denominated in ETH cannot be correct for both a nine-figure bridge hop and a small drain, and spraying dust to poison graph analysis is a known technique. Expressing the threshold relative to what a node actually received scales automatically — and it is the same proportional accounting the haircut model formalises in phase 3.
Bounds are disclosed, never silent. Every walk is bounded by depth, fan-out, and a global node budget. What each bound discarded is counted and printed. A bounded walk that does not say what it dropped reads as an exhaustive one, which is the easiest way for a tool like this to mislead the person using it.
"We stopped looking" never renders like "the money arrived." Each node records why its branch ended — terminal label, depth limit, node budget, or genuinely no outflows — and the report keeps those visually distinct. Conflating them turns an unexplored branch into a false negative.
data/labels.json is empty by design, and the tool warns when it is.
Terminal classification is only as good as the label set, and a wrong label does not produce a missing conclusion — it produces a confident false one, asserting funds reached an exchange that never saw them. So the loader, schema, and terminal semantics are complete and tested, and the dataset is left to be populated from sources you can cite: Etherscan's public label pages, the OFAC SDN list, the bridges' and mixers' own published addresses.
See data/labels.example.json for the schema. Every entry takes a source field. Do not populate it from memory.
Phase 1 of five. What works today: transfer ingestion with internal transfers, an on-disk cache, the bounded breadth-first walk with per-asset proportional thresholding, label-based terminal classification, and text/JSON/DOT reporting.
| Phase | Scope |
|---|---|
| 1 ✅ | Walker, cache, labels, reporting |
| 2 | Swap collapse, dust and spam filtering, richer entity resolution |
| 3 | Taint attribution — FIFO, haircut, and poison behind a flag; price data |
| 4 | Bridge continuation across chains, mixer-boundary termination, timeline output |
| 5 | Validation against documented incidents; published precision and recall |
- Swaps are not yet collapsed. Funds routed through a DEX router currently follow the router, which is both wrong and expensive. Phase 2.
- No cross-chain continuation. A bridge is recorded as a terminal, not followed. Phase 4.
- No price data, so the fan-out cap ranks mixed-asset edges by normalised magnitude — a heuristic used to decide what to look at first, never reported as a comparison. Phase 3.
- Single provider. The walker depends on
TransferSource, not on Alchemy, so a self-hosted backend can be swapped in without touching the graph logic — but only one implementation ships today.
Mixers break the link by design. Where phase 4 handles them, the correct behaviour is to terminate and record the deposits — timing and denomination correlation is probabilistic, and this tool will say so rather than emitting a confident-looking edge.
npm test # 54 tests, no network, no API key
npm run typecheckThe suite runs entirely against in-memory doubles. The bounds are the part most likely to break and they need graphs built to hit specific edges of the rules — shapes no captured mainnet response happens to contain. A test suite that needs credentials is one that stops running.
MIT