crypttrace 0.32.0__tar.gz → 0.34.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.
- {crypttrace-0.32.0/src/crypttrace.egg-info → crypttrace-0.34.0}/PKG-INFO +49 -8
- {crypttrace-0.32.0 → crypttrace-0.34.0}/README.md +48 -7
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/__init__.py +1 -1
- crypttrace-0.34.0/src/crypttrace/case.py +187 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/cli.py +119 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/known.json +1 -1
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/web/index.html +24 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0/src/crypttrace.egg-info}/PKG-INFO +49 -8
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/LICENSE +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/pyproject.toml +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/setup.cfg +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/chains.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/drain.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/evidence.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/export.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/solana.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/ton.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/tron.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/freeze.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/gascluster.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/investigate.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/labels.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/names.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/tonapi.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/tronscan.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/poisoning.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/render.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/spam.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/store.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/watch.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/webapp.py +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.34.0
|
|
4
4
|
Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron, Solana and TON
|
|
5
5
|
Author: bobslayerX
|
|
6
6
|
License-Expression: MIT
|
|
@@ -50,12 +50,14 @@ here's the wallet — where did the money go, and where can it still be stopped?
|
|
|
50
50
|
Works as a terminal tool **and** as a local web app with an interactive
|
|
51
51
|
fund-flow graph.
|
|
52
52
|
|
|
53
|
-

|
|
54
54
|
|
|
55
|
-
*Real case: the
|
|
56
|
-
stolen ETH
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
*Real case: the Ronin bridge exploiter (Lazarus, 2022). The graph shows the
|
|
56
|
+
stolen ETH leaving in three transfers of 23,500–33,600 ETH, every one to a
|
|
57
|
+
wallet on the US sanctions list. The side panel shows what is known about the
|
|
58
|
+
address and where it comes from: the OFAC listing behind the label, USDT and
|
|
59
|
+
USDC blacklisted by Tether and Circle, and the first funding of the wallet —
|
|
60
|
+
1.06 ETH from a Binance reserve wallet, an exchange that knows who withdrew it.*
|
|
59
61
|
|
|
60
62
|
---
|
|
61
63
|
|
|
@@ -137,6 +139,8 @@ so scams that start in a Telegram chat often end there. TON addresses are
|
|
|
137
139
|
accepted in any form (`UQ…`, `EQ…` or raw `0:…`) and shown as wallets show
|
|
138
140
|
them (`UQ…`); jetton transfers are reported between the owners' wallets.
|
|
139
141
|
|
|
142
|
+

|
|
143
|
+
|
|
140
144
|
---
|
|
141
145
|
|
|
142
146
|
## Was my wallet drained?
|
|
@@ -161,6 +165,14 @@ history and answers in plain language:
|
|
|
161
165
|
jetton balances and NFTs from trusted collections, not airdropped spam), and
|
|
162
166
|
who to report to.
|
|
163
167
|
|
|
168
|
+
It also names any payment the wallet made to an address on a phishing or
|
|
169
|
+
sanctions list (ScamSniffer's phishing blacklist and OFAC's, downloaded with
|
|
170
|
+
`crypttrace update-labels`). A fake site or a signed token approval takes money
|
|
171
|
+
in one transfer, so there is no burst to find, but the destination gives it
|
|
172
|
+
away:
|
|
173
|
+
|
|
174
|
+

|
|
175
|
+
|
|
164
176
|
The web UI has the same check in its **Was I drained?** tab.
|
|
165
177
|
|
|
166
178
|
### An evidence pack for the exchange and the police
|
|
@@ -192,8 +204,11 @@ explorer. A side panel shows the profile, first-funder chain, off-ramp check
|
|
|
192
204
|
and whether USDT/USDC at the address is frozen. Tabs add the assessment, the
|
|
193
205
|
timeline, the list of who sent funds here, and address poisoning (look-alikes
|
|
194
206
|
planted in the wallet's history, and payments lured by this address). **Case
|
|
195
|
-
file** downloads the same one-page HTML report `crypttrace report` writes
|
|
196
|
-
|
|
207
|
+
file** downloads the same one-page HTML report `crypttrace report` writes;
|
|
208
|
+
**GraphML** and **CSV** download the graph for Gephi or Maltego. The page's
|
|
209
|
+
address is a link to the investigation
|
|
210
|
+
(`/?address=…&chain=…&direction=…&depth=…&tab=check`), ready to bookmark or
|
|
211
|
+
send. Everything runs on your machine — nothing is uploaded anywhere.
|
|
197
212
|
|
|
198
213
|
This exists so non-technical victims can use the tool at all: a form and a
|
|
199
214
|
picture, instead of command-line flags.
|
|
@@ -329,6 +344,26 @@ entities — that's the OSINT handoff point. `--direction out` (default) answers
|
|
|
329
344
|
money come from", which is how you vet a suspicious address or find a victim's
|
|
330
345
|
source of funds.
|
|
331
346
|
|
|
347
|
+
### Cases — an investigation kept as a file
|
|
348
|
+
|
|
349
|
+
An investigation rarely fits one address. `case` keeps the addresses (any
|
|
350
|
+
chain, each with a role: victim, suspect, intermediary, exchange, mixer, bridge
|
|
351
|
+
or other), a log of notes, and the last snapshot of each address. `refresh`
|
|
352
|
+
reads them all again and says what changed: a balance that went down, new
|
|
353
|
+
activity since the last look.
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
crypttrace case new ronin --note "Ronin bridge exploit, March 2022"
|
|
357
|
+
crypttrace case add ronin 0x098b…2f96 --chain eth --role suspect --note "exploiter"
|
|
358
|
+
crypttrace case note ronin "asked Binance for KYC on the first-gas withdrawal"
|
|
359
|
+
crypttrace case refresh ronin # what moved since the last refresh
|
|
360
|
+
crypttrace case show ronin # addresses, current labels, last snapshot, the log
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
A case is one JSON file in `~/.crypttrace/cases/`. Every `case` command also
|
|
364
|
+
takes a path to a `.json` file, so handing a case to a colleague is sending the
|
|
365
|
+
file.
|
|
366
|
+
|
|
332
367
|
### Watch & alerts — catching the cash-out
|
|
333
368
|
|
|
334
369
|
The only window to freeze stolen funds is the moment they reach an exchange
|
|
@@ -434,6 +469,11 @@ explorers list newest first, so there it is the earliest of the latest 200
|
|
|
434
469
|
transfers. On EVM chains a first funding sent by a contract (a mixer or bridge
|
|
435
470
|
withdrawal, an internal transaction) counts and is flagged.
|
|
436
471
|
|
|
472
|
+

|
|
473
|
+
|
|
474
|
+
*Above: the Ronin exploiter's first gas came from a Binance reserve wallet. Below:
|
|
475
|
+
`verify` re-derives a Dogecoin wallet's totals and matches them with the chain.*
|
|
476
|
+
|
|
437
477
|
### Victim lists and timing analysis
|
|
438
478
|
|
|
439
479
|
Two things a graph shows but can't hand you as evidence.
|
|
@@ -727,6 +767,7 @@ src/crypttrace/
|
|
|
727
767
|
offramp.py # exchange-deposit detection
|
|
728
768
|
bridges.py # cross-chain matching
|
|
729
769
|
watch.py # watchlist + alerts
|
|
770
|
+
case.py # investigations as files: addresses, roles, notes, snapshots
|
|
730
771
|
report.py # Markdown + JSON reports
|
|
731
772
|
export.py # traced graph as GraphML (Gephi) and edge-list CSV (Maltego)
|
|
732
773
|
prices.py # USD valuation
|
|
@@ -16,12 +16,14 @@ here's the wallet — where did the money go, and where can it still be stopped?
|
|
|
16
16
|
Works as a terminal tool **and** as a local web app with an interactive
|
|
17
17
|
fund-flow graph.
|
|
18
18
|
|
|
19
|
-

|
|
20
20
|
|
|
21
|
-
*Real case: the
|
|
22
|
-
stolen ETH
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
*Real case: the Ronin bridge exploiter (Lazarus, 2022). The graph shows the
|
|
22
|
+
stolen ETH leaving in three transfers of 23,500–33,600 ETH, every one to a
|
|
23
|
+
wallet on the US sanctions list. The side panel shows what is known about the
|
|
24
|
+
address and where it comes from: the OFAC listing behind the label, USDT and
|
|
25
|
+
USDC blacklisted by Tether and Circle, and the first funding of the wallet —
|
|
26
|
+
1.06 ETH from a Binance reserve wallet, an exchange that knows who withdrew it.*
|
|
25
27
|
|
|
26
28
|
---
|
|
27
29
|
|
|
@@ -103,6 +105,8 @@ so scams that start in a Telegram chat often end there. TON addresses are
|
|
|
103
105
|
accepted in any form (`UQ…`, `EQ…` or raw `0:…`) and shown as wallets show
|
|
104
106
|
them (`UQ…`); jetton transfers are reported between the owners' wallets.
|
|
105
107
|
|
|
108
|
+

|
|
109
|
+
|
|
106
110
|
---
|
|
107
111
|
|
|
108
112
|
## Was my wallet drained?
|
|
@@ -127,6 +131,14 @@ history and answers in plain language:
|
|
|
127
131
|
jetton balances and NFTs from trusted collections, not airdropped spam), and
|
|
128
132
|
who to report to.
|
|
129
133
|
|
|
134
|
+
It also names any payment the wallet made to an address on a phishing or
|
|
135
|
+
sanctions list (ScamSniffer's phishing blacklist and OFAC's, downloaded with
|
|
136
|
+
`crypttrace update-labels`). A fake site or a signed token approval takes money
|
|
137
|
+
in one transfer, so there is no burst to find, but the destination gives it
|
|
138
|
+
away:
|
|
139
|
+
|
|
140
|
+

|
|
141
|
+
|
|
130
142
|
The web UI has the same check in its **Was I drained?** tab.
|
|
131
143
|
|
|
132
144
|
### An evidence pack for the exchange and the police
|
|
@@ -158,8 +170,11 @@ explorer. A side panel shows the profile, first-funder chain, off-ramp check
|
|
|
158
170
|
and whether USDT/USDC at the address is frozen. Tabs add the assessment, the
|
|
159
171
|
timeline, the list of who sent funds here, and address poisoning (look-alikes
|
|
160
172
|
planted in the wallet's history, and payments lured by this address). **Case
|
|
161
|
-
file** downloads the same one-page HTML report `crypttrace report` writes
|
|
162
|
-
|
|
173
|
+
file** downloads the same one-page HTML report `crypttrace report` writes;
|
|
174
|
+
**GraphML** and **CSV** download the graph for Gephi or Maltego. The page's
|
|
175
|
+
address is a link to the investigation
|
|
176
|
+
(`/?address=…&chain=…&direction=…&depth=…&tab=check`), ready to bookmark or
|
|
177
|
+
send. Everything runs on your machine — nothing is uploaded anywhere.
|
|
163
178
|
|
|
164
179
|
This exists so non-technical victims can use the tool at all: a form and a
|
|
165
180
|
picture, instead of command-line flags.
|
|
@@ -295,6 +310,26 @@ entities — that's the OSINT handoff point. `--direction out` (default) answers
|
|
|
295
310
|
money come from", which is how you vet a suspicious address or find a victim's
|
|
296
311
|
source of funds.
|
|
297
312
|
|
|
313
|
+
### Cases — an investigation kept as a file
|
|
314
|
+
|
|
315
|
+
An investigation rarely fits one address. `case` keeps the addresses (any
|
|
316
|
+
chain, each with a role: victim, suspect, intermediary, exchange, mixer, bridge
|
|
317
|
+
or other), a log of notes, and the last snapshot of each address. `refresh`
|
|
318
|
+
reads them all again and says what changed: a balance that went down, new
|
|
319
|
+
activity since the last look.
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
crypttrace case new ronin --note "Ronin bridge exploit, March 2022"
|
|
323
|
+
crypttrace case add ronin 0x098b…2f96 --chain eth --role suspect --note "exploiter"
|
|
324
|
+
crypttrace case note ronin "asked Binance for KYC on the first-gas withdrawal"
|
|
325
|
+
crypttrace case refresh ronin # what moved since the last refresh
|
|
326
|
+
crypttrace case show ronin # addresses, current labels, last snapshot, the log
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
A case is one JSON file in `~/.crypttrace/cases/`. Every `case` command also
|
|
330
|
+
takes a path to a `.json` file, so handing a case to a colleague is sending the
|
|
331
|
+
file.
|
|
332
|
+
|
|
298
333
|
### Watch & alerts — catching the cash-out
|
|
299
334
|
|
|
300
335
|
The only window to freeze stolen funds is the moment they reach an exchange
|
|
@@ -400,6 +435,11 @@ explorers list newest first, so there it is the earliest of the latest 200
|
|
|
400
435
|
transfers. On EVM chains a first funding sent by a contract (a mixer or bridge
|
|
401
436
|
withdrawal, an internal transaction) counts and is flagged.
|
|
402
437
|
|
|
438
|
+

|
|
439
|
+
|
|
440
|
+
*Above: the Ronin exploiter's first gas came from a Binance reserve wallet. Below:
|
|
441
|
+
`verify` re-derives a Dogecoin wallet's totals and matches them with the chain.*
|
|
442
|
+
|
|
403
443
|
### Victim lists and timing analysis
|
|
404
444
|
|
|
405
445
|
Two things a graph shows but can't hand you as evidence.
|
|
@@ -693,6 +733,7 @@ src/crypttrace/
|
|
|
693
733
|
offramp.py # exchange-deposit detection
|
|
694
734
|
bridges.py # cross-chain matching
|
|
695
735
|
watch.py # watchlist + alerts
|
|
736
|
+
case.py # investigations as files: addresses, roles, notes, snapshots
|
|
696
737
|
report.py # Markdown + JSON reports
|
|
697
738
|
export.py # traced graph as GraphML (Gephi) and edge-list CSV (Maltego)
|
|
698
739
|
prices.py # USD valuation
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.34.0"
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""An investigation kept as a file: its addresses, their roles, notes, and what
|
|
2
|
+
changed since you last looked.
|
|
3
|
+
|
|
4
|
+
A case is one JSON file — by default ~/.crypttrace/cases/NAME.json, or any path
|
|
5
|
+
— so handing a case to a colleague is sending them the file. It holds:
|
|
6
|
+
|
|
7
|
+
- addresses across chains, each with a role (victim, suspect, exchange, …)
|
|
8
|
+
and a note;
|
|
9
|
+
- a log of timestamped notes;
|
|
10
|
+
- for each address, the last snapshot taken by `refresh`: native balance
|
|
11
|
+
and the time of the latest transfer (tokens included where the chain has
|
|
12
|
+
them). The next `refresh` compares against it, which is how a case tells
|
|
13
|
+
you the money moved again.
|
|
14
|
+
"""
|
|
15
|
+
import datetime as dt
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Callable, List, Optional
|
|
21
|
+
|
|
22
|
+
from crypttrace import addresses, chains, config
|
|
23
|
+
|
|
24
|
+
CASES_DIR = config.DATA_DIR / "cases"
|
|
25
|
+
ROLES = ("victim", "suspect", "intermediary", "exchange", "mixer", "bridge", "other")
|
|
26
|
+
FORMAT = 1
|
|
27
|
+
_ALL_TOKENS = {"contract": None, "symbol": "*"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CaseError(ValueError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def path_for(name_or_path: str) -> Path:
|
|
35
|
+
"""A case named in the cases folder, or a .json file anywhere."""
|
|
36
|
+
p = Path(name_or_path)
|
|
37
|
+
if p.suffix.lower() == ".json" or p.parent != Path("."):
|
|
38
|
+
return p
|
|
39
|
+
if not re.fullmatch(r"[\w.-]{1,64}", name_or_path):
|
|
40
|
+
raise CaseError("a case name is letters, digits, '.', '-' or '_' (up to 64)")
|
|
41
|
+
return CASES_DIR / f"{name_or_path}.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _now() -> int:
|
|
45
|
+
return int(time.time())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def new(name: str, note: str = "") -> Path:
|
|
49
|
+
p = path_for(name)
|
|
50
|
+
if p.exists():
|
|
51
|
+
raise CaseError(f"case already exists: {p}")
|
|
52
|
+
case = {"crypttrace_case": FORMAT, "name": p.stem, "created": _now(), "updated": _now(),
|
|
53
|
+
"addresses": [], "notes": []}
|
|
54
|
+
if note:
|
|
55
|
+
case["notes"].append({"at": _now(), "text": note})
|
|
56
|
+
save(case, p)
|
|
57
|
+
return p
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load(name: str) -> dict:
|
|
61
|
+
p = path_for(name)
|
|
62
|
+
try:
|
|
63
|
+
case = json.loads(p.read_text(encoding="utf-8"))
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
raise CaseError(f"no such case: {p}")
|
|
66
|
+
except ValueError as e:
|
|
67
|
+
raise CaseError(f"{p} is not a readable case file: {e}")
|
|
68
|
+
if not isinstance(case, dict) or "crypttrace_case" not in case:
|
|
69
|
+
raise CaseError(f"{p} is not a crypttrace case file")
|
|
70
|
+
case["_path"] = str(p)
|
|
71
|
+
return case
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save(case: dict, p: Optional[Path] = None) -> Path:
|
|
75
|
+
p = Path(p or case["_path"])
|
|
76
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
data = {k: v for k, v in case.items() if not k.startswith("_")}
|
|
78
|
+
data["updated"] = _now()
|
|
79
|
+
tmp = p.with_suffix(p.suffix + ".tmp")
|
|
80
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
81
|
+
tmp.replace(p) # a half-written case never replaces a good one
|
|
82
|
+
return p
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def all_cases() -> List[dict]:
|
|
86
|
+
out = []
|
|
87
|
+
for p in sorted(CASES_DIR.glob("*.json")):
|
|
88
|
+
try:
|
|
89
|
+
c = load(str(p))
|
|
90
|
+
except CaseError:
|
|
91
|
+
continue
|
|
92
|
+
out.append({"name": c.get("name", p.stem), "path": str(p), "addresses": len(c.get("addresses", [])),
|
|
93
|
+
"notes": len(c.get("notes", [])), "updated": c.get("updated")})
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def add(name: str, address: str, chain: str, role: str = "other", note: str = "") -> dict:
|
|
98
|
+
chains.check(chain)
|
|
99
|
+
if role not in ROLES:
|
|
100
|
+
raise CaseError(f"role must be one of: {', '.join(ROLES)}")
|
|
101
|
+
ok, why = addresses.validate(address, chain)
|
|
102
|
+
if not ok:
|
|
103
|
+
raise CaseError(f"not a valid {chain} address: {why}")
|
|
104
|
+
case = load(name)
|
|
105
|
+
key = chains.norm_addr(address, chain)
|
|
106
|
+
for a in case["addresses"]:
|
|
107
|
+
if a["chain"] == chain and a["address"] == key:
|
|
108
|
+
a["role"] = role # adding it again updates it
|
|
109
|
+
if note:
|
|
110
|
+
a["note"] = note
|
|
111
|
+
save(case)
|
|
112
|
+
return a
|
|
113
|
+
entry = {"address": key, "chain": chain, "role": role, "note": note, "added": _now(), "snapshot": None}
|
|
114
|
+
case["addresses"].append(entry)
|
|
115
|
+
save(case)
|
|
116
|
+
return entry
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def remove(name: str, address: str, chain: Optional[str] = None) -> bool:
|
|
120
|
+
case = load(name)
|
|
121
|
+
before = len(case["addresses"])
|
|
122
|
+
case["addresses"] = [a for a in case["addresses"]
|
|
123
|
+
if not (a["address"] == chains.norm_addr(address, a["chain"])
|
|
124
|
+
and (chain is None or a["chain"] == chain))]
|
|
125
|
+
if len(case["addresses"]) == before:
|
|
126
|
+
return False
|
|
127
|
+
save(case)
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def note(name: str, text: str) -> dict:
|
|
132
|
+
case = load(name)
|
|
133
|
+
entry = {"at": _now(), "text": text}
|
|
134
|
+
case["notes"].append(entry)
|
|
135
|
+
save(case)
|
|
136
|
+
return entry
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def snapshot(address: str, chain: str) -> dict:
|
|
140
|
+
"""Native balance and the time of the latest transfer, read fresh."""
|
|
141
|
+
snap = {"at": _now(), "balance": None, "last_seen": None, "error": None}
|
|
142
|
+
try:
|
|
143
|
+
snap["balance"] = chains.balance(address, chain)
|
|
144
|
+
rows = list(chains.transfers(address, chain, 50, fresh=True))
|
|
145
|
+
if not chains.is_utxo(chain):
|
|
146
|
+
try:
|
|
147
|
+
rows += chains.transfers(address, chain, 50, asset=_ALL_TOKENS, fresh=True)
|
|
148
|
+
except chains.ChainError:
|
|
149
|
+
pass
|
|
150
|
+
stamps = [int(r.get("timestamp") or 0) for r in rows if r.get("timestamp")]
|
|
151
|
+
snap["last_seen"] = max(stamps) if stamps else None
|
|
152
|
+
except chains.ChainError as e:
|
|
153
|
+
snap["error"] = str(e)
|
|
154
|
+
return snap
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def changes(prev: Optional[dict], cur: dict) -> List[str]:
|
|
158
|
+
"""What is different from the last snapshot, in words."""
|
|
159
|
+
if cur.get("error"):
|
|
160
|
+
return [f"could not be read: {cur['error']}"]
|
|
161
|
+
if not prev or prev.get("error"):
|
|
162
|
+
return ["first snapshot"]
|
|
163
|
+
out = []
|
|
164
|
+
a, b = prev.get("balance"), cur.get("balance")
|
|
165
|
+
if a is not None and b is not None and abs(b - a) > max(1e-9, abs(a) * 1e-9):
|
|
166
|
+
out.append(f"balance {a:,.8f} -> {b:,.8f} ({b - a:+,.8f})")
|
|
167
|
+
if (cur.get("last_seen") or 0) > (prev.get("last_seen") or 0):
|
|
168
|
+
when = dt.datetime.fromtimestamp(cur["last_seen"], dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
169
|
+
out.append(f"new activity, latest {when}")
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def refresh(name: str, take: Callable[[str, str], dict] = snapshot) -> List[dict]:
|
|
174
|
+
"""Snapshot every address; report what changed since the last refresh."""
|
|
175
|
+
case = load(name)
|
|
176
|
+
report = []
|
|
177
|
+
for a in case["addresses"]:
|
|
178
|
+
cur = take(a["address"], a["chain"])
|
|
179
|
+
diff = changes(a.get("snapshot"), cur)
|
|
180
|
+
report.append({**{k: a[k] for k in ("address", "chain", "role")}, "changes": diff,
|
|
181
|
+
"moved": any(not d.startswith(("first", "could not")) for d in diff),
|
|
182
|
+
"balance": cur.get("balance"), "previous": a.get("snapshot")})
|
|
183
|
+
if not cur.get("error"):
|
|
184
|
+
a["snapshot"] = cur
|
|
185
|
+
case.setdefault("refreshes", []).append(_now())
|
|
186
|
+
save(case)
|
|
187
|
+
return report
|
|
@@ -1034,5 +1034,124 @@ def watch_run(
|
|
|
1034
1034
|
console.print("\n[dim]Stopped.[/dim]")
|
|
1035
1035
|
|
|
1036
1036
|
|
|
1037
|
+
# ---------------------------------------------------------------- cases
|
|
1038
|
+
case_app = typer.Typer(help="Keep an investigation as a file: addresses, roles, notes, what changed.")
|
|
1039
|
+
app.add_typer(case_app, name="case")
|
|
1040
|
+
CASE_ARG = typer.Argument(..., help="Case name (kept in ~/.crypttrace/cases) or a path to a .json case file")
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _case_call(fn, *a, **kw):
|
|
1044
|
+
from crypttrace import case as case_mod
|
|
1045
|
+
try:
|
|
1046
|
+
return fn(*a, **kw)
|
|
1047
|
+
except (case_mod.CaseError, chains_mod.ChainError) as e:
|
|
1048
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
1049
|
+
raise typer.Exit(1)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
@case_app.command("new")
|
|
1053
|
+
def case_new(name: str = CASE_ARG,
|
|
1054
|
+
note: str = typer.Option("", "--note", "-n", help="What the case is about")):
|
|
1055
|
+
"""Start a case."""
|
|
1056
|
+
from crypttrace import case as case_mod
|
|
1057
|
+
p = _case_call(case_mod.new, name, note)
|
|
1058
|
+
console.print(f"[green]✓ Case created[/green] {p}\n[dim]Add addresses with "
|
|
1059
|
+
f"`crypttrace case add {name} ADDRESS --chain … --role suspect`[/dim]")
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
@case_app.command("list")
|
|
1063
|
+
def case_list():
|
|
1064
|
+
"""Cases in ~/.crypttrace/cases."""
|
|
1065
|
+
from crypttrace import case as case_mod
|
|
1066
|
+
cases = case_mod.all_cases()
|
|
1067
|
+
if not cases:
|
|
1068
|
+
console.print("[dim]No cases yet. Start one with `crypttrace case new NAME`.[/dim]")
|
|
1069
|
+
return
|
|
1070
|
+
t = render.Table(header_style="bold")
|
|
1071
|
+
for c in ("Case", "Addresses", "Notes", "Updated (UTC)"):
|
|
1072
|
+
t.add_column(c)
|
|
1073
|
+
for c in cases:
|
|
1074
|
+
t.add_row(c["name"], str(c["addresses"]), str(c["notes"]), render._ts(str(c["updated"] or 0)))
|
|
1075
|
+
console.print(t)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
@case_app.command("add")
|
|
1079
|
+
def case_add(name: str = CASE_ARG,
|
|
1080
|
+
address: str = typer.Argument(..., help="Address to add"),
|
|
1081
|
+
chain: str = CHAIN_OPT,
|
|
1082
|
+
role: str = typer.Option("other", "--role", "-r",
|
|
1083
|
+
help="victim, suspect, intermediary, exchange, mixer, bridge or other"),
|
|
1084
|
+
note: str = typer.Option("", "--note", "-n", help="Why it is in the case")):
|
|
1085
|
+
"""Add an address to a case (adding it again updates its role and note)."""
|
|
1086
|
+
from crypttrace import case as case_mod
|
|
1087
|
+
a = _case_call(case_mod.add, name, address, chain, role, note)
|
|
1088
|
+
console.print(f"[green]✓[/green] {a['address']} ({a['chain']}) as {a['role']}"
|
|
1089
|
+
+ (f" — {a['note']}" if a.get("note") else ""))
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
@case_app.command("remove")
|
|
1093
|
+
def case_remove(name: str = CASE_ARG, address: str = typer.Argument(..., help="Address to drop"),
|
|
1094
|
+
chain: Optional[str] = typer.Option(None, "--chain", "-c", help="Only on this chain")):
|
|
1095
|
+
"""Drop an address from a case."""
|
|
1096
|
+
from crypttrace import case as case_mod
|
|
1097
|
+
if _case_call(case_mod.remove, name, address, chain):
|
|
1098
|
+
console.print(f"[green]✓ Removed[/green] {address}")
|
|
1099
|
+
else:
|
|
1100
|
+
console.print("[dim]That address is not in the case.[/dim]")
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
@case_app.command("note")
|
|
1104
|
+
def case_note(name: str = CASE_ARG, text: str = typer.Argument(..., help="The note")):
|
|
1105
|
+
"""Add a timestamped note to the case log."""
|
|
1106
|
+
from crypttrace import case as case_mod
|
|
1107
|
+
_case_call(case_mod.note, name, text)
|
|
1108
|
+
console.print("[green]✓ Noted[/green]")
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
@case_app.command("show")
|
|
1112
|
+
def case_show(name: str = CASE_ARG):
|
|
1113
|
+
"""The case: its addresses with current labels, the last snapshot, the log."""
|
|
1114
|
+
from crypttrace import case as case_mod
|
|
1115
|
+
c = _case_call(case_mod.load, name)
|
|
1116
|
+
console.print(f"[bold]{c['name']}[/bold] [dim]{c['_path']}[/dim]")
|
|
1117
|
+
if c["addresses"]:
|
|
1118
|
+
t = render.Table(header_style="bold")
|
|
1119
|
+
for col in ("Role", "Chain", "Address", "Label", "Note", "Last snapshot"):
|
|
1120
|
+
t.add_column(col)
|
|
1121
|
+
for a in c["addresses"]:
|
|
1122
|
+
s = a.get("snapshot") or {}
|
|
1123
|
+
snap = "—" if not s else (
|
|
1124
|
+
(f"{s['balance']:,.8f}" if s.get("balance") is not None else "?") + " "
|
|
1125
|
+
f"{chains_mod.symbol(a['chain'])} · seen "
|
|
1126
|
+
+ (render._ts(str(s['last_seen'])) if s.get("last_seen") else "never")
|
|
1127
|
+
+ f" · taken {render._ts(str(s['at']))}")
|
|
1128
|
+
t.add_row(a["role"], a["chain"], a["address"], labels.label_of(a["address"]) or "",
|
|
1129
|
+
a.get("note") or "", snap)
|
|
1130
|
+
console.print(t)
|
|
1131
|
+
else:
|
|
1132
|
+
console.print("[dim]No addresses yet.[/dim]")
|
|
1133
|
+
for n in c.get("notes", []):
|
|
1134
|
+
console.print(f" [dim]{render._ts(str(n['at']))}[/dim] {n['text']}")
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
@case_app.command("refresh")
|
|
1138
|
+
def case_refresh(name: str = CASE_ARG):
|
|
1139
|
+
"""Read every address again and say what changed since the last refresh."""
|
|
1140
|
+
from crypttrace import case as case_mod
|
|
1141
|
+
rep = _case_call(case_mod.refresh, name)
|
|
1142
|
+
if not rep:
|
|
1143
|
+
console.print("[dim]The case has no addresses.[/dim]")
|
|
1144
|
+
return
|
|
1145
|
+
moved = [r for r in rep if r["moved"]]
|
|
1146
|
+
for r in rep:
|
|
1147
|
+
mark = "[bold yellow]●[/bold yellow]" if r["moved"] else "[dim]·[/dim]"
|
|
1148
|
+
console.print(f"{mark} {r['role']:<12} {r['chain']:<8} {r['address']}")
|
|
1149
|
+
for d in r["changes"]:
|
|
1150
|
+
console.print(f" {d}")
|
|
1151
|
+
console.print(f"\n[bold]{len(moved)}[/bold] of {len(rep)} address(es) changed since the last refresh."
|
|
1152
|
+
if any(r["previous"] for r in rep) else "\nFirst snapshot taken; the next refresh "
|
|
1153
|
+
"will say what changed.")
|
|
1154
|
+
|
|
1155
|
+
|
|
1037
1156
|
if __name__ == "__main__":
|
|
1038
1157
|
app()
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"0x2796317b0ff8538f253012862c06787adfb8ceb6": {"name": "Synapse: Bridge", "type": "bridge"},
|
|
98
98
|
"0x5427fefa711eff984124bfbb1ab6fbf5e3da1820": {"name": "Celer: cBridge V2", "type": "bridge"},
|
|
99
99
|
"0x841ce48f9446c8e281d3f1444cb859b4a6d0738c": {"name": "Celer: cBridge (old)", "type": "bridge"},
|
|
100
|
-
"0x098b716b8aaf21512996dc57eb0615e2383e2f96": {"name": "Ronin Bridge Exploiter (Lazarus)", "type": "sanctioned"},
|
|
100
|
+
"0x098b716b8aaf21512996dc57eb0615e2383e2f96": {"name": "Ronin Bridge Exploiter (Lazarus)", "type": "sanctioned", "source": "US Treasury OFAC SDN list, Lazarus Group entry (address added in April 2022); present in the SDN digital-currency address extract, github.com/0xB10C/ofac-sanctioned-digital-currency-addresses, checked 2026-11-07", "source_kind": "official-list", "added": "2026-11-07"},
|
|
101
101
|
"0x2f2974fabc54dba33442261211c06bd20e0feefc": {"name": "AFX Trade Exploiter (Jul 2026, $24M)", "type": "scam", "chain": "eth", "source": "Lookonchain, 23 Jul 2026 - received 24.15M USDC bridged from Arbitrum, swapped to 12,467 ETH", "source_kind": "research", "added": "2026-08-01"},
|
|
102
102
|
|
|
103
103
|
"bc1qq85v2c926eg6pgxhwp6q7lf6cnsz80qs3fcu9r": {"name": "Coldcard Exploiter (Jul 2026) - consolidation", "type": "scam", "chain": "btc", "source": "Galaxy Research flow-of-funds thread, 31 Jul 2026, based on Block (clay_garrett) analysis", "source_kind": "research", "added": "2026-08-09"},
|
|
@@ -285,6 +285,8 @@ const COLORS={exchange:'#3fdf87',mixer:'#c48cff',sanctioned:'#ff6b60',scam:'#ff6
|
|
|
285
285
|
const EXPLORER={eth:'https://etherscan.io',bsc:'https://bscscan.com',polygon:'https://polygonscan.com',
|
|
286
286
|
arbitrum:'https://arbiscan.io',optimism:'https://optimistic.etherscan.io',base:'https://basescan.org'};
|
|
287
287
|
const NON_EVM={btc:a=>`https://mempool.space/address/${a}`,
|
|
288
|
+
ltc:a=>`https://litecoinspace.org/address/${a}`,
|
|
289
|
+
doge:a=>`https://blockchair.com/dogecoin/address/${a}`,
|
|
288
290
|
tron:a=>`https://tronscan.org/#/address/${a}`,
|
|
289
291
|
sol:a=>`https://solscan.io/account/${a}`,
|
|
290
292
|
ton:a=>`https://tonviewer.com/${a}`};
|
|
@@ -486,6 +488,10 @@ async function inspect(addr){
|
|
|
486
488
|
async function run(){
|
|
487
489
|
const a=$('#addr').value.trim();
|
|
488
490
|
if(!a){ setStatus('enter an address',true); return; }
|
|
491
|
+
// the page's own address becomes a link to this investigation
|
|
492
|
+
const link=new URLSearchParams({address:a,chain:chain(),asset:$('#asset').value,
|
|
493
|
+
direction:$('#direction').value,depth:$('#depth').value});
|
|
494
|
+
history.replaceState(null,'',`?${link}`);
|
|
489
495
|
setStatus('tracing the chain…',false,true); loader(35);
|
|
490
496
|
$('#hint').classList.add('hide');
|
|
491
497
|
loaded={}; // new address -> other views must reload
|
|
@@ -514,6 +520,8 @@ async function run(){
|
|
|
514
520
|
async function syncAssets(){
|
|
515
521
|
const sel=$('#asset'), c=chain();
|
|
516
522
|
$('#addr').placeholder = c==='btc' ? 'bc1… / 1… / 3… Bitcoin address'
|
|
523
|
+
: c==='ltc' ? 'L… / M… / ltc1… Litecoin address'
|
|
524
|
+
: c==='doge' ? 'D… Dogecoin address'
|
|
517
525
|
: c==='tron' ? 'T… Tron address'
|
|
518
526
|
: c==='sol' ? 'Solana account address'
|
|
519
527
|
: c==='ton' ? 'UQ… / EQ… TON address'
|
|
@@ -821,6 +829,22 @@ $('#zroot').onclick=()=>{
|
|
|
821
829
|
setTimeout(showScale,520);
|
|
822
830
|
};
|
|
823
831
|
$('#addr').addEventListener('keydown',e=>{ if(e.key==='Enter') run(); });
|
|
832
|
+
|
|
833
|
+
// A link opens an investigation: /?address=…&chain=eth&direction=in&depth=4&asset=usdt&tab=check
|
|
834
|
+
(function(){
|
|
835
|
+
const p=new URLSearchParams(location.search), a=p.get('address');
|
|
836
|
+
if(!a) return;
|
|
837
|
+
const set=(id,v)=>{
|
|
838
|
+
const el=$(id);
|
|
839
|
+
if(v!==null && [...el.options].some(o=>o.value===v)){ el.value=v; el.dispatchEvent(new Event('change')); }
|
|
840
|
+
};
|
|
841
|
+
set('#chain',p.get('chain')); // first: it decides which assets are offered
|
|
842
|
+
set('#asset',p.get('asset')); set('#direction',p.get('direction')); set('#depth',p.get('depth'));
|
|
843
|
+
$('#addr').value=a;
|
|
844
|
+
run();
|
|
845
|
+
const v=p.get('tab');
|
|
846
|
+
if(v && [...document.querySelectorAll('.tab')].some(t=>t.dataset.view===v)) showView(v);
|
|
847
|
+
})();
|
|
824
848
|
</script>
|
|
825
849
|
</body>
|
|
826
850
|
</html>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.34.0
|
|
4
4
|
Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron, Solana and TON
|
|
5
5
|
Author: bobslayerX
|
|
6
6
|
License-Expression: MIT
|
|
@@ -50,12 +50,14 @@ here's the wallet — where did the money go, and where can it still be stopped?
|
|
|
50
50
|
Works as a terminal tool **and** as a local web app with an interactive
|
|
51
51
|
fund-flow graph.
|
|
52
52
|
|
|
53
|
-

|
|
54
54
|
|
|
55
|
-
*Real case: the
|
|
56
|
-
stolen ETH
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
*Real case: the Ronin bridge exploiter (Lazarus, 2022). The graph shows the
|
|
56
|
+
stolen ETH leaving in three transfers of 23,500–33,600 ETH, every one to a
|
|
57
|
+
wallet on the US sanctions list. The side panel shows what is known about the
|
|
58
|
+
address and where it comes from: the OFAC listing behind the label, USDT and
|
|
59
|
+
USDC blacklisted by Tether and Circle, and the first funding of the wallet —
|
|
60
|
+
1.06 ETH from a Binance reserve wallet, an exchange that knows who withdrew it.*
|
|
59
61
|
|
|
60
62
|
---
|
|
61
63
|
|
|
@@ -137,6 +139,8 @@ so scams that start in a Telegram chat often end there. TON addresses are
|
|
|
137
139
|
accepted in any form (`UQ…`, `EQ…` or raw `0:…`) and shown as wallets show
|
|
138
140
|
them (`UQ…`); jetton transfers are reported between the owners' wallets.
|
|
139
141
|
|
|
142
|
+

|
|
143
|
+
|
|
140
144
|
---
|
|
141
145
|
|
|
142
146
|
## Was my wallet drained?
|
|
@@ -161,6 +165,14 @@ history and answers in plain language:
|
|
|
161
165
|
jetton balances and NFTs from trusted collections, not airdropped spam), and
|
|
162
166
|
who to report to.
|
|
163
167
|
|
|
168
|
+
It also names any payment the wallet made to an address on a phishing or
|
|
169
|
+
sanctions list (ScamSniffer's phishing blacklist and OFAC's, downloaded with
|
|
170
|
+
`crypttrace update-labels`). A fake site or a signed token approval takes money
|
|
171
|
+
in one transfer, so there is no burst to find, but the destination gives it
|
|
172
|
+
away:
|
|
173
|
+
|
|
174
|
+

|
|
175
|
+
|
|
164
176
|
The web UI has the same check in its **Was I drained?** tab.
|
|
165
177
|
|
|
166
178
|
### An evidence pack for the exchange and the police
|
|
@@ -192,8 +204,11 @@ explorer. A side panel shows the profile, first-funder chain, off-ramp check
|
|
|
192
204
|
and whether USDT/USDC at the address is frozen. Tabs add the assessment, the
|
|
193
205
|
timeline, the list of who sent funds here, and address poisoning (look-alikes
|
|
194
206
|
planted in the wallet's history, and payments lured by this address). **Case
|
|
195
|
-
file** downloads the same one-page HTML report `crypttrace report` writes
|
|
196
|
-
|
|
207
|
+
file** downloads the same one-page HTML report `crypttrace report` writes;
|
|
208
|
+
**GraphML** and **CSV** download the graph for Gephi or Maltego. The page's
|
|
209
|
+
address is a link to the investigation
|
|
210
|
+
(`/?address=…&chain=…&direction=…&depth=…&tab=check`), ready to bookmark or
|
|
211
|
+
send. Everything runs on your machine — nothing is uploaded anywhere.
|
|
197
212
|
|
|
198
213
|
This exists so non-technical victims can use the tool at all: a form and a
|
|
199
214
|
picture, instead of command-line flags.
|
|
@@ -329,6 +344,26 @@ entities — that's the OSINT handoff point. `--direction out` (default) answers
|
|
|
329
344
|
money come from", which is how you vet a suspicious address or find a victim's
|
|
330
345
|
source of funds.
|
|
331
346
|
|
|
347
|
+
### Cases — an investigation kept as a file
|
|
348
|
+
|
|
349
|
+
An investigation rarely fits one address. `case` keeps the addresses (any
|
|
350
|
+
chain, each with a role: victim, suspect, intermediary, exchange, mixer, bridge
|
|
351
|
+
or other), a log of notes, and the last snapshot of each address. `refresh`
|
|
352
|
+
reads them all again and says what changed: a balance that went down, new
|
|
353
|
+
activity since the last look.
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
crypttrace case new ronin --note "Ronin bridge exploit, March 2022"
|
|
357
|
+
crypttrace case add ronin 0x098b…2f96 --chain eth --role suspect --note "exploiter"
|
|
358
|
+
crypttrace case note ronin "asked Binance for KYC on the first-gas withdrawal"
|
|
359
|
+
crypttrace case refresh ronin # what moved since the last refresh
|
|
360
|
+
crypttrace case show ronin # addresses, current labels, last snapshot, the log
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
A case is one JSON file in `~/.crypttrace/cases/`. Every `case` command also
|
|
364
|
+
takes a path to a `.json` file, so handing a case to a colleague is sending the
|
|
365
|
+
file.
|
|
366
|
+
|
|
332
367
|
### Watch & alerts — catching the cash-out
|
|
333
368
|
|
|
334
369
|
The only window to freeze stolen funds is the moment they reach an exchange
|
|
@@ -434,6 +469,11 @@ explorers list newest first, so there it is the earliest of the latest 200
|
|
|
434
469
|
transfers. On EVM chains a first funding sent by a contract (a mixer or bridge
|
|
435
470
|
withdrawal, an internal transaction) counts and is flagged.
|
|
436
471
|
|
|
472
|
+

|
|
473
|
+
|
|
474
|
+
*Above: the Ronin exploiter's first gas came from a Binance reserve wallet. Below:
|
|
475
|
+
`verify` re-derives a Dogecoin wallet's totals and matches them with the chain.*
|
|
476
|
+
|
|
437
477
|
### Victim lists and timing analysis
|
|
438
478
|
|
|
439
479
|
Two things a graph shows but can't hand you as evidence.
|
|
@@ -727,6 +767,7 @@ src/crypttrace/
|
|
|
727
767
|
offramp.py # exchange-deposit detection
|
|
728
768
|
bridges.py # cross-chain matching
|
|
729
769
|
watch.py # watchlist + alerts
|
|
770
|
+
case.py # investigations as files: addresses, roles, notes, snapshots
|
|
730
771
|
report.py # Markdown + JSON reports
|
|
731
772
|
export.py # traced graph as GraphML (Gephi) and edge-list CSV (Maltego)
|
|
732
773
|
prices.py # USD valuation
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|