crypttrace 0.14.0__tar.gz → 0.16.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 (50) hide show
  1. {crypttrace-0.14.0/src/crypttrace.egg-info → crypttrace-0.16.0}/PKG-INFO +32 -1
  2. {crypttrace-0.14.0 → crypttrace-0.16.0}/README.md +31 -0
  3. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/analysis.py +29 -4
  5. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/chains.py +1 -1
  6. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/cli.py +16 -0
  7. crypttrace-0.16.0/src/crypttrace/drain.py +197 -0
  8. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/ton.py +31 -0
  9. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/funder.py +1 -1
  10. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/render.py +67 -2
  11. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/store.py +17 -5
  12. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/verify.py +6 -1
  13. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/web/index.html +45 -0
  14. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/webapp.py +16 -0
  15. {crypttrace-0.14.0 → crypttrace-0.16.0/src/crypttrace.egg-info}/PKG-INFO +32 -1
  16. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
  17. {crypttrace-0.14.0 → crypttrace-0.16.0}/LICENSE +0 -0
  18. {crypttrace-0.14.0 → crypttrace-0.16.0}/pyproject.toml +0 -0
  19. {crypttrace-0.14.0 → crypttrace-0.16.0}/setup.cfg +0 -0
  20. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/addresses.py +0 -0
  21. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/assess.py +0 -0
  22. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/assets.py +0 -0
  23. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/bridges.py +0 -0
  24. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/config.py +0 -0
  25. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/__init__.py +0 -0
  26. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  27. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  28. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/http.py +0 -0
  29. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/solana.py +0 -0
  30. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/tron.py +0 -0
  31. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/freeze.py +0 -0
  32. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/investigate.py +0 -0
  33. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/__init__.py +0 -0
  34. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/audit.py +0 -0
  35. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/bulk.py +0 -0
  36. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/known.json +0 -0
  37. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/labels.py +0 -0
  38. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/partial.json +0 -0
  39. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/tonapi.py +0 -0
  40. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/offramp.py +0 -0
  41. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/poisoning.py +0 -0
  42. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/prices.py +0 -0
  43. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/report.py +0 -0
  44. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/spam.py +0 -0
  45. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/trace.py +0 -0
  46. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/watch.py +0 -0
  47. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  48. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  49. {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace.egg-info/requires.txt +0 -0
  50. {crypttrace-0.14.0 → crypttrace-0.16.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.14.0
3
+ Version: 0.16.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
@@ -129,6 +129,30 @@ them (`UQ…`); jetton transfers are reported between the owners' wallets.
129
129
 
130
130
  ---
131
131
 
132
+ ## Was my wallet drained?
133
+
134
+ ```bash
135
+ crypttrace check YOUR_ADDRESS --chain ton
136
+ ```
137
+
138
+ For someone who just found their wallet empty. `check` reads the wallet's
139
+ history and answers in plain language:
140
+
141
+ - **drained or not** — several signed transfers moving everything (tokens, NFTs,
142
+ then the coin) to one new address within minutes, leaving the wallet empty:
143
+ a program holding your key did that, not you;
144
+ - **a mass drain?** — whether the same address emptied other wallets in the same
145
+ hour (leaked keys from a shared service or tool, not a scam aimed at you);
146
+ - **is the key still in use?** — money that arrived after the drain and was
147
+ taken too;
148
+ - **where the money went next** — exchange deposits with the memo that names the
149
+ account (see *Exchange deposits with a memo*);
150
+ - **what to do now** — what is still on the wallet worth moving (on TON: real
151
+ jetton balances and NFTs from trusted collections, not airdropped spam), and
152
+ who to report to.
153
+
154
+ The web UI has the same check in its **Was I drained?** tab.
155
+
132
156
  ## Web UI
133
157
 
134
158
  ```bash
@@ -412,6 +436,13 @@ flip the timing verdict entirely. Each chain has a dust threshold; override with
412
436
  paged rather than read one page deep, so an old sweep isn't hidden behind recent
413
437
  spam.
414
438
 
439
+ **On TON, only payments count.** A drainer's collector also receives notes
440
+ people send the attacker, dust from look-alike addresses, and gas refunds and
441
+ notices from jetton wallets and NFT items. None of those senders are victims,
442
+ and `victims` leaves them out. It also counts the NFTs (Telegram usernames,
443
+ numbers, domains) each wallet lost — a victim who lost only NFTs is listed too,
444
+ with an `nfts` column in the CSV. Failed (aborted) NFT transfers are not counted.
445
+
415
446
  ### Bitcoin clustering (common-input-ownership)
416
447
 
417
448
  Bitcoin's UTXO model enables the strongest clustering heuristic in blockchain
@@ -95,6 +95,30 @@ them (`UQ…`); jetton transfers are reported between the owners' wallets.
95
95
 
96
96
  ---
97
97
 
98
+ ## Was my wallet drained?
99
+
100
+ ```bash
101
+ crypttrace check YOUR_ADDRESS --chain ton
102
+ ```
103
+
104
+ For someone who just found their wallet empty. `check` reads the wallet's
105
+ history and answers in plain language:
106
+
107
+ - **drained or not** — several signed transfers moving everything (tokens, NFTs,
108
+ then the coin) to one new address within minutes, leaving the wallet empty:
109
+ a program holding your key did that, not you;
110
+ - **a mass drain?** — whether the same address emptied other wallets in the same
111
+ hour (leaked keys from a shared service or tool, not a scam aimed at you);
112
+ - **is the key still in use?** — money that arrived after the drain and was
113
+ taken too;
114
+ - **where the money went next** — exchange deposits with the memo that names the
115
+ account (see *Exchange deposits with a memo*);
116
+ - **what to do now** — what is still on the wallet worth moving (on TON: real
117
+ jetton balances and NFTs from trusted collections, not airdropped spam), and
118
+ who to report to.
119
+
120
+ The web UI has the same check in its **Was I drained?** tab.
121
+
98
122
  ## Web UI
99
123
 
100
124
  ```bash
@@ -378,6 +402,13 @@ flip the timing verdict entirely. Each chain has a dust threshold; override with
378
402
  paged rather than read one page deep, so an old sweep isn't hidden behind recent
379
403
  spam.
380
404
 
405
+ **On TON, only payments count.** A drainer's collector also receives notes
406
+ people send the attacker, dust from look-alike addresses, and gas refunds and
407
+ notices from jetton wallets and NFT items. None of those senders are victims,
408
+ and `victims` leaves them out. It also counts the NFTs (Telegram usernames,
409
+ numbers, domains) each wallet lost — a victim who lost only NFTs is listed too,
410
+ with an `nfts` column in the CSV. Failed (aborted) NFT transfers are not counted.
411
+
381
412
  ### Bitcoin clustering (common-input-ownership)
382
413
 
383
414
  Bitcoin's UTXO model enables the strongest clustering heuristic in blockchain
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.14.0"
2
+ __version__ = "0.16.0"
@@ -51,8 +51,8 @@ def _direct(address: str, chain: str, asset: Optional[dict], limit: int,
51
51
  continue
52
52
  other = r.get(far)
53
53
  val = r.get("value", 0) or 0
54
- if not other or val <= 0 or val < min_value:
55
- continue
54
+ if not other or val <= 0 or val < min_value or r.get("kind"):
55
+ continue # TON: gas refunds, notices and notes are not payments
56
56
  rec = agg.setdefault(other, {"address": other, "value": 0.0, "txs": 0,
57
57
  "first_ts": None, "last_ts": None})
58
58
  rec["value"] += val
@@ -97,6 +97,7 @@ def collect_sources(address: str, chain: str = "btc", depth: int = 1,
97
97
  found[key]["txs"] += rec["txs"]
98
98
  continue
99
99
  rec = dict(rec)
100
+ rec.setdefault("nfts", 0)
100
101
  rec["hop"] = hop
101
102
  rec["into"] = node
102
103
  rec["label"] = labels.label_of(addr)
@@ -105,11 +106,32 @@ def collect_sources(address: str, chain: str = "btc", depth: int = 1,
105
106
  if key not in seen:
106
107
  seen.add(key)
107
108
  next_frontier.append(addr)
109
+ if chain == "ton" and asset is None:
110
+ _add_nfts(found, node, hop)
108
111
  frontier = next_frontier
109
112
  if not frontier or len(found) >= max_addresses:
110
113
  break
111
114
 
112
- return sorted(found.values(), key=lambda r: r["value"], reverse=True)
115
+ return sorted(found.values(), key=lambda r: (r["value"], r.get("nfts", 0)), reverse=True)
116
+
117
+
118
+ def _add_nfts(found: Dict[str, dict], node: str, hop: int) -> None:
119
+ """Count NFTs each sender handed to `node` — a drained wallet often loses only its NFTs."""
120
+ from crypttrace.fetchers import ton
121
+ try:
122
+ rows = ton.nft_transfers(node, 1000)
123
+ except ton.TonError:
124
+ return
125
+ for r in rows:
126
+ if r["to"] != node or not r["from"] or r["from"] == node:
127
+ continue
128
+ rec = found.setdefault(r["from"], {"address": r["from"], "value": 0.0, "txs": 0, "nfts": 0,
129
+ "first_ts": r["timestamp"], "last_ts": r["timestamp"],
130
+ "hop": hop, "into": node,
131
+ "label": labels.label_of(r["from"]),
132
+ "type": labels.type_of(r["from"])})
133
+ rec["nfts"] = rec.get("nfts", 0) + 1
134
+ rec["first_ts"] = min(rec["first_ts"] or r["timestamp"], r["timestamp"])
113
135
 
114
136
 
115
137
  def export_csv(rows: List[dict], path: Path, chain: str, symbol: str) -> Path:
@@ -117,6 +139,9 @@ def export_csv(rows: List[dict], path: Path, chain: str, symbol: str) -> Path:
117
139
  path.parent.mkdir(parents=True, exist_ok=True)
118
140
  cols = ["address", "hop", f"amount_sent_{symbol}", "transactions",
119
141
  "first_seen_utc", "last_seen_utc", "sent_into", "label", "explorer"]
142
+ nfts = any(r.get("nfts") for r in rows)
143
+ if nfts:
144
+ cols.append("nfts")
120
145
  with open(path, "w", newline="", encoding="utf-8") as fh:
121
146
  w = csv.writer(fh)
122
147
  w.writerow(cols)
@@ -124,7 +149,7 @@ def export_csv(rows: List[dict], path: Path, chain: str, symbol: str) -> Path:
124
149
  w.writerow([r["address"], r.get("hop", 1), round(r["value"], 8), r["txs"],
125
150
  _ts(r.get("first_ts")), _ts(r.get("last_ts")),
126
151
  r.get("into", ""), r.get("label", ""),
127
- chains.explorer_url(r["address"], chain)])
152
+ chains.explorer_url(r["address"], chain)] + ([r.get("nfts", 0)] if nfts else []))
128
153
  return path
129
154
 
130
155
 
@@ -242,7 +242,7 @@ def flows(address: str, chain: str, top: int, direction: str = "out",
242
242
  if r.get(near) != me:
243
243
  continue
244
244
  other = r.get(far)
245
- if not other or r.get("value", 0) <= 0:
245
+ if not other or r.get("value", 0) <= 0 or r.get("kind"):
246
246
  continue
247
247
  rec = agg.setdefault(other, [0.0, 0])
248
248
  rec[0] += r["value"]
@@ -166,6 +166,22 @@ def profile(
166
166
  console.print("[dim]No transactions found for this address on this chain.[/dim]")
167
167
 
168
168
 
169
+ @app.command()
170
+ def check(
171
+ address: str = typer.Argument(..., help="Your wallet address"),
172
+ chain: str = CHAIN_OPT,
173
+ ):
174
+ """Was this wallet drained? Who took it, whether others were hit too, what to do now."""
175
+ from crypttrace import drain
176
+ try:
177
+ with console.status("Reading the wallet's history…"):
178
+ res = drain.check(address, chain)
179
+ except (chains_mod.ChainError, etherscan.EtherscanError) as e:
180
+ console.print(f"[red]Error:[/red] {e}")
181
+ raise typer.Exit(1)
182
+ console.print(render.drain_report(res))
183
+
184
+
169
185
  @app.command()
170
186
  def trace(
171
187
  address: str = typer.Argument(..., help="Starting address (0x…)"),
@@ -0,0 +1,197 @@
1
+ """Was this wallet drained? — the check a victim can run on their own address.
2
+
3
+ A drain looks the same on every chain where the attacker holds the key: within
4
+ minutes, several signed transfers move everything — tokens, NFTs, then the coin
5
+ itself — to one address the wallet never paid before, and the wallet is left
6
+ empty. Nobody approves a dozen transfers a minute by hand, so a burst like that
7
+ means the key or seed phrase is in someone else's hands.
8
+
9
+ Three more facts turn "my wallet is empty" into something a victim can act on:
10
+ - the same address emptied many other wallets in the same hour: a mass drain
11
+ (leaked keys from a shared service or tool), not a scam aimed at you;
12
+ - money that arrived after the drain was taken too: the attacker still has
13
+ the key, and anything sent to this wallet will be lost;
14
+ - where the money went next — an exchange, with the deposit memo that names
15
+ the account there.
16
+ """
17
+ from collections import defaultdict
18
+ from typing import Dict, List, Optional
19
+
20
+ from crypttrace import analysis, chains, spam
21
+ from crypttrace.labels import labels
22
+
23
+ WINDOW = 15 * 60 # a drain empties a wallet within minutes
24
+ BURST = 120 # signed transfers this close together are automated
25
+ MASS_SENDERS = 10 # wallets swept into one address within an hour of yours
26
+ MASS_SPAN = 3600
27
+ _ALL_TOKENS = {"contract": None, "symbol": "*"}
28
+
29
+
30
+ def _history(address: str, chain: str, limit: int) -> List[dict]:
31
+ rows = list(chains.transfers(address, chain, limit))
32
+ if chain != "btc":
33
+ try:
34
+ rows += [r for r in chains.transfers(address, chain, limit, asset=_ALL_TOKENS)
35
+ if not spam.verdict(chain, r.get("contract", ""), r.get("symbol", ""),
36
+ r.get("name", ""), bool(r.get("flagged")))]
37
+ except chains.ChainError:
38
+ pass
39
+ if chain == "ton":
40
+ from crypttrace.fetchers import ton
41
+ try:
42
+ rows += ton.nft_transfers(address, limit)
43
+ except ton.TonError:
44
+ pass
45
+ return rows
46
+
47
+
48
+ def _best_burst(rows: List[dict], native: str) -> Optional[dict]:
49
+ """The WINDOW of outgoing transfers to one destination that took the most:
50
+ most kinds of asset first, then the most of the chain's own coin."""
51
+ by_dest: Dict[str, List[dict]] = defaultdict(list)
52
+ for r in rows:
53
+ by_dest[r["to"]].append(r)
54
+ best = None
55
+ for dest, rs in by_dest.items():
56
+ rs.sort(key=lambda r: r["timestamp"])
57
+ for i, first in enumerate(rs):
58
+ win = [r for r in rs[i:] if r["timestamp"] - first["timestamp"] <= WINDOW]
59
+ assets = {r.get("symbol") for r in win}
60
+ coin = sum(r.get("value", 0) or 0 for r in win if r.get("symbol") == native)
61
+ score = (len(assets), coin, len({r.get("hash") for r in win}))
62
+ if best is None or score > best["score"]:
63
+ best = {"score": score, "to": dest, "rows": win,
64
+ "start": first["timestamp"], "end": win[-1]["timestamp"]}
65
+ return best
66
+
67
+
68
+ def _amounts(rows: List[dict]) -> Dict[str, float]:
69
+ tot: Dict[str, float] = defaultdict(float)
70
+ for r in rows:
71
+ tot[r.get("symbol") or "?"] += r.get("value", 0) or 0
72
+ return dict(tot)
73
+
74
+
75
+ def check(address: str, chain: str, limit: int = 1000) -> Dict:
76
+ """Verdict on one wallet: drained (and how), suspicious, or no drain found."""
77
+ me = chains.norm_addr(address, chain)
78
+ rows = _history(address, chain, limit)
79
+ out = [r for r in rows if r.get("from") == me and r.get("to") and r.get("to") != me
80
+ and not r.get("kind") and (r.get("value") or 0) > 0]
81
+ res: Dict = {"address": me, "chain": chain, "verdict": "no_drain", "reasons": []}
82
+ try:
83
+ res["balance"] = chains.balance(address, chain)
84
+ except chains.ChainError:
85
+ res["balance"] = None
86
+ if not out:
87
+ res["reasons"].append("this wallet has sent nothing in the history read")
88
+ return res
89
+
90
+ burst = _best_burst(out, chains.symbol(chain))
91
+ dest, win = burst["to"], burst["rows"]
92
+ labels.enrich([dest], chain)
93
+ paid_before = any(r.get("to") == dest and r["timestamp"] < burst["start"] for r in out)
94
+ times = sorted(r["timestamp"] for r in win)
95
+ tight = sum(1 for a, b in zip(times, times[1:]) if b - a <= BURST)
96
+ dust = analysis.dust_threshold(chain)
97
+ emptied = res["balance"] is not None and res["balance"] <= max(dust, 1e-9) * 5
98
+ nfts = sum(1 for r in win if r.get("symbol") == "NFT")
99
+ assets = _amounts([r for r in win if r.get("symbol") != "NFT"])
100
+ res.update({"to": dest, "to_label": labels.label_of(dest), "to_type": labels.type_of(dest),
101
+ "start": burst["start"], "end": burst["end"], "transfers": len({r.get("hash") for r in win}),
102
+ "assets": assets, "nfts": nfts, "emptied": emptied, "new_destination": not paid_before,
103
+ "tight_pairs": tight})
104
+
105
+ # a mass drain: the same address swept many wallets around the same time
106
+ try:
107
+ inflow = _history(dest, chain, limit) if labels.type_of(dest) != "exchange" else []
108
+ except chains.ChainError:
109
+ inflow = []
110
+ senders = {r["from"] for r in inflow
111
+ if r.get("to") == dest and not r.get("kind") and r.get("from") != dest
112
+ and abs(r["timestamp"] - burst["start"]) <= MASS_SPAN}
113
+ res["other_wallets_same_hour"] = max(0, len(senders) - 1)
114
+ mass = res["other_wallets_same_hour"] >= MASS_SENDERS
115
+
116
+ automated = tight >= 2 or (len(assets) + (1 if nfts else 0)) >= 2 or mass
117
+ if labels.type_of(dest) == "exchange":
118
+ res["reasons"].append("the largest outflow went to an exchange — usually your own deposit")
119
+ elif emptied and automated and not paid_before:
120
+ res["verdict"] = "drained"
121
+ elif emptied and not paid_before:
122
+ res["verdict"] = "suspicious"
123
+ if res["verdict"] == "no_drain":
124
+ return res
125
+
126
+ if tight >= 2 or len(assets) + (1 if nfts else 0) >= 2:
127
+ res["reasons"].append(f"{res['transfers']} transfers to one new address within "
128
+ f"{max(1, (burst['end'] - burst['start']) // 60)} min, "
129
+ "several seconds apart — signed by a program holding your key")
130
+ if emptied:
131
+ res["reasons"].append("the wallet was left empty")
132
+ if labels.type_of(dest) == "scam":
133
+ res["reasons"].append(f"the receiving address is a known drainer: {labels.label_of(dest)}")
134
+
135
+ if mass:
136
+ res["reasons"].append(f"the same address took money from {res['other_wallets_same_hour']} other "
137
+ "wallets within an hour — a mass drain from leaked keys, not a scam aimed at you")
138
+
139
+ # taken again after the drain: the attacker still has the key
140
+ later = [r for r in out if r["timestamp"] > burst["end"] + 60 and r.get("to") == dest]
141
+ res["resweeps"] = len({r.get("hash") for r in later})
142
+ if res["resweeps"]:
143
+ res["reasons"].append(f"{res['resweeps']} later transfer(s) to the same address after the wallet "
144
+ "was topped up — the attacker still has the key")
145
+
146
+ # where it went next: exchange deposits (with memo) of the receiver and its next hop
147
+ nxt = []
148
+ try:
149
+ hops = [dest] + [o for o, _, _ in chains.outflows(dest, chain, 3)]
150
+ labels.enrich(hops, chain)
151
+ for h in hops:
152
+ for d in chains.exchange_deposits(h, chain):
153
+ nxt.append({**d, "from": h})
154
+ if h != dest and labels.type_of(h) == "exchange":
155
+ nxt.append({"exchange_address": h, "exchange": labels.label_of(h), "memo": "",
156
+ "value": None, "txs": None, "symbol": "", "from": dest})
157
+ except chains.ChainError:
158
+ pass
159
+ res["next"] = nxt
160
+
161
+ # what can still be saved
162
+ if chain == "ton":
163
+ res["remaining_tokens"], res["remaining_nfts"] = _ton_left(me)
164
+ else:
165
+ keep = []
166
+ try:
167
+ keep = [h for h in chains.token_holdings(address, chain) if not h.get("spam")]
168
+ except chains.ChainError:
169
+ pass
170
+ res["remaining_tokens"] = [{"symbol": h["symbol"], "amount": h["net"]} for h in keep[:10]]
171
+ return res
172
+
173
+
174
+ def _ton_left(address: str):
175
+ """Jettons the wallet holds now (not spam), and NFTs from collections tonapi trusts —
176
+ airdropped spam NFTs are not worth the gas to move."""
177
+ from crypttrace.fetchers import http, ton
178
+ tokens = []
179
+ try:
180
+ for w in ton._get("/jetton/wallets", {"owner_address": address, "limit": 100}).get("jetton_wallets") or []:
181
+ master = (w.get("jetton") or "").lower()
182
+ meta = ton._meta(master)
183
+ if not meta or not int(w.get("balance") or 0):
184
+ continue
185
+ if spam.verdict("ton", master, meta["symbol"], meta["name"], meta["scam"]):
186
+ continue
187
+ tokens.append({"symbol": meta["symbol"], "amount": int(w["balance"]) / 10 ** meta["decimals"]})
188
+ except (ton.TonError, ValueError, TypeError):
189
+ pass
190
+ nfts = None
191
+ try:
192
+ items = http.request_json(f"https://tonapi.io/v2/accounts/{address}/nfts", {"limit": 100},
193
+ timeout=20).get("nft_items") or []
194
+ nfts = sum(1 for i in items if i.get("trust") == "whitelist" or i.get("approved_by"))
195
+ except Exception: # tonapi down: say nothing rather than guess
196
+ pass
197
+ return tokens[:10], nfts
@@ -70,6 +70,21 @@ def _addr(raw: Optional[str]) -> str:
70
70
  return addresses.ton_friendly(raw or "") or (raw or "")
71
71
 
72
72
 
73
+ # messages contracts send back as part of a transfer: gas refunds ("excesses"),
74
+ # jetton and NFT arrival notices, bounces. They carry TON, but nobody *sent* it.
75
+ SERVICE_OPS = {"0xd53276db", "0x7362d09c", "0x05138d91", "0xffffffff"}
76
+ MESSAGE_MAX = 0.05 # a comment with less TON than this is a note, not a payment
77
+
78
+
79
+ def kind_of(opcode, memo: str, value: float) -> str:
80
+ """'service' for contract plumbing, 'message' for a note sent to an address, else ''."""
81
+ if opcode in SERVICE_OPS:
82
+ return "service"
83
+ if memo and value < MESSAGE_MAX:
84
+ return "message"
85
+ return ""
86
+
87
+
73
88
  def _memo(text) -> str:
74
89
  """A transfer comment, cleaned — exchanges read deposit memos from it."""
75
90
  return str(text or "").replace("\x00", "").strip()
@@ -110,6 +125,9 @@ def transfers(address: str, limit: int = 1000) -> List[Dict]:
110
125
  memo = _memo(((m.get("message_content") or {}).get("decoded") or {}).get("comment"))
111
126
  if memo:
112
127
  row["memo"] = memo
128
+ kind = kind_of(m.get("opcode"), memo, value)
129
+ if kind:
130
+ row["kind"] = kind
113
131
  rows.append(row)
114
132
  return rows
115
133
 
@@ -135,6 +153,19 @@ def _meta(master: str) -> Optional[dict]:
135
153
  return _META[master]
136
154
 
137
155
 
156
+ def nft_transfers(address: str, limit: int = 1000) -> List[Dict]:
157
+ """NFTs (Telegram usernames, numbers, domains, collectibles) moved to or from `address`."""
158
+ rows = []
159
+ for t in _pages("/nft/transfers", "nft_transfers", {"owner_address": address}, limit, JETTON_PAGE):
160
+ if t.get("transaction_aborted"):
161
+ continue
162
+ rows.append({"from": _addr(t.get("old_owner")), "to": _addr(t.get("new_owner")),
163
+ "value": 1.0, "symbol": "NFT", "timestamp": int(t.get("transaction_now") or 0),
164
+ "hash": _hex(t.get("transaction_hash", "")), "item": _addr(t.get("nft_address")),
165
+ "contract": (t.get("nft_collection") or t.get("nft_address") or "").lower()})
166
+ return rows
167
+
168
+
138
169
  def jetton_transfers(address: str, limit: int = 1000) -> List[Dict]:
139
170
  """Jetton (token) transfers to and from `address`, between owner wallets."""
140
171
  rows = []
@@ -30,7 +30,7 @@ def first_funder(address: str, chain: str = "eth") -> Optional[dict]:
30
30
  dust = analysis.dust_threshold(chain)
31
31
  for r in rows: # ascending => first match is the earliest funding
32
32
  # dust is not funding: it is what poisoners and spammers send everyone
33
- if r.get("to") != me or r.get("value", 0) < max(dust, 1e-18):
33
+ if r.get("to") != me or r.get("value", 0) < max(dust, 1e-18) or r.get("kind"):
34
34
  continue
35
35
  return {
36
36
  "funder": r.get("from", ""),
@@ -141,10 +141,13 @@ def sources_table(rows: list, symbol: str, top: int = 25) -> Table:
141
141
  t.add_column(f"Sent ({symbol})", justify="right")
142
142
  t.add_column("Txs", justify="right")
143
143
  t.add_column("When (UTC)")
144
+ nfts = any(r.get("nfts") for r in rows)
145
+ if nfts:
146
+ t.add_column("NFTs", justify="right")
144
147
  for r in rows[:top]:
145
148
  t.add_row(addr_label(r["address"]), str(r.get("hop", 1)),
146
- f"{r['value']:.8f}".rstrip("0").rstrip("."), str(r["txs"]),
147
- _ts(str(r.get("first_ts") or 0)))
149
+ f"{r['value']:.8f}".rstrip("0").rstrip(".") or "0", str(r["txs"]),
150
+ _ts(str(r.get("first_ts") or 0)), *([str(r.get("nfts") or "")] if nfts else []))
148
151
  return t
149
152
 
150
153
 
@@ -224,6 +227,68 @@ def funding_tree(address: str, hops: list, symbol: str = "ETH") -> Tree:
224
227
  return root
225
228
 
226
229
 
230
+ def drain_report(res: dict):
231
+ """What `crypttrace check` prints: verdict, what was taken, where it went, what to do."""
232
+ from rich.console import Group
233
+ from rich.panel import Panel
234
+ verdict = {
235
+ "drained": ("[bold red]DRAINED[/bold red] — someone who has this wallet's key or seed "
236
+ "phrase emptied it.", "red"),
237
+ "suspicious": ("[bold yellow]POSSIBLY DRAINED[/bold yellow] — the wallet was emptied to a new "
238
+ "address. If you did not send it, treat the key as leaked.", "yellow"),
239
+ "no_drain": ("[bold green]No drain found[/bold green] in the history read.", "green"),
240
+ }[res["verdict"]]
241
+ parts = [Text.from_markup(verdict[0])]
242
+ for r in res.get("reasons", []):
243
+ parts.append(Text(f" • {r}"))
244
+ if res["verdict"] != "no_drain":
245
+ taken = ", ".join(f"{v:,.4f} {k}" for k, v in res.get("assets", {}).items())
246
+ if res.get("nfts"):
247
+ taken += f"{', ' if taken else ''}{res['nfts']} NFT"
248
+ t = Table(show_header=False, box=None, pad_edge=False)
249
+ t.add_column(style="dim")
250
+ t.add_column()
251
+ t.add_row("when (UTC)", _ts(str(res.get("start") or 0)))
252
+ t.add_row("taken", escape(taken or "—"))
253
+ t.add_row("to", escape(res["to"] + (f" [{res['to_label']}]" if res.get("to_label") else "")))
254
+ if res.get("other_wallets_same_hour") is not None:
255
+ t.add_row("same hour", f"this address emptied {res['other_wallets_same_hour']} other wallet(s)")
256
+ parts += [Text(""), t]
257
+ if res.get("next"):
258
+ nx = Table(title="Where the money went next — the memo names one account at the exchange",
259
+ header_style="bold")
260
+ nx.add_column("Exchange")
261
+ nx.add_column("Memo", style="bold green")
262
+ nx.add_column("Amount", justify="right")
263
+ nx.add_column("Deposited from")
264
+ for d in res["next"]:
265
+ amt = f"{d['value']:,.4f} {d['symbol']}" if d.get("value") is not None else "—"
266
+ nx.add_row(escape(d["exchange"]), escape(d.get("memo") or "—"), amt,
267
+ escape(d["from"][:10] + "…" + d["from"][-6:]))
268
+ parts += [Text(""), nx]
269
+ steps = ["This seed phrase is burned. Make a new wallet with a new seed phrase; never type it "
270
+ "into a site, bot or \"support\" chat again.",
271
+ "Send nothing to this wallet — " + ("the attacker took money that arrived after the "
272
+ "drain, too." if res.get("resweeps") else
273
+ "anything that arrives can be taken."),
274
+ "Other wallets made from the same seed phrase (other versions, other networks) are "
275
+ "exposed too: move them first."]
276
+ left = [f"{x['amount']:,.4f} {x['symbol']}" for x in res.get("remaining_tokens", [])]
277
+ if res.get("remaining_nfts"):
278
+ left.append(f"{res['remaining_nfts']} NFT")
279
+ if left:
280
+ steps.insert(1, "Move what is still here to the new wallet right away: " + ", ".join(left) + ".")
281
+ if any(d.get("memo") for d in res.get("next", [])):
282
+ steps.append("Report it to the police and to the exchange above, quoting the memo and the "
283
+ "transaction: the exchange can freeze the account and identify its owner.")
284
+ else:
285
+ steps.append("Report it to the police with this wallet, the receiving address and the time.")
286
+ parts += [Text(""), Text("What to do now", style="bold")]
287
+ parts += [Text(f" {i}. {s}") for i, s in enumerate(steps, 1)]
288
+ return Panel(Group(*parts), title=f"Drain check — {res['address']} ({res['chain']})",
289
+ border_style=verdict[1])
290
+
291
+
227
292
  def deposits_table(deposits: list) -> Table:
228
293
  """Exchange deposits made with a memo — each memo names one account at the exchange."""
229
294
  t = Table(title="Exchange deposits — the memo names one account at the exchange (KYC)",
@@ -77,6 +77,15 @@ def _conn() -> sqlite3.Connection:
77
77
  c.execute("DELETE FROM fetched WHERE chain='ton'")
78
78
  c.execute("PRAGMA user_version=2")
79
79
  c.commit()
80
+ if c.execute("PRAGMA user_version").fetchone()[0] < 3:
81
+ # 0.15: TON rows say whether they are contract plumbing or a note
82
+ cols = [r[1] for r in c.execute("PRAGMA table_info(transfers)")]
83
+ if "kind" not in cols:
84
+ c.execute("ALTER TABLE transfers ADD COLUMN kind TEXT")
85
+ c.execute("DELETE FROM transfers WHERE chain='ton'")
86
+ c.execute("DELETE FROM fetched WHERE chain='ton'")
87
+ c.execute("PRAGMA user_version=3")
88
+ c.commit()
80
89
  return c
81
90
 
82
91
 
@@ -100,12 +109,13 @@ def save(chain: str, address: str, rows: List[dict], asset_key: str = "",
100
109
  try:
101
110
  c.executemany(
102
111
  "INSERT OR REPLACE INTO transfers"
103
- "(id,chain,tx_hash,from_addr,to_addr,value,symbol,contract,ts,fee_share,memo)"
104
- " VALUES (?,?,?,?,?,?,?,?,?,?,?)",
112
+ "(id,chain,tx_hash,from_addr,to_addr,value,symbol,contract,ts,fee_share,memo,kind)"
113
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
105
114
  [(_row_id(chain, r), chain, r.get("hash", ""), r.get("from", ""),
106
115
  r.get("to", ""), float(r.get("value", 0) or 0), r.get("symbol", ""),
107
116
  r.get("contract", ""), int(r.get("timestamp", 0) or 0),
108
- float(r.get("fee_share", 0) or 0), r.get("memo") or None) for r in rows])
117
+ float(r.get("fee_share", 0) or 0), r.get("memo") or None,
118
+ r.get("kind") or None) for r in rows])
109
119
  c.execute("INSERT OR REPLACE INTO fetched(chain,address,asset,fetched_at,rows,complete)"
110
120
  " VALUES (?,?,?,?,?,?)",
111
121
  (chain, address, asset_key, int(time.time()), len(rows), int(complete)))
@@ -135,7 +145,7 @@ def is_fresh(chain: str, address: str, asset_key: str = "",
135
145
  def load(chain: str, address: str, contract: str = "", native_symbol: str = "") -> List[dict]:
136
146
  """Every stored transfer touching this address, newest first."""
137
147
  c = _conn()
138
- cols = "SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share,memo FROM transfers"
148
+ cols = "SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share,memo,kind FROM transfers"
139
149
  try:
140
150
  if contract == "*":
141
151
  # every token, whatever its contract (address-poisoning checks need fakes too)
@@ -161,7 +171,7 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
161
171
  finally:
162
172
  c.close()
163
173
  out = []
164
- for f, t, v, sym, con, ts, h, fee, memo in rows:
174
+ for f, t, v, sym, con, ts, h, fee, memo, kind in rows:
165
175
  r = {"from": f, "to": t, "value": v, "symbol": sym,
166
176
  "timestamp": ts, "hash": h}
167
177
  if con:
@@ -170,6 +180,8 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
170
180
  r["fee_share"] = fee
171
181
  if memo:
172
182
  r["memo"] = memo
183
+ if kind:
184
+ r["kind"] = kind
173
185
  out.append(r)
174
186
  return out
175
187
 
@@ -21,6 +21,9 @@ from crypttrace.fetchers import bitcoin
21
21
  # how far computed and reported totals may drift before we complain
22
22
  REL_TOLERANCE = 0.005 # 0.5%
23
23
  ABS_TOLERANCE = 1e-6
24
+ # fee an account-based chain may charge per outgoing transaction, which never
25
+ # shows up as a transfer: the net-vs-balance check allows this much per tx
26
+ GAS_PER_TX = {"ton": 0.02}
24
27
 
25
28
 
26
29
  def _within(a: float, b: float) -> bool:
@@ -99,7 +102,9 @@ def reconcile(address: str, chain: str = "btc", asset: Optional[dict] = None,
99
102
  if ref_in is None and ref_balance is not None and asset is None and complete:
100
103
  # no aggregate feed: fall back to net vs balance (fees make it inexact)
101
104
  net = got_in - got_out
102
- ok = abs(net - ref_balance) <= max(0.01, abs(ref_balance) * 0.02)
105
+ n_out = len({r.get("hash") for r in rows if r.get("from") == me})
106
+ ok = abs(net - ref_balance) <= max(0.01, abs(ref_balance) * 0.02,
107
+ GAS_PER_TX.get(chain, 0) * n_out)
103
108
  checks.append(("net vs balance", net, ref_balance, ok))
104
109
  if not ok:
105
110
  notes.append("net flow differs from the current balance; on account-based "
@@ -192,6 +192,7 @@
192
192
  .t-unknown,.t-offramp{color:var(--dim);background:rgba(147,161,181,.12)}
193
193
  .muted{color:var(--dim);font-size:13px;line-height:1.55}
194
194
  .err{color:var(--red);font-size:13px}
195
+ .warn{color:#ffc857;font-size:13px}
195
196
  .alert{border-color:rgba(255,107,96,.4);background:rgba(255,107,96,.08)}
196
197
  a.btnlink{display:inline-block;margin-top:9px;font-size:12.5px;color:var(--accent);text-decoration:none;
197
198
  padding:7px 12px;border-radius:10px;background:rgba(77,157,255,.10);border:1px solid rgba(77,157,255,.28);transition:.25s}
@@ -248,6 +249,7 @@
248
249
  <button class="tab" data-view="timeline">Timeline</button>
249
250
  <button class="tab" data-view="victims">Who sent funds here</button>
250
251
  <button class="tab" data-view="poison">Poisoning</button>
252
+ <button class="tab" data-view="check">Was I drained?</button>
251
253
  </div>
252
254
  <div class="stage">
253
255
  <div class="loader" id="loader"></div>
@@ -258,6 +260,7 @@
258
260
  <div class="view" id="v-timeline"></div>
259
261
  <div class="view" id="v-victims"></div>
260
262
  <div class="view" id="v-poison"></div>
263
+ <div class="view" id="v-check"></div>
261
264
  <div class="scaleview" id="scaleview" style="display:none">100%</div>
262
265
  <div class="zoomctl" id="zoomctl">
263
266
  <button id="zin" title="Zoom in">+</button>
@@ -536,6 +539,7 @@ function showView(v){
536
539
  $('#v-timeline').classList.toggle('on',v==='timeline');
537
540
  $('#v-victims').classList.toggle('on',v==='victims');
538
541
  $('#v-poison').classList.toggle('on',v==='poison');
542
+ $('#v-check').classList.toggle('on',v==='check');
539
543
  const a=$('#addr').value.trim();
540
544
  if(a && !loaded[v+a]) {
541
545
  loaded[v+a]=true;
@@ -543,6 +547,7 @@ function showView(v){
543
547
  if(v==='victims') loadVictims(a);
544
548
  if(v==='assess') loadAssessment(a);
545
549
  if(v==='poison') loadPoisoning(a);
550
+ if(v==='check') loadCheck(a);
546
551
  }
547
552
  }
548
553
  document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>showView(t.dataset.view));
@@ -610,6 +615,46 @@ async function loadAssessment(a){
610
615
  }
611
616
 
612
617
  let victimRows=[], victimSym='';
618
+ async function loadCheck(a){
619
+ const el=$('#v-check');
620
+ el.innerHTML='<div class="muted"><span class="spin"></span>Reading the wallet\'s history…</div>';
621
+ try{
622
+ const r=await api(`/api/check?address=${q(a)}&chain=${chain()}`);
623
+ const head={drained:['err','DRAINED — someone who has this wallet\'s key or seed phrase emptied it.'],
624
+ suspicious:['warn','POSSIBLY DRAINED — the wallet was emptied to a new address. If you did not send it, treat the key as leaked.'],
625
+ no_drain:['muted','No drain found in the history read.']}[r.verdict];
626
+ let h=`<div class="${head[0]}" style="font-weight:600;margin-bottom:10px">${esc(head[1])}</div>`;
627
+ h+=(r.reasons||[]).map(x=>`<div>• ${esc(x)}</div>`).join('');
628
+ if(r.verdict!=='no_drain'){
629
+ const taken=Object.entries(r.assets||{}).map(([k,v])=>`${Number(v).toLocaleString()} ${esc(k)}`)
630
+ .concat(r.nfts?[`${r.nfts} NFT`]:[]).join(', ');
631
+ h+=`<table class="grid" style="margin-top:12px"><tbody>
632
+ <tr><td>when (UTC)</td><td>${dstamp(r.start)}</td></tr>
633
+ <tr><td>taken</td><td>${taken||'—'}</td></tr>
634
+ <tr><td>to</td><td class="mono"><a href="${safeUrl(r.to_explorer)}" target="_blank" rel="noopener">${esc(r.to)}</a>
635
+ ${r.to_label?`<span class="pill t-${esc(r.to_type)}" style="margin-left:6px">${esc(r.to_label)}</span>`:''}</td></tr>
636
+ <tr><td>same hour</td><td>this address emptied ${r.other_wallets_same_hour??'?'} other wallet(s)</td></tr>
637
+ </tbody></table>`;
638
+ if((r.next||[]).length){
639
+ h+=`<h3 style="margin-top:16px">Where the money went next</h3>
640
+ <div class="muted">The memo names one account at the exchange — quote it with the transaction when you report.</div>
641
+ <table class="grid"><thead><tr><th>Exchange</th><th>Memo</th><th style="text-align:right">Amount</th></tr></thead><tbody>`+
642
+ r.next.map(d=>`<tr><td>${esc(d.exchange)}</td><td class="mono"><b>${esc(d.memo||'—')}</b></td>
643
+ <td class="num">${d.value!=null?Number(d.value).toLocaleString()+' '+esc(d.symbol):'—'}</td></tr>`).join('')+'</tbody></table>';
644
+ }
645
+ const left=(r.remaining_tokens||[]).map(x=>`${Number(x.amount).toLocaleString()} ${esc(x.symbol)}`)
646
+ .concat(r.remaining_nfts?[`${r.remaining_nfts} NFT`]:[]);
647
+ h+=`<h3 style="margin-top:16px">What to do now</h3><ol>
648
+ <li>This seed phrase is burned. Make a new wallet with a new seed phrase; never type it into a site, bot or "support" chat again.</li>
649
+ ${left.length?`<li>Move what is still here to the new wallet right away: ${left.join(', ')}.</li>`:''}
650
+ <li>Send nothing to this wallet — ${r.resweeps?'the attacker took money that arrived after the drain, too.':'anything that arrives can be taken.'}</li>
651
+ <li>Other wallets made from the same seed phrase are exposed too: move them first.</li>
652
+ <li>${(r.next||[]).some(d=>d.memo)?'Report it to the police and to the exchange above, quoting the memo and the transaction.':'Report it to the police with this wallet, the receiving address and the time.'}</li></ol>`;
653
+ }
654
+ el.innerHTML=`<div style="max-width:860px">${h}</div>`;
655
+ }catch(e){ el.innerHTML=`<div class="err">${esc(e.message)}</div>`; }
656
+ }
657
+
613
658
  async function loadVictims(a){
614
659
  const el=$('#v-victims');
615
660
  el.innerHTML='<div class="muted"><span class="spin"></span>Walking the money backwards…</div>';
@@ -185,6 +185,21 @@ def create_app() -> Flask:
185
185
  v["symbol"] = asset["symbol"] if asset else chains.symbol(chain)
186
186
  return jsonify(v)
187
187
 
188
+ @app.route("/api/check")
189
+ def api_check():
190
+ from crypttrace import drain
191
+ addr = request.args.get("address", "")
192
+ chain = request.args.get("chain", "eth")
193
+ bad = validate(addr, chain)
194
+ if bad:
195
+ return jsonify({"error": bad}), 400
196
+ try:
197
+ res = drain.check(addr, chain)
198
+ except _KNOWN_ERRORS as e:
199
+ return jsonify({"error": str(e)}), 400
200
+ res["to_explorer"] = chains.explorer_url(res["to"], chain) if res.get("to") else None
201
+ return jsonify(res)
202
+
188
203
  @app.route("/api/victims")
189
204
  def api_victims():
190
205
  """Addresses that fed this wallet — in a mass theft, the victim list."""
@@ -212,6 +227,7 @@ def create_app() -> Flask:
212
227
  "address": r["address"], "hop": r.get("hop", 1),
213
228
  "value": round(r["value"], 8), "txs": r["txs"],
214
229
  "first_seen": r.get("first_ts"), "last_seen": r.get("last_ts"),
230
+ "nfts": r.get("nfts", 0),
215
231
  "label": r.get("label", ""), "type": r.get("type", "unknown"),
216
232
  "into": r.get("into", ""),
217
233
  "explorer": chains.explorer_url(r["address"], chain),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.14.0
3
+ Version: 0.16.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
@@ -129,6 +129,30 @@ them (`UQ…`); jetton transfers are reported between the owners' wallets.
129
129
 
130
130
  ---
131
131
 
132
+ ## Was my wallet drained?
133
+
134
+ ```bash
135
+ crypttrace check YOUR_ADDRESS --chain ton
136
+ ```
137
+
138
+ For someone who just found their wallet empty. `check` reads the wallet's
139
+ history and answers in plain language:
140
+
141
+ - **drained or not** — several signed transfers moving everything (tokens, NFTs,
142
+ then the coin) to one new address within minutes, leaving the wallet empty:
143
+ a program holding your key did that, not you;
144
+ - **a mass drain?** — whether the same address emptied other wallets in the same
145
+ hour (leaked keys from a shared service or tool, not a scam aimed at you);
146
+ - **is the key still in use?** — money that arrived after the drain and was
147
+ taken too;
148
+ - **where the money went next** — exchange deposits with the memo that names the
149
+ account (see *Exchange deposits with a memo*);
150
+ - **what to do now** — what is still on the wallet worth moving (on TON: real
151
+ jetton balances and NFTs from trusted collections, not airdropped spam), and
152
+ who to report to.
153
+
154
+ The web UI has the same check in its **Was I drained?** tab.
155
+
132
156
  ## Web UI
133
157
 
134
158
  ```bash
@@ -412,6 +436,13 @@ flip the timing verdict entirely. Each chain has a dust threshold; override with
412
436
  paged rather than read one page deep, so an old sweep isn't hidden behind recent
413
437
  spam.
414
438
 
439
+ **On TON, only payments count.** A drainer's collector also receives notes
440
+ people send the attacker, dust from look-alike addresses, and gas refunds and
441
+ notices from jetton wallets and NFT items. None of those senders are victims,
442
+ and `victims` leaves them out. It also counts the NFTs (Telegram usernames,
443
+ numbers, domains) each wallet lost — a victim who lost only NFTs is listed too,
444
+ with an `nfts` column in the CSV. Failed (aborted) NFT transfers are not counted.
445
+
415
446
  ### Bitcoin clustering (common-input-ownership)
416
447
 
417
448
  Bitcoin's UTXO model enables the strongest clustering heuristic in blockchain
@@ -10,6 +10,7 @@ src/crypttrace/bridges.py
10
10
  src/crypttrace/chains.py
11
11
  src/crypttrace/cli.py
12
12
  src/crypttrace/config.py
13
+ src/crypttrace/drain.py
13
14
  src/crypttrace/freeze.py
14
15
  src/crypttrace/funder.py
15
16
  src/crypttrace/investigate.py
File without changes
File without changes
File without changes