tornado-demix 2.13.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tornado_demix-2.13.0/LICENSE +21 -0
- tornado_demix-2.13.0/PKG-INFO +460 -0
- tornado_demix-2.13.0/README.md +427 -0
- tornado_demix-2.13.0/pyproject.toml +67 -0
- tornado_demix-2.13.0/setup.cfg +4 -0
- tornado_demix-2.13.0/tests/test_attribution.py +203 -0
- tornado_demix-2.13.0/tests/test_broadcaster.py +87 -0
- tornado_demix-2.13.0/tests/test_characterize.py +185 -0
- tornado_demix-2.13.0/tests/test_cli.py +488 -0
- tornado_demix-2.13.0/tests/test_cluster.py +361 -0
- tornado_demix-2.13.0/tests/test_cluster_gate.py +199 -0
- tornado_demix-2.13.0/tests/test_cluster_tokens.py +109 -0
- tornado_demix-2.13.0/tests/test_config.py +200 -0
- tornado_demix-2.13.0/tests/test_constants.py +25 -0
- tornado_demix-2.13.0/tests/test_core_edges.py +278 -0
- tornado_demix-2.13.0/tests/test_demix.py +853 -0
- tornado_demix-2.13.0/tests/test_edge_cases.py +389 -0
- tornado_demix-2.13.0/tests/test_encoding.py +139 -0
- tornado_demix-2.13.0/tests/test_ens_labels.py +110 -0
- tornado_demix-2.13.0/tests/test_errors.py +227 -0
- tornado_demix-2.13.0/tests/test_etherscan.py +536 -0
- tornado_demix-2.13.0/tests/test_events.py +114 -0
- tornado_demix-2.13.0/tests/test_graph.py +209 -0
- tornado_demix-2.13.0/tests/test_groups.py +82 -0
- tornado_demix-2.13.0/tests/test_multi.py +369 -0
- tornado_demix-2.13.0/tests/test_networks.py +180 -0
- tornado_demix-2.13.0/tests/test_packaging.py +75 -0
- tornado_demix-2.13.0/tests/test_placebo.py +254 -0
- tornado_demix-2.13.0/tests/test_pools.py +161 -0
- tornado_demix-2.13.0/tests/test_properties.py +367 -0
- tornado_demix-2.13.0/tests/test_registry_validation.py +106 -0
- tornado_demix-2.13.0/tests/test_report.py +950 -0
- tornado_demix-2.13.0/tests/test_rpc.py +257 -0
- tornado_demix-2.13.0/tests/test_scoring.py +394 -0
- tornado_demix-2.13.0/tests/test_scoring_independence.py +222 -0
- tornado_demix-2.13.0/tests/test_sensitivity.py +70 -0
- tornado_demix-2.13.0/tests/test_shipped_registry.py +198 -0
- tornado_demix-2.13.0/tests/test_simulate.py +54 -0
- tornado_demix-2.13.0/tests/test_thresholds.py +118 -0
- tornado_demix-2.13.0/tests/test_trace.py +123 -0
- tornado_demix-2.13.0/tests/test_verify_pools.py +272 -0
- tornado_demix-2.13.0/tests/test_webui.py +484 -0
- tornado_demix-2.13.0/tests/test_windows.py +202 -0
- tornado_demix-2.13.0/tornado_demix/__init__.py +25 -0
- tornado_demix-2.13.0/tornado_demix/__main__.py +6 -0
- tornado_demix-2.13.0/tornado_demix/attribution.py +113 -0
- tornado_demix-2.13.0/tornado_demix/characterize.py +165 -0
- tornado_demix-2.13.0/tornado_demix/cli.py +698 -0
- tornado_demix-2.13.0/tornado_demix/cluster.py +243 -0
- tornado_demix-2.13.0/tornado_demix/config.py +165 -0
- tornado_demix-2.13.0/tornado_demix/constants.py +37 -0
- tornado_demix-2.13.0/tornado_demix/data/networks.csv +56 -0
- tornado_demix-2.13.0/tornado_demix/demix.py +770 -0
- tornado_demix-2.13.0/tornado_demix/errors.py +39 -0
- tornado_demix-2.13.0/tornado_demix/etherscan.py +525 -0
- tornado_demix-2.13.0/tornado_demix/events.py +101 -0
- tornado_demix-2.13.0/tornado_demix/graph.py +121 -0
- tornado_demix-2.13.0/tornado_demix/groups.py +98 -0
- tornado_demix-2.13.0/tornado_demix/heuristics.py +603 -0
- tornado_demix-2.13.0/tornado_demix/multi.py +356 -0
- tornado_demix-2.13.0/tornado_demix/networks.py +343 -0
- tornado_demix-2.13.0/tornado_demix/placebo.py +163 -0
- tornado_demix-2.13.0/tornado_demix/pools.py +94 -0
- tornado_demix-2.13.0/tornado_demix/relayer.py +150 -0
- tornado_demix-2.13.0/tornado_demix/report.py +38 -0
- tornado_demix-2.13.0/tornado_demix/report_csv.py +488 -0
- tornado_demix-2.13.0/tornado_demix/report_html.py +1447 -0
- tornado_demix-2.13.0/tornado_demix/report_json.py +107 -0
- tornado_demix-2.13.0/tornado_demix/rpc.py +182 -0
- tornado_demix-2.13.0/tornado_demix/trace.py +191 -0
- tornado_demix-2.13.0/tornado_demix.egg-info/PKG-INFO +460 -0
- tornado_demix-2.13.0/tornado_demix.egg-info/SOURCES.txt +74 -0
- tornado_demix-2.13.0/tornado_demix.egg-info/dependency_links.txt +1 -0
- tornado_demix-2.13.0/tornado_demix.egg-info/entry_points.txt +2 -0
- tornado_demix-2.13.0/tornado_demix.egg-info/requires.txt +14 -0
- tornado_demix-2.13.0/tornado_demix.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tornado-demix contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tornado-demix
|
|
3
|
+
Version: 2.13.0
|
|
4
|
+
Summary: Tornado Cash forensics: evidence-banded exit leads, exit groups, operator clusters and FIFO tracing on 8 EVM chains, with a placebo check on real data.
|
|
5
|
+
Author: tornado-demix contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://prettydeath.github.io/TornadoCash-demixer/
|
|
8
|
+
Project-URL: Repository, https://github.com/prettydeath/TornadoCash-demixer
|
|
9
|
+
Project-URL: Documentation, https://prettydeath.github.io/TornadoCash-demixer/
|
|
10
|
+
Project-URL: Issues, https://github.com/prettydeath/TornadoCash-demixer/issues
|
|
11
|
+
Keywords: ethereum,tornado-cash,blockchain,forensics,demixing,etherscan,osint,aml,deanonymization,evm
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Intended Audience :: Information Technology
|
|
16
|
+
Classifier: Topic :: Security :: Cryptography
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: requests<3,>=2.32.4
|
|
22
|
+
Provides-Extra: web
|
|
23
|
+
Requires-Dist: flask<4,>=3.0; extra == "web"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest<10,>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff<0.17,>=0.6; extra == "dev"
|
|
27
|
+
Requires-Dist: hypothesis<7,>=6.100; extra == "dev"
|
|
28
|
+
Requires-Dist: pycryptodome<4,>=3.20; extra == "dev"
|
|
29
|
+
Provides-Extra: labels
|
|
30
|
+
Requires-Dist: pycryptodome<4,>=3.20; extra == "labels"
|
|
31
|
+
Requires-Dist: eth-abi<6,>=5; extra == "labels"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# TornadoCash Demixer
|
|
35
|
+
|
|
36
|
+
Probabilistic demixing of Tornado Cash deposits using only public on-chain data.
|
|
37
|
+
The toolkit links a depositor wallet to likely withdrawal addresses and reports
|
|
38
|
+
each candidate with an evidence band and the evidence behind it. A band needs a
|
|
39
|
+
linked address (a direct counterparty, or a withdrawal sent by the depositor's
|
|
40
|
+
side): on real depositors amount+timing and gas-price matches turn up as often by
|
|
41
|
+
chance as for real exits, so they only corroborate. It also groups exits paid out
|
|
42
|
+
together, links wallets of one operator and follows withdrawn funds forward.
|
|
43
|
+
|
|
44
|
+
Output is a set of leads for further investigation, not proof. Tornado Cash breaks
|
|
45
|
+
the deposit-withdrawal link cryptographically; the tool looks for behavioural
|
|
46
|
+
traces users leave around it.
|
|
47
|
+
|
|
48
|
+
## What it does
|
|
49
|
+
|
|
50
|
+
- Finds a wallet's deposits into the 55 registered pools on 8 EVM networks
|
|
51
|
+
(native transfers, internal transfers from contract wallets, ERC-20 transfers,
|
|
52
|
+
direct or through a router).
|
|
53
|
+
- Groups deposits into vouchers (one pool, one session, `--gap-hours`, default 24)
|
|
54
|
+
and gives each voucher its own withdrawal search window.
|
|
55
|
+
- Reads the pools' `Withdrawal` events in those windows and counts how many
|
|
56
|
+
withdrawals each recipient received. A recipient that received exactly *N*
|
|
57
|
+
withdrawals matches a voucher of *N* notes.
|
|
58
|
+
- Scores every recipient: a count match weighted by how much of the recipient
|
|
59
|
+
field it eliminates (`disc`; counted only at `disc >= 0.5` in a field of at
|
|
60
|
+
least 5 recipients), self-relayed withdrawals, reuse of a deposit gas price
|
|
61
|
+
(only in blocks without an EIP-1559 base fee), and direct transactions with the
|
|
62
|
+
depositor (not counted when the counterparty is a contract).
|
|
63
|
+
- Reports a band per candidate: `strong` (a linked address plus another evidence
|
|
64
|
+
family), `moderate` (a linked address alone), `weak` (amount+timing and/or gas
|
|
65
|
+
price without a linked address — chance-level on real depositors, see the
|
|
66
|
+
placebo test in [EVALUATION.md](https://github.com/prettydeath/TornadoCash-demixer/blob/main/docs/EVALUATION.md)), with the evidence behind it: every family, whether it holds,
|
|
67
|
+
and the numbers (share of the recipient field with the same count, `disc`,
|
|
68
|
+
gas price). Candidates are ordered by band, then by an uncalibrated noisy-OR
|
|
69
|
+
score taken across evidence families (within a family only the strongest
|
|
70
|
+
signal counts). Reports state the analysis parameters and the block ranges read.
|
|
71
|
+
- Marks the top candidates that had at most a day of history before their first
|
|
72
|
+
withdrawal (fresh, disposable exits; shown as context, never scored).
|
|
73
|
+
- Groups exits withdrawn together in repeated bursts, the payout rhythm of an
|
|
74
|
+
operator that pooled several deposits; a group is listed when a corroborated
|
|
75
|
+
candidate or an exit you already know (`--known-exit`) anchors it.
|
|
76
|
+
- Marks a linked exit that withdrew within 72 hours of the deposit (early exit;
|
|
77
|
+
context, never scored).
|
|
78
|
+
- Optionally runs a placebo check (`--placebo`, or the web UI option): the same
|
|
79
|
+
analysis on a decoy window that ends before the wallet's first deposit, so the
|
|
80
|
+
report shows how many leads chance alone produces for this wallet.
|
|
81
|
+
- Opens the HTML report with a case overview and a flow diagram: depositor, pools,
|
|
82
|
+
candidate exits coloured by band.
|
|
83
|
+
- For several wallets: shared candidates, denomination-profile matches,
|
|
84
|
+
cross-wallet consolidators graded against the window-overlap artefact, deposit
|
|
85
|
+
synchronicity, shared private funders, and operator clusters (union-find on
|
|
86
|
+
discriminating edges only).
|
|
87
|
+
- Traces split exits one hop forward to a common collection address (`cluster`).
|
|
88
|
+
- Follows withdrawn funds forward over several hops with FIFO attribution and
|
|
89
|
+
DEX-swap resolution, up to a labelled service (`trace`).
|
|
90
|
+
- Characterises an exit candidate: activity, pool inflows, next hops, a disposable
|
|
91
|
+
flag, optional address labels (`characterize`).
|
|
92
|
+
|
|
93
|
+
The method, thresholds and the reasoning behind them are in
|
|
94
|
+
[docs/METHODOLOGY.md](https://github.com/prettydeath/TornadoCash-demixer/blob/main/docs/METHODOLOGY.md).
|
|
95
|
+
|
|
96
|
+
## Components
|
|
97
|
+
|
|
98
|
+
| Module | Role |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `tornado_demix/demix.py` | deposit detection, vouchers, search windows, withdrawal counting |
|
|
101
|
+
| `tornado_demix/heuristics.py` | signals, `count_discrimination`, noisy-OR score, bands |
|
|
102
|
+
| `tornado_demix/multi.py`, `graph.py` | multi-wallet correlation, consolidator grading, clustering |
|
|
103
|
+
| `tornado_demix/cluster.py` | one-hop tracing of split exits, on-disk cache |
|
|
104
|
+
| `tornado_demix/groups.py` | exit groups from joint withdrawal bursts |
|
|
105
|
+
| `tornado_demix/trace.py` | multi-hop FIFO tracing of withdrawn funds with swap resolution |
|
|
106
|
+
| `tornado_demix/characterize.py`, `attribution.py` | recipient-side analysis, address labels |
|
|
107
|
+
| `tornado_demix/etherscan.py`, `rpc.py`, `events.py` | explorer client, JSON-RPC probes, event decoding |
|
|
108
|
+
| `tornado_demix/networks.py`, `pools.py`, `data/networks.csv` | verified pool registry |
|
|
109
|
+
| `tornado_demix/report.py` (`report_csv`, `report_html`, `report_json`) | CSV, self-contained HTML and JSON reports |
|
|
110
|
+
| `tornado_demix/cli.py`, `webui/app.py` | command line and local Flask UI over the same functions |
|
|
111
|
+
| `tools/verify_pools.py` | on-chain verification of registry entries |
|
|
112
|
+
| `tools/calibrate.py` | precision/recall against confirmed cases (needs private data) |
|
|
113
|
+
| `tools/sensitivity.py` | ranking stability of saved results under perturbed weights |
|
|
114
|
+
| `tools/simulate.py` | synthetic benchmark: ablation, negative controls, counter-measures ([results](https://github.com/prettydeath/TornadoCash-demixer/blob/main/docs/EVALUATION.md)) |
|
|
115
|
+
| `tools/real_cases.py` | the method on two public laundering cases (KuCoin, Harmony) |
|
|
116
|
+
| `tools/ens_labels.py`, `tools/evaluate_labels.py` | an ENS-labelled set of depositor/exit pairs and the evaluation on it ([results](https://github.com/prettydeath/TornadoCash-demixer/blob/main/docs/EVALUATION.md#a-labelled-set-from-ens)) |
|
|
117
|
+
| `tools/wang_baseline.py` | the ENS set scored under the Wang et al. (2023) protocol, with their H2/H3/H5 re-implemented |
|
|
118
|
+
| `tools/placebo_eval.py`, `tools/placebo_windows.py` | placebo (target-decoy) test on random real depositors, per evidence family and exit window ([results](https://github.com/prettydeath/TornadoCash-demixer/blob/main/docs/EVALUATION.md#placebo-test-on-real-depositors)) |
|
|
119
|
+
| `tools/review_sample.py` | a blinded manual-review sheet of leads and hidden controls, and its scoring |
|
|
120
|
+
|
|
121
|
+
## Requirements
|
|
122
|
+
|
|
123
|
+
- Python 3.9 or newer. Runtime dependency: `requests`. The web UI adds `flask`.
|
|
124
|
+
- An Etherscan V2 API key. One key covers Ethereum, BNB Smart Chain, Polygon,
|
|
125
|
+
Arbitrum, Base and Gnosis. Avalanche (Routescan) and Optimism (Blockscout) need
|
|
126
|
+
no key. On the free Etherscan plan the `getLogs` endpoint was not
|
|
127
|
+
available for BNB Smart Chain, Gnosis and Base at the time of writing.
|
|
128
|
+
|
|
129
|
+
## Installation
|
|
130
|
+
|
|
131
|
+
From a checkout of the repository:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
python -m pip install . # core: the tornado-demix command and the pool registry
|
|
135
|
+
python -m pip install ".[web]" # plus Flask; run the UI from a checkout
|
|
136
|
+
python -m pip install -e ".[dev]" # for development: tests and ruff
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Dependencies and their version bounds are declared in `pyproject.toml`.
|
|
140
|
+
|
|
141
|
+
## Configuration
|
|
142
|
+
|
|
143
|
+
Copy `config/api.csv.example` to `config/api.csv` and put your key in it, or set
|
|
144
|
+
`ETHERSCAN_API_KEY`:
|
|
145
|
+
|
|
146
|
+
```csv
|
|
147
|
+
service,api_key
|
|
148
|
+
etherscan,YOUR_KEY
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Config files are looked up in `$TORNADO_DEMIX_CONFIG` (only there, when set),
|
|
152
|
+
then `./config/`, then the repository's `config/`. One directory per case keeps
|
|
153
|
+
case inputs apart. `config/wallets.csv` (column `address`) can replace addresses
|
|
154
|
+
on the command line. `config/networks.csv`, if present, overrides the bundled
|
|
155
|
+
registry.
|
|
156
|
+
|
|
157
|
+
### Address labels (optional)
|
|
158
|
+
|
|
159
|
+
Labels are not bundled. The tool reads one `<network>.csv` per chain
|
|
160
|
+
(`ethereum.csv`, `bsc.csv`, ...) with an `address` column and optionally `entity`,
|
|
161
|
+
`label`, `category`, `source`, `confidence`, from the directory given by
|
|
162
|
+
`--attribution-dir` or `TORNADO_DEMIX_ATTRIBUTION`.
|
|
163
|
+
|
|
164
|
+
A ready dataset in this format is
|
|
165
|
+
[prettydeath/wallet-attribution](https://github.com/prettydeath/wallet-attribution):
|
|
166
|
+
about 115,000 addresses (exchanges, OFAC-sanctioned entities, scams, mixers,
|
|
167
|
+
bridges, DeFi) on 47 networks, covering all eight networks of this tool.
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
git clone https://github.com/prettydeath/wallet-attribution
|
|
171
|
+
python -m tornado_demix characterize <address> --attribution-dir wallet-attribution/data
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
It is aggregated from public sources: the OFAC SDN address list (US government
|
|
175
|
+
public record, via 0xB10C/ofac-sanctioned-digital-currency-addresses),
|
|
176
|
+
dawsbot/eth-labels, tradezon/cex-list, MyEtherWallet/ethereum-lists and the
|
|
177
|
+
proof-of-reserves wallets in DefiLlama-Adapters. The dataset's code is MIT; each
|
|
178
|
+
label keeps the license of its upstream source, so check those before
|
|
179
|
+
redistributing labels or using them commercially.
|
|
180
|
+
|
|
181
|
+
Labels are only displayed. They never change a score or a band, and a label is
|
|
182
|
+
a claim by its source, not a finding of this tool.
|
|
183
|
+
|
|
184
|
+
## Command line
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
# one wallet: CSV files, an HTML report and the full result as JSON
|
|
188
|
+
python -m tornado_demix demix 0x019b5bb2051797e33f726d0e7a8cb9b9c2003ac2 \
|
|
189
|
+
--network ethereum --out-dir out/demix --report out/demix.html --json out/demix.json
|
|
190
|
+
|
|
191
|
+
# narrow the search to 6 hours after each voucher's last deposit
|
|
192
|
+
python -m tornado_demix demix <wallet> --exit-window 6
|
|
193
|
+
|
|
194
|
+
# list the exit group of an exit already known from the investigation
|
|
195
|
+
python -m tornado_demix demix <wallet> --known-exit <exit address>
|
|
196
|
+
|
|
197
|
+
# also run the same analysis on a decoy window before the first deposit
|
|
198
|
+
python -m tornado_demix demix <wallet> --placebo
|
|
199
|
+
|
|
200
|
+
# a 7-day exit window, as seen in public laundering cases
|
|
201
|
+
python -m tornado_demix demix <wallet> --rapid
|
|
202
|
+
|
|
203
|
+
# several wallets
|
|
204
|
+
python -m tornado_demix multi <wallet1> <wallet2> --report out/multi.html
|
|
205
|
+
python -m tornado_demix multi --wallets-csv config/wallets.csv
|
|
206
|
+
|
|
207
|
+
# split exits, one hop forward (resumable cache in --cache-dir)
|
|
208
|
+
python -m tornado_demix cluster <wallet> --cache-dir .cache/case-42
|
|
209
|
+
|
|
210
|
+
# describe an exit candidate
|
|
211
|
+
python -m tornado_demix characterize <address> --attribution-dir path/to/labels
|
|
212
|
+
|
|
213
|
+
# follow 10 ETH withdrawn to <address> in block 12000000 over up to 4 hops
|
|
214
|
+
python -m tornado_demix trace <address> --amount 10 --start-block 12000000 --report out/trace.html
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`python -m tornado_demix <command> --help` lists every option. A configuration or
|
|
218
|
+
provider error exits with a non-zero status and a one-line message. Progress goes
|
|
219
|
+
to stderr; the summary to stdout.
|
|
220
|
+
|
|
221
|
+
## Web UI
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
python webui/app.py # http://127.0.0.1:5000
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The form runs the same five analyses. For a demix run it shows the strongest
|
|
228
|
+
band, the ranked candidates with their band and score, and for each candidate an
|
|
229
|
+
**Evidence** panel listing every evidence family that was checked, whether it
|
|
230
|
+
holds, and the numbers behind it. **Analysis parameters** lists the assumptions
|
|
231
|
+
(voucher gap, window, thresholds) and the block ranges read. The CSV table, the
|
|
232
|
+
HTML report and the full JSON result can be downloaded.
|
|
233
|
+
|
|
234
|
+
For `trace`, enter one exit address, the amount to follow and optionally the
|
|
235
|
+
block the funds arrived in, an ERC-20 token and the number of hops (up to 8). The
|
|
236
|
+
page lists where the traced funds stop and why (labelled address, hop limit, a
|
|
237
|
+
contract that paid nothing back, not moved on) and every edge with its attributed
|
|
238
|
+
amount, swaps included; the HTML report, CSV and JSON can be downloaded.
|
|
239
|
+
|
|
240
|
+

|
|
241
|
+
*A demix run on Avalanche: two vouchers, a `strong` candidate (count match plus a direct transaction with the depositor) and the evidence panel listing every family that was checked.*
|
|
242
|
+
|
|
243
|
+
The UI binds to localhost and has no authentication (forms carry a CSRF token); see
|
|
244
|
+
[SECURITY.md](https://github.com/prettydeath/TornadoCash-demixer/blob/main/SECURITY.md).
|
|
245
|
+
|
|
246
|
+
## Documented cases
|
|
247
|
+
|
|
248
|
+
Five public cases with an external source of truth. Each can be re-run from the
|
|
249
|
+
addresses shown (an Etherscan API key is enough; the Ronin labels need an
|
|
250
|
+
attribution set). The screenshots are from the web UI; the CLI prints the same
|
|
251
|
+
results. A new run can show slightly different counts as the chain grows and open
|
|
252
|
+
search windows are clamped to the current block.
|
|
253
|
+
|
|
254
|
+
| # | Case | Analysis | What it shows | Source of truth |
|
|
255
|
+
|---|---|---|---|---|
|
|
256
|
+
| 1 | Ronin Bridge 2022 | `characterize` | OFAC-labelled exploiter, bridge and USDC calls, 12,595 ETH to a second sanctioned address | [OFAC, 14 Apr 2022](https://ofac.treasury.gov/recent-actions/20220414) |
|
|
257
|
+
| 2 | Wintermute 2022 | `characterize` | one 9.9435 ETH inflow from the 10 ETH pool; a disposable address | [Merkle Science](https://www.merklescience.com/blog/hack-track-analysis-of-wintermute-attack) |
|
|
258
|
+
| 3 | Beanstalk 2022 | `demix --exit-window 24` | 271 deposits in three hours; no candidate above `weak` | [Merkle Science](https://www.merklescience.com/blog/hack-track-analysis-of-beanstalk-flash-loan-attack) |
|
|
259
|
+
| 4 | KuCoin 2020 | `demix` | 6 of 7 exits found through the linked withdrawal sender | [tayvano/lazarus-bluenoroff-research](https://github.com/tayvano/lazarus-bluenoroff-research) |
|
|
260
|
+
| 5 | Harmony 2022 | `demix --known-exit`, `multi`, `trace` | an exit group from one known exit; 14 depositors in 5 funder clusters; a three-hop trace | [tayvano/lazarus-bluenoroff-research](https://github.com/tayvano/lazarus-bluenoroff-research) |
|
|
261
|
+
|
|
262
|
+
Attributing the Ronin, KuCoin and Harmony thefts to Lazarus is the conclusion of
|
|
263
|
+
government and industry investigators, not of on-chain analysis; the identity of
|
|
264
|
+
the Wintermute and Beanstalk attackers is not established.
|
|
265
|
+
|
|
266
|
+
### 1. Ronin Bridge exploiter
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
python -m tornado_demix characterize 0x098B716B8Aaf21512996dC57EB0615e2383E2f96
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The exploiter received nothing from a Tornado pool; `characterize` shows where
|
|
273
|
+
its funds went next. With the attribution set loaded, the address itself and the
|
|
274
|
+
12,595.3 ETH destination are labelled OFAC-sanctioned, and the contract calls go
|
|
275
|
+
to Circle: USDC and the Ronin Bridge. Without labels the same hops are shown
|
|
276
|
+
unlabelled.
|
|
277
|
+
|
|
278
|
+

|
|
279
|
+
*`characterize` on the Ronin exploiter: attribution labels, no pool inflows, and the dominant next hops with the 12,595.3 ETH transfer to a second sanctioned address.*
|
|
280
|
+
|
|
281
|
+
### 2. Wintermute attacker
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
python -m tornado_demix characterize 0xe74b28c2eAe8679e3cCc3a94d5d0dE83CCB84705
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
One inflow of 9.9435 ETH from the 10 ETH pool on the day of the attack
|
|
288
|
+
(2022-09-20), the first activity of the address: it is flagged as disposable, the
|
|
289
|
+
pattern of 98.6 % of laundering exits in the MixLaunder cases.
|
|
290
|
+
|
|
291
|
+

|
|
292
|
+
*`characterize` on the Wintermute attacker: one pool inflow, classified as a possible personal exit and flagged disposable.*
|
|
293
|
+
|
|
294
|
+
### 3. Beanstalk attacker
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
python -m tornado_demix demix 0x1c5dCdd006EA78a7E4783f9e6021C32935a10fb4 --exit-window 24
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
271 deposits (247 x 100, 14 x 10, 9 x 1 and 1 x 0.1 ETH) within about three
|
|
301
|
+
hours. With a 24-hour exit window the count match leaves one `weak` candidate and
|
|
302
|
+
nothing corroborated: a careful operator leaves no lead, and the tool says so
|
|
303
|
+
rather than naming an unrelated address.
|
|
304
|
+
|
|
305
|
+

|
|
306
|
+
*`demix` on the Beanstalk attacker: four vouchers, 271 notes, and a single weak count-match candidate.*
|
|
307
|
+
|
|
308
|
+
### 4. KuCoin hack (2020)
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
python -m tornado_demix demix 0x820a7a97dd146fd97f79881afdf4767624973368
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
The attacker called `withdraw()` itself from an address investigators attribute
|
|
315
|
+
to it: 128 withdrawals to 7 exits. No exit received a voucher-sized count (each
|
|
316
|
+
got 11-29 notes against vouchers of 24 and 30 notes), so the count match finds nothing. The
|
|
317
|
+
depositor had transacted with that caller, so every withdrawal it sent marks its
|
|
318
|
+
recipient (`linked_sender`): 6 candidates, all 6 true exits; the seventh
|
|
319
|
+
received its withdrawals months later, outside the window. The report opens with
|
|
320
|
+
a flow diagram from the depositor through the pool to the candidates.
|
|
321
|
+
|
|
322
|
+

|
|
323
|
+
*`demix` on a KuCoin depositor: six moderate candidates, each an exit that received withdrawals sent by the depositor's counterparty; the evidence panel shows which families hold.*
|
|
324
|
+
|
|
325
|
+

|
|
326
|
+
*The case overview of the HTML report: depositor, pool and the six candidates, coloured by band.*
|
|
327
|
+
|
|
328
|
+
With `--placebo` the same analysis also runs on a decoy window that ends a day
|
|
329
|
+
before the first deposit, where no withdrawal can spend this depositor's notes:
|
|
330
|
+
|
|
331
|
+

|
|
332
|
+
*The placebo check: six `moderate` leads in the real window, none in the decoy window; the one decoy candidate is `weak`.*
|
|
333
|
+
|
|
334
|
+
### 5. Harmony Bridge hack (2022)
|
|
335
|
+
|
|
336
|
+
Investigators listed 14 depositors and 55 withdrawal addresses. The attacker
|
|
337
|
+
pooled 857 notes of the 100 ETH pool and paid them out mostly six at a time, so
|
|
338
|
+
the count match finds no exit and no false one. Three other analyses do.
|
|
339
|
+
|
|
340
|
+
**Exit groups.** Starting from one exit known from the investigation, `demix`
|
|
341
|
+
lists the recipients paid out in the same bursts:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
python -m tornado_demix demix 0xe71d5fa89d1086d5c3b0ab03eeee2483d2d5ca97 \
|
|
345
|
+
--known-exit 0x0562ddf7ea5ab56728852eea2eacab61c4b78a1a
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+

|
|
349
|
+
*An exit group anchored by one known exit: 22 addresses paid out in joint bursts, 20 of them on the investigators' list. Across all depositors, one known exit gives 23 addresses on average, 73 % of them on the list.*
|
|
350
|
+
|
|
351
|
+
**Shared funders.** Correlating the 14 depositors links all of them through five
|
|
352
|
+
immediate funders, each of which was paid directly by the bridge exploiter
|
|
353
|
+
`0x0d04...ded00`; a sixth, busy funder is left out:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
python -m tornado_demix multi --wallets-csv docs/cases/harmony_depositors.csv
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+

|
|
360
|
+
*`multi` on the 14 Harmony depositors: operator clusters linked by a shared funder, and the funders themselves.*
|
|
361
|
+
|
|
362
|
+
**Trace.** From an exit, `trace` follows the 100 ETH it received forward:
|
|
363
|
+
|
|
364
|
+
```bash
|
|
365
|
+
python -m tornado_demix trace 0x04bca8fa79f36749fa605597e9c9f6788c126944 --amount 100 --max-hops 3
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+

|
|
369
|
+
*`trace` from a Harmony exit: the 100 ETH moves through two intermediate addresses; the trace stops at the hop limit.*
|
|
370
|
+
|
|
371
|
+
The method on both public cases, with the numbers behind each claim, is in
|
|
372
|
+
[docs/EVALUATION.md](https://github.com/prettydeath/TornadoCash-demixer/blob/main/docs/EVALUATION.md); `python tools/real_cases.py` repeats it.
|
|
373
|
+
|
|
374
|
+
## Limitations
|
|
375
|
+
|
|
376
|
+
- Leads, not proof. The weights and `MIN_COUNT_DISCRIMINATION = 0.5` are expert
|
|
377
|
+
judgements; the score has not been calibrated on cases with known outcomes.
|
|
378
|
+
The band does not depend on the weights at all; they only order candidates
|
|
379
|
+
within a band (check a saved result with `tools/sensitivity.py`). How each
|
|
380
|
+
component behaves on generated data with a known answer is in
|
|
381
|
+
[docs/EVALUATION.md](https://github.com/prettydeath/TornadoCash-demixer/blob/main/docs/EVALUATION.md).
|
|
382
|
+
- On real depositors the count match, self-relay and gas-price reuse do not beat
|
|
383
|
+
chance: a placebo test on 152 random Ethereum depositors found as many such
|
|
384
|
+
leads in decoy windows before the first deposit as in the real windows, at every
|
|
385
|
+
window from 6 hours to 30 days. Only a linked address (a direct counterparty, or
|
|
386
|
+
a withdrawal sent by the depositor's side) stood above chance (30 against 7 over
|
|
387
|
+
30 days, 19 against 1 within 72 hours), so only it makes a `moderate` or `strong`
|
|
388
|
+
band ([details](https://github.com/prettydeath/TornadoCash-demixer/blob/main/docs/EVALUATION.md#placebo-test-on-real-depositors)).
|
|
389
|
+
- On 31 depositor/exit pairs labelled through ENS (2019-2026), demix found 16 of
|
|
390
|
+
the 21 pairs inside its window, all through a direct transaction between the
|
|
391
|
+
two addresses, which the label sees as well; without that signal it found one.
|
|
392
|
+
Most labelled depositors made single-note deposits, which the count match
|
|
393
|
+
cannot narrow ([details](https://github.com/prettydeath/TornadoCash-demixer/blob/main/docs/EVALUATION.md#a-labelled-set-from-ens)).
|
|
394
|
+
- Single-note vouchers cannot be narrowed by count: every recipient in the
|
|
395
|
+
window has count 1.
|
|
396
|
+
- Busy pools and long windows produce many equal counts. `--exit-window` trades
|
|
397
|
+
recall for discrimination.
|
|
398
|
+
- A careful user defeats the method: relayers, long delays, split withdrawals
|
|
399
|
+
to fresh addresses. The result is then "no lead", or weak leads on unrelated
|
|
400
|
+
addresses that happen to share the voucher count, never a `moderate` or `strong` one.
|
|
401
|
+
On the public KuCoin and Harmony laundering cases the count match found no exit
|
|
402
|
+
(every exit collected notes of several deposits); in KuCoin the linked withdrawal
|
|
403
|
+
sender found 6 of 7 exits for one depositor ([docs/EVALUATION.md](https://github.com/prettydeath/TornadoCash-demixer/blob/main/docs/EVALUATION.md)).
|
|
404
|
+
- Only Tornado Cash pools in the registry are covered. Bridges, other mixers and
|
|
405
|
+
cross-chain hops are not followed; `cluster` follows one hop only, and `trace`
|
|
406
|
+
follows funds by FIFO attribution, a convention that does not hold when an
|
|
407
|
+
address mixes the traced funds with others.
|
|
408
|
+
- Native chains other than Ethereum, Polygon and Avalanche have no verified
|
|
409
|
+
router in the registry, so routed deposits there are not detected.
|
|
410
|
+
- `characterize` counts inflows from native pools only.
|
|
411
|
+
- Results depend on the explorer API. Provider failures raise errors instead of
|
|
412
|
+
returning empty results, and pools whose search window cannot be resolved are
|
|
413
|
+
reported as not searched.
|
|
414
|
+
|
|
415
|
+
## Development
|
|
416
|
+
|
|
417
|
+
```bash
|
|
418
|
+
python -m pytest -q # network access is blocked in tests
|
|
419
|
+
python -m pytest -m live # on-chain registry check, needs network
|
|
420
|
+
python -m ruff check . && python -m ruff format --check .
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Weight sensitivity of saved results (no API calls):
|
|
424
|
+
|
|
425
|
+
```bash
|
|
426
|
+
python tools/sensitivity.py out/demix.json:<expected exit> --samples 1000 --spread 0.5
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Synthetic benchmark (no API calls, about half a minute):
|
|
430
|
+
|
|
431
|
+
```bash
|
|
432
|
+
python tools/simulate.py --experiment all --trials 200 --seed 1
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Public laundering cases (KuCoin 2020, Harmony 2022; needs an API key):
|
|
436
|
+
|
|
437
|
+
```bash
|
|
438
|
+
python tools/real_cases.py
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
`requirements-lock.txt` pins every dependency with hashes (generated by
|
|
442
|
+
`uv pip compile pyproject.toml --extra web --extra dev --python-version 3.9
|
|
443
|
+
--generate-hashes --universal`); install it with
|
|
444
|
+
`pip install --require-hashes -r requirements-lock.txt` and then
|
|
445
|
+
`pip install --no-deps -e .` to reproduce a result with the same libraries.
|
|
446
|
+
|
|
447
|
+
CI runs the tests on Linux and Windows with Python 3.9 and 3.13 against the lock file, a test under a
|
|
448
|
+
legacy Windows codepage, the linter, and a wheel install check.
|
|
449
|
+
|
|
450
|
+
## Legal and ethical use
|
|
451
|
+
|
|
452
|
+
For research, compliance and authorised investigations. Every address processed
|
|
453
|
+
is public on-chain data, but a label or a band attached to an address is an
|
|
454
|
+
allegation about a person. Corroborate any lead independently before it appears
|
|
455
|
+
in a report, a referral or a freeze request, and follow the terms of service of
|
|
456
|
+
the data providers you use.
|
|
457
|
+
|
|
458
|
+
## License
|
|
459
|
+
|
|
460
|
+
MIT, see [LICENSE](https://github.com/prettydeath/TornadoCash-demixer/blob/main/LICENSE).
|