Skip to content

Repository files navigation

contrail

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.

Node Runtime dependencies Tests


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.

Usage

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.svg

Pin --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.

Design notes

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.

Labels

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.

Status

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

Known limitations

  • 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.

Tests

npm test        # 54 tests, no network, no API key
npm run typecheck

The 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.

License

MIT

About

On-chain fund-flow tracer: follows stolen funds downstream through hops, swaps and bridges, and classifies where the trail ends.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages