crypttrace 0.8.0__tar.gz → 0.10.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 (47) hide show
  1. {crypttrace-0.8.0/src/crypttrace.egg-info → crypttrace-0.10.0}/PKG-INFO +21 -9
  2. {crypttrace-0.8.0 → crypttrace-0.10.0}/README.md +20 -8
  3. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/cli.py +22 -7
  5. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/fetchers/tron.py +24 -4
  6. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/poisoning.py +31 -6
  7. crypttrace-0.10.0/src/crypttrace/watch.py +194 -0
  8. {crypttrace-0.8.0 → crypttrace-0.10.0/src/crypttrace.egg-info}/PKG-INFO +21 -9
  9. crypttrace-0.8.0/src/crypttrace/watch.py +0 -138
  10. {crypttrace-0.8.0 → crypttrace-0.10.0}/LICENSE +0 -0
  11. {crypttrace-0.8.0 → crypttrace-0.10.0}/pyproject.toml +0 -0
  12. {crypttrace-0.8.0 → crypttrace-0.10.0}/setup.cfg +0 -0
  13. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/addresses.py +0 -0
  14. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/analysis.py +0 -0
  15. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/assess.py +0 -0
  16. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/assets.py +0 -0
  17. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/bridges.py +0 -0
  18. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/chains.py +0 -0
  19. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/config.py +0 -0
  20. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/fetchers/__init__.py +0 -0
  21. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  22. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  23. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/fetchers/http.py +0 -0
  24. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/fetchers/solana.py +0 -0
  25. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/freeze.py +0 -0
  26. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/funder.py +0 -0
  27. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/investigate.py +0 -0
  28. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/labels/__init__.py +0 -0
  29. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/labels/audit.py +0 -0
  30. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/labels/bulk.py +0 -0
  31. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/labels/known.json +0 -0
  32. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/labels/labels.py +0 -0
  33. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/labels/partial.json +0 -0
  34. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/offramp.py +0 -0
  35. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/prices.py +0 -0
  36. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/render.py +0 -0
  37. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/report.py +0 -0
  38. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/store.py +0 -0
  39. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/trace.py +0 -0
  40. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/verify.py +0 -0
  41. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/web/index.html +0 -0
  42. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/webapp.py +0 -0
  43. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
  44. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  45. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  46. {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace.egg-info/requires.txt +0 -0
  47. {crypttrace-0.8.0 → crypttrace-0.10.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.8.0
3
+ Version: 0.10.0
4
4
  Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron and Solana
5
5
  Author: bobslayerX
6
6
  License-Expression: MIT
@@ -226,7 +226,7 @@ crypttrace timeline bc1qADDRESS --chain btc
226
226
  crypttrace crosschain 0xADDRESS --window 48
227
227
 
228
228
  # Watch addresses; alert loudly the moment funds head for an exchange
229
- crypttrace watch add 0xADDRESS --note "my stolen ETH"
229
+ crypttrace watch add TADDRESS --chain tron --note "my stolen USDT"
230
230
  crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
231
231
 
232
232
  # Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
@@ -277,15 +277,22 @@ toward an exchange (directly, or to a detected deposit address); quieter notices
277
277
  for other movement. It only alerts on activity *after* an address is added, and
278
278
  never double-alerts.
279
279
 
280
+ It works on every supported chain and watches the native coin **and the
281
+ stablecoins** (USDT, USDC) — on Tron, stolen money is almost always USDT.
282
+ Counterfeit "USDT" and zero/dust transfers from address poisoners do not
283
+ alert; they are summed up in one line per address with a warning never to copy
284
+ those senders. Each check reads fresh data.
285
+
280
286
  ```bash
281
- crypttrace watch add 0xADDRESS --note "victim funds"
287
+ crypttrace watch add TADDRESS --chain tron --note "victim funds"
282
288
  crypttrace watch list
283
289
  crypttrace watch run --interval 300 # continuous
284
290
  crypttrace watch run --once # single check, for scheduled tasks
285
291
  ```
286
292
 
287
293
  Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
288
- then pass `--telegram`.
294
+ then pass `--telegram`. Each alert carries the chain, amount, asset and a link
295
+ to the transaction.
289
296
 
290
297
  ### Stablecoin freezes
291
298
 
@@ -331,11 +338,16 @@ Tested on a documented Tron case from August 2026: from the victim's wallet it
331
338
  finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
332
339
  `TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
333
340
  attacker's side it finds the same payment and two more victims of $2.4M and
334
- $2.0M, each lured by a look-alike created three to twelve minutes earlier.
335
-
336
- Cheap look-alikes match only one or two characters at each end, which happens
337
- by chance; those are reported only when they arrived as bait after the real
338
- address was in use.
341
+ $2.0M, each lured by a look-alike created three to twelve minutes earlier, and
342
+ for all three it names the address that was imitated.
343
+
344
+ Look-alikes come in three strengths. Matching several characters at both ends is
345
+ reported on sight. So is one that copies only the ending, as long as the shared
346
+ ending is long enough to be no accident (6 characters on Tron and Solana, 7 on
347
+ bech32 Bitcoin, 8 on hex) — one of the three victims above was caught that way,
348
+ by `…E1doqb`. Cheap look-alikes that match one or two characters at each end
349
+ happen by chance, so they are reported only when they arrived as bait after the
350
+ real address was in use.
339
351
 
340
352
  ### Off-ramp detection
341
353
 
@@ -192,7 +192,7 @@ crypttrace timeline bc1qADDRESS --chain btc
192
192
  crypttrace crosschain 0xADDRESS --window 48
193
193
 
194
194
  # Watch addresses; alert loudly the moment funds head for an exchange
195
- crypttrace watch add 0xADDRESS --note "my stolen ETH"
195
+ crypttrace watch add TADDRESS --chain tron --note "my stolen USDT"
196
196
  crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
197
197
 
198
198
  # Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
@@ -243,15 +243,22 @@ toward an exchange (directly, or to a detected deposit address); quieter notices
243
243
  for other movement. It only alerts on activity *after* an address is added, and
244
244
  never double-alerts.
245
245
 
246
+ It works on every supported chain and watches the native coin **and the
247
+ stablecoins** (USDT, USDC) — on Tron, stolen money is almost always USDT.
248
+ Counterfeit "USDT" and zero/dust transfers from address poisoners do not
249
+ alert; they are summed up in one line per address with a warning never to copy
250
+ those senders. Each check reads fresh data.
251
+
246
252
  ```bash
247
- crypttrace watch add 0xADDRESS --note "victim funds"
253
+ crypttrace watch add TADDRESS --chain tron --note "victim funds"
248
254
  crypttrace watch list
249
255
  crypttrace watch run --interval 300 # continuous
250
256
  crypttrace watch run --once # single check, for scheduled tasks
251
257
  ```
252
258
 
253
259
  Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
254
- then pass `--telegram`.
260
+ then pass `--telegram`. Each alert carries the chain, amount, asset and a link
261
+ to the transaction.
255
262
 
256
263
  ### Stablecoin freezes
257
264
 
@@ -297,11 +304,16 @@ Tested on a documented Tron case from August 2026: from the victim's wallet it
297
304
  finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
298
305
  `TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
299
306
  attacker's side it finds the same payment and two more victims of $2.4M and
300
- $2.0M, each lured by a look-alike created three to twelve minutes earlier.
301
-
302
- Cheap look-alikes match only one or two characters at each end, which happens
303
- by chance; those are reported only when they arrived as bait after the real
304
- address was in use.
307
+ $2.0M, each lured by a look-alike created three to twelve minutes earlier, and
308
+ for all three it names the address that was imitated.
309
+
310
+ Look-alikes come in three strengths. Matching several characters at both ends is
311
+ reported on sight. So is one that copies only the ending, as long as the shared
312
+ ending is long enough to be no accident (6 characters on Tron and Solana, 7 on
313
+ bech32 Bitcoin, 8 on hex) — one of the three victims above was caught that way,
314
+ by `…E1doqb`. Cheap look-alikes that match one or two characters at each end
315
+ happen by chance, so they are reported only when they arrived as bait after the
316
+ real address was in use.
305
317
 
306
318
  ### Off-ramp detection
307
319
 
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.8.0"
2
+ __version__ = "0.10.0"
@@ -836,12 +836,16 @@ app.add_typer(watch_app, name="watch")
836
836
 
837
837
  @watch_app.command("add")
838
838
  def watch_add(
839
- address: str = typer.Argument(..., help="Address to watch (0x…)"),
839
+ address: str = typer.Argument(..., help="Address to watch"),
840
840
  chain: str = CHAIN_OPT,
841
- note: str = typer.Option("", "--note", "-n", help="A label for this case, e.g. 'my stolen ETH'"),
841
+ note: str = typer.Option("", "--note", "-n", help="A label for this case, e.g. 'my stolen USDT'"),
842
842
  ):
843
843
  """Add an address to the watchlist (alerts only on activity from now on)."""
844
- watch_mod.add(address, chain, note)
844
+ try:
845
+ watch_mod.add(address, chain, note)
846
+ except (watch_mod.WatchError, chains_mod.ChainError) as e:
847
+ console.print(f"[red]Error:[/red] {e}")
848
+ raise typer.Exit(1)
845
849
  console.print(f"[green]✓ Watching[/green] {address} ({chain})"
846
850
  + (f" — {note}" if note else ""))
847
851
 
@@ -851,7 +855,8 @@ def watch_list():
851
855
  """Show the watchlist."""
852
856
  d = watch_mod.all_watched()
853
857
  if not d:
854
- console.print("[dim]Watchlist is empty. Add one with `crypttrace watch add 0x…`[/dim]")
858
+ console.print("[dim]Watchlist is empty. Add one with "
859
+ "`crypttrace watch add ADDRESS --chain tron`[/dim]")
855
860
  return
856
861
  for addr, m in d.items():
857
862
  console.print(f" {addr} ({m.get('chain','eth')})"
@@ -873,7 +878,7 @@ def _render_alert(e: dict) -> None:
873
878
  when = render._ts(str(e["timestamp"]))
874
879
  line = (f"{icon} [{style}]{e['sev'].upper()}[/{style}] {e['address'][:12]}…"
875
880
  + (f" ({e['note']})" if e.get("note") else "")
876
- + f" {e['value']:.4f} {e['reason']} [dim]{when}[/dim]")
881
+ + f" {e['value']:,.4f} {e.get('symbol', '')} {e['reason']} [dim]{when}[/dim]")
877
882
  if e["sev"] == "high":
878
883
  console.bell() # audible bell for cash-out events
879
884
  console.print(line)
@@ -888,7 +893,8 @@ def watch_run(
888
893
  ):
889
894
  """Poll the watchlist and alert on new activity. Loud alert on likely cash-out."""
890
895
  if not watch_mod.all_watched():
891
- console.print("[dim]Watchlist is empty. Add one with `crypttrace watch add 0x…`[/dim]")
896
+ console.print("[dim]Watchlist is empty. Add one with "
897
+ "`crypttrace watch add ADDRESS --chain tron`[/dim]")
892
898
  raise typer.Exit(1)
893
899
 
894
900
  def _pass():
@@ -896,11 +902,20 @@ def watch_run(
896
902
  if not alerts:
897
903
  console.print(f"[dim]{render._ts(str(int(time.time())))} — no new activity[/dim]")
898
904
  return
905
+ dust = {}
899
906
  for e in alerts:
907
+ if e["sev"] == "dust": # one line per address, not one per speck
908
+ dust[e["address"]] = dust.get(e["address"], 0) + 1
909
+ continue
900
910
  _render_alert(e)
901
911
  if telegram and e["sev"] in ("high", "move"):
902
- msg = f"crypttrace {e['sev'].upper()}: {e['address']} {e['value']:.4f} {e['reason']}"
912
+ msg = (f"crypttrace {e['sev'].upper()} ({e.get('chain', '')}): {e['address']} "
913
+ f"{e['value']:,.4f} {e.get('symbol', '')} {e['reason']}"
914
+ + (f"\n{e['explorer']}" if e.get("explorer") else ""))
903
915
  watch_mod.telegram_notify(msg)
916
+ for addr, n in dust.items():
917
+ console.print(f"[dim] {addr[:12]}… {n} zero/dust transfer(s) — likely address "
918
+ "poisoning; never copy an address from this wallet's history[/dim]")
904
919
 
905
920
  if once:
906
921
  _pass()
@@ -82,10 +82,31 @@ def balance(address: str) -> float:
82
82
  return (data[0].get("balance") or 0) / SUN
83
83
 
84
84
 
85
+ PAGE = 200 # TronGrid's page size
86
+
87
+
88
+ def _pages(path: str, limit: int) -> List[Dict]:
89
+ """Up to `limit` items, following TronGrid's fingerprint cursor page by page.
90
+
91
+ One page (200 items) used to be all we read, so a busy wallet's history
92
+ ended a few days back and older transfers were simply invisible."""
93
+ items: List[Dict] = []
94
+ params = {"limit": min(limit, PAGE)}
95
+ while len(items) < limit:
96
+ d = _get(path, dict(params))
97
+ page = d.get("data", []) or []
98
+ items += page
99
+ cursor = (d.get("meta") or {}).get("fingerprint")
100
+ if len(page) < params["limit"] or not cursor:
101
+ break
102
+ params["fingerprint"] = cursor
103
+ return items[:limit]
104
+
105
+
85
106
  def _native(address: str, limit: int) -> List[Dict]:
86
- d = _get(f"/v1/accounts/{address}/transactions", {"limit": min(limit, 200)})
107
+ # the endpoint lists every transaction type; TRX transfers are a subset of it
87
108
  rows = []
88
- for tx in d.get("data", []) or []:
109
+ for tx in _pages(f"/v1/accounts/{address}/transactions", limit):
89
110
  try:
90
111
  c = (tx.get("raw_data", {}).get("contract") or [])[0]
91
112
  if c.get("type") != "TransferContract":
@@ -106,9 +127,8 @@ def _native(address: str, limit: int) -> List[Dict]:
106
127
 
107
128
  def token_transfers(address: str, limit: int = 200) -> List[Dict]:
108
129
  """TRC20 transfers (USDT and friends) — already base58 in the API."""
109
- d = _get(f"/v1/accounts/{address}/transactions/trc20", {"limit": min(limit, 200)})
110
130
  rows = []
111
- for t in d.get("data", []) or []:
131
+ for t in _pages(f"/v1/accounts/{address}/transactions/trc20", limit):
112
132
  # the endpoint also lists Approval events, whose "value" is an allowance
113
133
  # (often 2**256-1), not money that moved
114
134
  if t.get("type", "Transfer") != "Transfer":
@@ -19,6 +19,7 @@ Three views of the same attack:
19
19
  * campaign(address) — an address sending zero/dust transfers to many
20
20
  unrelated wallets (the dust-sending style, common on Tron).
21
21
  """
22
+ import math
22
23
  from collections import defaultdict
23
24
  from typing import Dict, List, Optional, Tuple
24
25
 
@@ -30,6 +31,20 @@ from crypttrace import analysis, assets, chains
30
31
  # the look-alike arrived with bait after the genuine address was in use.
31
32
  STRONG = (3, 3, 7) # min prefix, min suffix, min total
32
33
  WEAK = (1, 2, 3)
34
+ # Many people check only the ending, and poisoners know it: a look-alike can match
35
+ # the last characters alone. That is strong evidence once the shared ending carries
36
+ # 32 bits — 6 base58 characters, 7 bech32, 8 hex — about 1 in 4 billion by chance.
37
+ SUFFIX_BITS = 32
38
+
39
+
40
+ def _suffix_chars(address: str, chain: str) -> int:
41
+ if chains.is_evm(chain):
42
+ bits = 4.0 # hex
43
+ elif address.lower().startswith(("bc1", "tb1")):
44
+ bits = 5.0 # bech32
45
+ else:
46
+ bits = 5.86 # base58: Tron, Solana, legacy Bitcoin
47
+ return math.ceil(SUFFIX_BITS / bits)
33
48
  # A campaign: this many distinct recipients of zero/dust transfers.
34
49
  CAMPAIGN_MIN_TARGETS = 10
35
50
 
@@ -62,9 +77,12 @@ def resemblance(a: str, b: str, chain: str) -> Optional[str]:
62
77
  if chains.norm_addr(a, chain) == chains.norm_addr(b, chain):
63
78
  return None
64
79
  pre, suf = match_lengths(a, b, chain)
65
- for name, (p, s, t) in (("strong", STRONG), ("weak", WEAK)):
66
- if pre >= p and suf >= s and pre + suf >= t:
67
- return name
80
+ p, s, t = STRONG
81
+ if (pre >= p and suf >= s and pre + suf >= t) or suf >= _suffix_chars(a, chain):
82
+ return "strong"
83
+ p, s, t = WEAK
84
+ if pre >= p and suf >= s and pre + suf >= t:
85
+ return "weak"
68
86
  return None
69
87
 
70
88
 
@@ -128,13 +146,17 @@ def lookalikes(address: str, chain: str, limit: int = 1000) -> List[dict]:
128
146
  c["sent_first_ts"] = ts if c["sent_first_ts"] is None else min(c["sent_first_ts"], ts)
129
147
 
130
148
  # only compare addresses that could match: bucket by visible ends
149
+ # (and by the ending alone, for look-alikes that only imitate the last characters)
131
150
  buckets = defaultdict(list)
132
151
  for a in seen:
133
152
  b = _body(a, chain)
134
153
  if len(b) >= WEAK[0] + WEAK[1]:
135
- buckets[(b[:WEAK[0]], b[-WEAK[1]:])].append(a)
154
+ buckets[("ends", b[:WEAK[0]], b[-WEAK[1]:])].append(a)
155
+ n = _suffix_chars(a, chain)
156
+ if len(b) > n:
157
+ buckets[("tail", b[-n:])].append(a)
136
158
 
137
- findings = []
159
+ findings, compared = [], set()
138
160
  for group in buckets.values():
139
161
  if len(group) < 2:
140
162
  continue
@@ -144,7 +166,10 @@ def lookalikes(address: str, chain: str, limit: int = 1000) -> List[dict]:
144
166
  return (c["sent_first_ts"] is None, c["sent_first_ts"] or c["first_ts"], c["first_ts"])
145
167
  genuine = min(group, key=rank)
146
168
  for fake in group:
147
- level = resemblance(genuine, fake, chain) if fake != genuine else None
169
+ if fake == genuine or (genuine, fake) in compared:
170
+ continue # a pair can sit in both kinds of bucket
171
+ compared.add((genuine, fake))
172
+ level = resemblance(genuine, fake, chain)
148
173
  if not level:
149
174
  continue
150
175
  g, f = seen[genuine], seen[fake]
@@ -0,0 +1,194 @@
1
+ """Address monitoring — the feature that actually helps recover funds.
2
+
3
+ The one window to freeze stolen crypto is the moment it reaches an exchange
4
+ deposit address. Victims can't watch a chain 24/7. `watch` keeps a list of
5
+ addresses, detects new activity, and raises a loud HIGH alert the instant funds
6
+ move toward an exchange (directly, or to a detected deposit address). Everything
7
+ else is a quieter movement notice.
8
+
9
+ It works on every supported chain and watches the native coin *and* the
10
+ stablecoins (USDT, USDC) — most thefts move stablecoins, and on Tron almost
11
+ nothing else. Each poll reads fresh data, never the local store's copy.
12
+
13
+ Honest limit: the tool tells you *when* to act; freezing funds still depends on
14
+ the exchange and law enforcement responding quickly.
15
+ """
16
+ import json
17
+ from typing import Dict, List, Optional, Tuple
18
+
19
+ import requests
20
+
21
+ from crypttrace.labels import labels
22
+ from crypttrace import addresses, assets, chains, config, offramp, poisoning
23
+
24
+ WATCHLIST = config.DATA_DIR / "watchlist.json"
25
+
26
+
27
+ class WatchError(ValueError):
28
+ pass
29
+
30
+
31
+ def _load() -> Dict[str, dict]:
32
+ if WATCHLIST.exists():
33
+ try:
34
+ return json.loads(WATCHLIST.read_text(encoding="utf-8"))
35
+ except (ValueError, OSError):
36
+ return {}
37
+ return {}
38
+
39
+
40
+ def _save(d: Dict[str, dict]) -> None:
41
+ WATCHLIST.parent.mkdir(parents=True, exist_ok=True)
42
+ WATCHLIST.write_text(json.dumps(d, indent=2), encoding="utf-8")
43
+
44
+
45
+ _ALL_TOKENS = {"contract": None, "symbol": "*"}
46
+ _SPL = {"contract": "spl", "symbol": "SPL"}
47
+
48
+
49
+ def _key(row: dict) -> str:
50
+ return "|".join(str(row.get(k, "")) for k in ("hash", "from", "to", "value", "symbol"))
51
+
52
+
53
+ def _rows(address: str, chain: str, limit: int) -> List[dict]:
54
+ """Fresh native and stablecoin transfers, each tagged with the asset it belongs to.
55
+
56
+ Tokens are read in one request and kept only when their contract is the real
57
+ stablecoin's — counterfeit "USDT" sent by address poisoners must not alert."""
58
+ rows = [{**r, "_asset": None} for r in chains.transfers(address, chain, limit, fresh=True)]
59
+ if chain == "btc":
60
+ return rows
61
+ try:
62
+ tokens = chains.transfers(address, chain, limit, asset=_ALL_TOKENS, fresh=True)
63
+ except chains.ChainError:
64
+ return rows # the native history alone still gets watched
65
+ if chain == "sol": # Solana token rows carry no mint to tell tokens apart
66
+ return rows + [{**r, "_asset": _SPL} for r in tokens]
67
+ stables = {t["contract"].lower(): dict(t) for t in assets.tokens_for(chain).values()
68
+ if t.get("stable")}
69
+ return rows + [{**r, "_asset": stables[(r.get("contract") or "").lower()]}
70
+ for r in tokens if (r.get("contract") or "").lower() in stables]
71
+
72
+
73
+ def _latest(rows: List[dict]) -> Tuple[int, List[str]]:
74
+ """Newest timestamp and the keys of every transfer at that second."""
75
+ last = max((int(r.get("timestamp") or 0) for r in rows), default=0)
76
+ return last, [_key(r) for r in rows if int(r.get("timestamp") or 0) == last]
77
+
78
+
79
+ def add(address: str, chain: str = "eth", note: str = "") -> int:
80
+ ok, why = addresses.validate(address, chain)
81
+ if not ok:
82
+ raise WatchError(f"not a valid {chain} address: {why}")
83
+ last, keys = _latest(_rows(address, chain, 50)) # only alert on activity after now
84
+ d = _load()
85
+ d[chains.norm_addr(address, chain)] = {"chain": chain, "note": note,
86
+ "last_ts": last, "last_keys": keys}
87
+ _save(d)
88
+ return last
89
+
90
+
91
+ def remove(address: str) -> bool:
92
+ d = _load()
93
+ for k in (address, address.lower()):
94
+ if k in d:
95
+ del d[k]
96
+ _save(d)
97
+ return True
98
+ return False
99
+
100
+
101
+ def all_watched() -> Dict[str, dict]:
102
+ return _load()
103
+
104
+
105
+ def _classify(me: str, row: dict, chain: str) -> dict:
106
+ frm, to = row.get("from") or "", row.get("to") or ""
107
+ val = row.get("value", 0) or 0
108
+ asset = row.get("_asset")
109
+ symbol = asset["symbol"] if asset else chains.symbol(chain)
110
+ direction = "out" if frm == me else "in"
111
+ other = to if direction == "out" else frm
112
+ otype = labels.type_of(other)
113
+
114
+ sev, reason = "info", "incoming funds" if direction == "in" else "funds moved out"
115
+ if poisoning._is_bait(row, chain):
116
+ # zero, dust or counterfeit: how poisoners plant look-alikes in a history
117
+ sev, reason = "dust", "zero/dust transfer — likely address poisoning; never copy this sender"
118
+ elif direction == "out" and otype == "exchange":
119
+ sev, reason = "high", f"→ EXCHANGE {labels.label_of(other)} — possible cash-out (freeze window)"
120
+ elif direction == "out" and otype in ("mixer", "sanctioned"):
121
+ sev, reason = "high", f"→ {otype} {labels.label_of(other)}"
122
+ elif direction == "out" and otype == "bridge":
123
+ sev, reason = "move", f"→ bridge {labels.label_of(other)} (funds may leave this chain)"
124
+ elif direction == "out" and val > 0:
125
+ try:
126
+ off = offramp.detect(other, chain, asset=asset, stablecoins=False)
127
+ except chains.ChainError:
128
+ off = None
129
+ if off:
130
+ sev, reason = "high", f"→ likely {off['company']} DEPOSIT address — possible cash-out"
131
+ else:
132
+ sev, reason = "move", "funds moved out"
133
+
134
+ return {"direction": direction, "other": other, "otype": otype, "value": val,
135
+ "symbol": symbol, "sev": sev, "reason": reason,
136
+ "timestamp": int(row.get("timestamp") or 0), "hash": row.get("hash", ""),
137
+ "explorer": chains.tx_url(row["hash"], chain) if row.get("hash") else ""}
138
+
139
+
140
+ def check(address: str, chain: str, since_ts: int, seen_keys: Optional[List[str]] = None,
141
+ limit: int = 100) -> List[dict]:
142
+ """New events since the baseline, newest first, each classified. A transfer in
143
+ the same second as the baseline counts as new unless it was already seen."""
144
+ me = chains.norm_addr(address, chain)
145
+ seen = set(seen_keys or [])
146
+ rows = [r for r in _rows(address, chain, limit)
147
+ if int(r.get("timestamp") or 0) > since_ts
148
+ or (int(r.get("timestamp") or 0) == since_ts and _key(r) not in seen)]
149
+ rows.sort(key=lambda r: int(r.get("timestamp") or 0), reverse=True)
150
+ return [dict(_classify(me, r, chain), _key=_key(r)) for r in rows]
151
+
152
+
153
+ def poll_once() -> List[dict]:
154
+ """Check every watched address, update baselines, return all new alerts."""
155
+ d = _load()
156
+ alerts = []
157
+ for addr, meta in d.items():
158
+ chain = meta.get("chain", "eth")
159
+ try:
160
+ events = check(addr, chain, meta.get("last_ts", 0), meta.get("last_keys"))
161
+ except chains.ChainError:
162
+ continue
163
+ if events:
164
+ last = max(e["timestamp"] for e in events)
165
+ keys = [e["_key"] for e in events if e["timestamp"] == last]
166
+ if last == meta.get("last_ts"):
167
+ keys += meta.get("last_keys", [])
168
+ meta["last_ts"], meta["last_keys"] = last, keys
169
+ for e in events:
170
+ e.pop("_key", None)
171
+ e["address"] = addr
172
+ e["chain"] = chain
173
+ e["note"] = meta.get("note", "")
174
+ alerts.append(e)
175
+ if alerts:
176
+ _save(d)
177
+ # highest severity first
178
+ order = {"high": 0, "move": 1, "info": 2, "dust": 3}
179
+ return sorted(alerts, key=lambda e: order.get(e["sev"], 3))
180
+
181
+
182
+ def telegram_notify(text: str) -> Optional[bool]:
183
+ """Send an alert to Telegram if CRYPTTRACE_TG_TOKEN + CRYPTTRACE_TG_CHAT are set."""
184
+ import os
185
+ token = os.environ.get("CRYPTTRACE_TG_TOKEN")
186
+ chat = os.environ.get("CRYPTTRACE_TG_CHAT")
187
+ if not token or not chat:
188
+ return None
189
+ try:
190
+ r = requests.get(f"https://api.telegram.org/bot{token}/sendMessage",
191
+ params={"chat_id": chat, "text": text}, timeout=15)
192
+ return r.ok
193
+ except requests.RequestException:
194
+ return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron and Solana
5
5
  Author: bobslayerX
6
6
  License-Expression: MIT
@@ -226,7 +226,7 @@ crypttrace timeline bc1qADDRESS --chain btc
226
226
  crypttrace crosschain 0xADDRESS --window 48
227
227
 
228
228
  # Watch addresses; alert loudly the moment funds head for an exchange
229
- crypttrace watch add 0xADDRESS --note "my stolen ETH"
229
+ crypttrace watch add TADDRESS --chain tron --note "my stolen USDT"
230
230
  crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
231
231
 
232
232
  # Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
@@ -277,15 +277,22 @@ toward an exchange (directly, or to a detected deposit address); quieter notices
277
277
  for other movement. It only alerts on activity *after* an address is added, and
278
278
  never double-alerts.
279
279
 
280
+ It works on every supported chain and watches the native coin **and the
281
+ stablecoins** (USDT, USDC) — on Tron, stolen money is almost always USDT.
282
+ Counterfeit "USDT" and zero/dust transfers from address poisoners do not
283
+ alert; they are summed up in one line per address with a warning never to copy
284
+ those senders. Each check reads fresh data.
285
+
280
286
  ```bash
281
- crypttrace watch add 0xADDRESS --note "victim funds"
287
+ crypttrace watch add TADDRESS --chain tron --note "victim funds"
282
288
  crypttrace watch list
283
289
  crypttrace watch run --interval 300 # continuous
284
290
  crypttrace watch run --once # single check, for scheduled tasks
285
291
  ```
286
292
 
287
293
  Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
288
- then pass `--telegram`.
294
+ then pass `--telegram`. Each alert carries the chain, amount, asset and a link
295
+ to the transaction.
289
296
 
290
297
  ### Stablecoin freezes
291
298
 
@@ -331,11 +338,16 @@ Tested on a documented Tron case from August 2026: from the victim's wallet it
331
338
  finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
332
339
  `TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
333
340
  attacker's side it finds the same payment and two more victims of $2.4M and
334
- $2.0M, each lured by a look-alike created three to twelve minutes earlier.
335
-
336
- Cheap look-alikes match only one or two characters at each end, which happens
337
- by chance; those are reported only when they arrived as bait after the real
338
- address was in use.
341
+ $2.0M, each lured by a look-alike created three to twelve minutes earlier, and
342
+ for all three it names the address that was imitated.
343
+
344
+ Look-alikes come in three strengths. Matching several characters at both ends is
345
+ reported on sight. So is one that copies only the ending, as long as the shared
346
+ ending is long enough to be no accident (6 characters on Tron and Solana, 7 on
347
+ bech32 Bitcoin, 8 on hex) — one of the three victims above was caught that way,
348
+ by `…E1doqb`. Cheap look-alikes that match one or two characters at each end
349
+ happen by chance, so they are reported only when they arrived as bait after the
350
+ real address was in use.
339
351
 
340
352
  ### Off-ramp detection
341
353
 
@@ -1,138 +0,0 @@
1
- """Address monitoring — the feature that actually helps recover funds.
2
-
3
- The one window to freeze stolen crypto is the moment it reaches an exchange
4
- deposit address. Victims can't watch a chain 24/7. `watch` keeps a list of
5
- addresses, detects new activity, and raises a loud HIGH alert the instant funds
6
- move toward an exchange (directly, or to a detected deposit address). Everything
7
- else is a quieter movement notice.
8
-
9
- Honest limit: the tool tells you *when* to act; freezing funds still depends on
10
- the exchange and law enforcement responding quickly.
11
- """
12
- import json
13
- import time
14
- from typing import List, Dict, Optional
15
-
16
- import requests
17
-
18
- from crypttrace.fetchers import etherscan
19
- from crypttrace.labels import labels
20
- from crypttrace import chains, config, offramp
21
-
22
- WATCHLIST = config.DATA_DIR / "watchlist.json"
23
-
24
-
25
- def _load() -> Dict[str, dict]:
26
- if WATCHLIST.exists():
27
- try:
28
- return json.loads(WATCHLIST.read_text())
29
- except (ValueError, OSError):
30
- return {}
31
- return {}
32
-
33
-
34
- def _save(d: Dict[str, dict]) -> None:
35
- WATCHLIST.parent.mkdir(parents=True, exist_ok=True)
36
- WATCHLIST.write_text(json.dumps(d, indent=2))
37
-
38
-
39
- def _latest_ts(address: str, chain: str) -> int:
40
- txs = etherscan.get_txs(address, chain, limit=1)
41
- return int(txs[0]["timeStamp"]) if txs else 0
42
-
43
-
44
- def add(address: str, chain: str = "eth", note: str = "") -> int:
45
- d = _load()
46
- baseline = _latest_ts(address, chain) # only alert on activity *after* now
47
- d[address.lower()] = {"chain": chain, "note": note, "last_ts": baseline}
48
- _save(d)
49
- return baseline
50
-
51
-
52
- def remove(address: str) -> bool:
53
- d = _load()
54
- if address.lower() in d:
55
- del d[address.lower()]
56
- _save(d)
57
- return True
58
- return False
59
-
60
-
61
- def all_watched() -> Dict[str, dict]:
62
- return _load()
63
-
64
-
65
- def _classify(me: str, tx: dict, chain: str) -> dict:
66
- frm = tx.get("from", "").lower()
67
- to = tx.get("to", "").lower()
68
- val = int(tx.get("value", 0)) / config.WEI
69
- direction = "out" if frm == me else "in"
70
- other = to if direction == "out" else frm
71
- otype = labels.type_of(other)
72
-
73
- sev, reason = "info", "incoming funds" if direction == "in" else "funds moved out"
74
- if direction == "out" and otype == "exchange":
75
- sev, reason = "high", f"→ EXCHANGE {labels.label_of(other)} — possible cash-out (freeze window)"
76
- elif direction == "out" and otype in ("mixer", "sanctioned"):
77
- sev, reason = "high", f"→ {otype} {labels.label_of(other)}"
78
- elif direction == "out" and otype == "bridge":
79
- sev, reason = "move", f"→ bridge {labels.label_of(other)} (funds may leave this chain)"
80
- elif direction == "out" and val > 0:
81
- off = offramp.detect(other, chain)
82
- if off:
83
- sev, reason = "high", f"→ likely {off['exchange']} DEPOSIT address — possible cash-out"
84
- else:
85
- sev, reason = "move", "funds moved out"
86
-
87
- return {"direction": direction, "other": other, "otype": otype, "value": val,
88
- "sev": sev, "reason": reason, "timestamp": int(tx.get("timeStamp", "0")),
89
- "hash": tx.get("hash", "")}
90
-
91
-
92
- def check(address: str, chain: str, since_ts: int, limit: int = 100) -> List[dict]:
93
- """New events (newer than since_ts), newest first, each classified."""
94
- me = address.lower()
95
- events = []
96
- for tx in etherscan.get_txs(address, chain, limit=limit): # desc (newest first)
97
- ts = int(tx.get("timeStamp", "0"))
98
- if ts <= since_ts:
99
- break
100
- events.append(_classify(me, tx, chain))
101
- return events
102
-
103
-
104
- def poll_once() -> List[dict]:
105
- """Check every watched address, update baselines, return all new alerts."""
106
- d = _load()
107
- alerts = []
108
- for addr, meta in d.items():
109
- try:
110
- events = check(addr, meta.get("chain", "eth"), meta.get("last_ts", 0))
111
- except (etherscan.EtherscanError, chains.ChainError):
112
- continue
113
- if events:
114
- meta["last_ts"] = max(e["timestamp"] for e in events)
115
- for e in events:
116
- e["address"] = addr
117
- e["note"] = meta.get("note", "")
118
- alerts.append(e)
119
- if alerts:
120
- _save(d)
121
- # highest severity first
122
- order = {"high": 0, "move": 1, "info": 2}
123
- return sorted(alerts, key=lambda e: order.get(e["sev"], 3))
124
-
125
-
126
- def telegram_notify(text: str) -> Optional[bool]:
127
- """Send an alert to Telegram if CRYPTTRACE_TG_TOKEN + CRYPTTRACE_TG_CHAT are set."""
128
- import os
129
- token = os.environ.get("CRYPTTRACE_TG_TOKEN")
130
- chat = os.environ.get("CRYPTTRACE_TG_CHAT")
131
- if not token or not chat:
132
- return None
133
- try:
134
- r = requests.get(f"https://api.telegram.org/bot{token}/sendMessage",
135
- params={"chat_id": chat, "text": text}, timeout=15)
136
- return r.ok
137
- except requests.RequestException:
138
- return False
File without changes
File without changes
File without changes