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.
Files changed (56) hide show
  1. {crypttrace-0.32.0/src/crypttrace.egg-info → crypttrace-0.34.0}/PKG-INFO +49 -8
  2. {crypttrace-0.32.0 → crypttrace-0.34.0}/README.md +48 -7
  3. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/__init__.py +1 -1
  4. crypttrace-0.34.0/src/crypttrace/case.py +187 -0
  5. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/cli.py +119 -0
  6. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/known.json +1 -1
  7. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/web/index.html +24 -0
  8. {crypttrace-0.32.0 → crypttrace-0.34.0/src/crypttrace.egg-info}/PKG-INFO +49 -8
  9. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
  10. {crypttrace-0.32.0 → crypttrace-0.34.0}/LICENSE +0 -0
  11. {crypttrace-0.32.0 → crypttrace-0.34.0}/pyproject.toml +0 -0
  12. {crypttrace-0.32.0 → crypttrace-0.34.0}/setup.cfg +0 -0
  13. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/addresses.py +0 -0
  14. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/analysis.py +0 -0
  15. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/assess.py +0 -0
  16. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/assets.py +0 -0
  17. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/bridges.py +0 -0
  18. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/chains.py +0 -0
  19. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/config.py +0 -0
  20. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/drain.py +0 -0
  21. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/evidence.py +0 -0
  22. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/export.py +0 -0
  23. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/__init__.py +0 -0
  24. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  25. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  26. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/http.py +0 -0
  27. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/solana.py +0 -0
  28. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/ton.py +0 -0
  29. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/fetchers/tron.py +0 -0
  30. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/freeze.py +0 -0
  31. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/funder.py +0 -0
  32. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/gascluster.py +0 -0
  33. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/investigate.py +0 -0
  34. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/__init__.py +0 -0
  35. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/audit.py +0 -0
  36. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/bulk.py +0 -0
  37. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/labels.py +0 -0
  38. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/names.py +0 -0
  39. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/partial.json +0 -0
  40. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/tonapi.py +0 -0
  41. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/labels/tronscan.py +0 -0
  42. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/offramp.py +0 -0
  43. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/poisoning.py +0 -0
  44. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/prices.py +0 -0
  45. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/render.py +0 -0
  46. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/report.py +0 -0
  47. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/spam.py +0 -0
  48. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/store.py +0 -0
  49. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/trace.py +0 -0
  50. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/verify.py +0 -0
  51. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/watch.py +0 -0
  52. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace/webapp.py +0 -0
  53. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  54. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  55. {crypttrace-0.32.0 → crypttrace-0.34.0}/src/crypttrace.egg-info/requires.txt +0 -0
  56. {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.32.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
- ![crypttrace tracing the AFX Trade exploit](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/graph-outflow.png)
53
+ ![crypttrace investigating the Ronin bridge exploiter in the web UI](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/web-ronin.png)
54
54
 
55
- *Real case: the AFX Trade exploiter (July 2026, $24.15M). crypttrace follows the
56
- stolen ETH out of the attacker's wallet — 12,467 ETH (~$23.3M) to a holding
57
- address, then split across three wallets and split again. Classic layering,
58
- mapped in seconds.*
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
+ ![A Binance reserve wallet on Dogecoin: 2.12 billion DOGE, labelled from Binance's own proof-of-reserves list](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/web-dogecoin.png)
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
+ ![A payment to a listed phishing address, found by the drain check](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/web-check-phishing.png)
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
- Everything runs on your machine — nothing is uploaded anywhere.
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
+ ![funder on the Ronin exploiter, and verify on a Dogecoin wallet](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/cli-funder-verify.png)
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
- ![crypttrace tracing the AFX Trade exploit](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/graph-outflow.png)
19
+ ![crypttrace investigating the Ronin bridge exploiter in the web UI](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/web-ronin.png)
20
20
 
21
- *Real case: the AFX Trade exploiter (July 2026, $24.15M). crypttrace follows the
22
- stolen ETH out of the attacker's wallet — 12,467 ETH (~$23.3M) to a holding
23
- address, then split across three wallets and split again. Classic layering,
24
- mapped in seconds.*
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
+ ![A Binance reserve wallet on Dogecoin: 2.12 billion DOGE, labelled from Binance's own proof-of-reserves list](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/web-dogecoin.png)
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
+ ![A payment to a listed phishing address, found by the drain check](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/web-check-phishing.png)
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
- Everything runs on your machine — nothing is uploaded anywhere.
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
+ ![funder on the Ronin exploiter, and verify on a Dogecoin wallet](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/cli-funder-verify.png)
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.32.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.32.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
- ![crypttrace tracing the AFX Trade exploit](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/graph-outflow.png)
53
+ ![crypttrace investigating the Ronin bridge exploiter in the web UI](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/web-ronin.png)
54
54
 
55
- *Real case: the AFX Trade exploiter (July 2026, $24.15M). crypttrace follows the
56
- stolen ETH out of the attacker's wallet — 12,467 ETH (~$23.3M) to a holding
57
- address, then split across three wallets and split again. Classic layering,
58
- mapped in seconds.*
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
+ ![A Binance reserve wallet on Dogecoin: 2.12 billion DOGE, labelled from Binance's own proof-of-reserves list](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/web-dogecoin.png)
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
+ ![A payment to a listed phishing address, found by the drain check](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/web-check-phishing.png)
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
- Everything runs on your machine — nothing is uploaded anywhere.
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
+ ![funder on the Ronin exploiter, and verify on a Dogecoin wallet](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/cli-funder-verify.png)
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
@@ -7,6 +7,7 @@ src/crypttrace/analysis.py
7
7
  src/crypttrace/assess.py
8
8
  src/crypttrace/assets.py
9
9
  src/crypttrace/bridges.py
10
+ src/crypttrace/case.py
10
11
  src/crypttrace/chains.py
11
12
  src/crypttrace/cli.py
12
13
  src/crypttrace/config.py
File without changes
File without changes
File without changes