crypttrace 0.8.0__tar.gz → 0.9.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.9.0}/PKG-INFO +11 -4
  2. {crypttrace-0.8.0 → crypttrace-0.9.0}/README.md +10 -3
  3. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/cli.py +22 -7
  5. crypttrace-0.9.0/src/crypttrace/watch.py +194 -0
  6. {crypttrace-0.8.0 → crypttrace-0.9.0/src/crypttrace.egg-info}/PKG-INFO +11 -4
  7. crypttrace-0.8.0/src/crypttrace/watch.py +0 -138
  8. {crypttrace-0.8.0 → crypttrace-0.9.0}/LICENSE +0 -0
  9. {crypttrace-0.8.0 → crypttrace-0.9.0}/pyproject.toml +0 -0
  10. {crypttrace-0.8.0 → crypttrace-0.9.0}/setup.cfg +0 -0
  11. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/addresses.py +0 -0
  12. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/analysis.py +0 -0
  13. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/assess.py +0 -0
  14. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/assets.py +0 -0
  15. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/bridges.py +0 -0
  16. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/chains.py +0 -0
  17. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/config.py +0 -0
  18. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/fetchers/__init__.py +0 -0
  19. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  20. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  21. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/fetchers/http.py +0 -0
  22. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/fetchers/solana.py +0 -0
  23. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/fetchers/tron.py +0 -0
  24. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/freeze.py +0 -0
  25. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/funder.py +0 -0
  26. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/investigate.py +0 -0
  27. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/labels/__init__.py +0 -0
  28. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/labels/audit.py +0 -0
  29. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/labels/bulk.py +0 -0
  30. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/labels/known.json +0 -0
  31. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/labels/labels.py +0 -0
  32. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/labels/partial.json +0 -0
  33. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/offramp.py +0 -0
  34. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/poisoning.py +0 -0
  35. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/prices.py +0 -0
  36. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/render.py +0 -0
  37. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/report.py +0 -0
  38. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/store.py +0 -0
  39. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/trace.py +0 -0
  40. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/verify.py +0 -0
  41. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/web/index.html +0 -0
  42. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace/webapp.py +0 -0
  43. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
  44. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  45. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  46. {crypttrace-0.8.0 → crypttrace-0.9.0}/src/crypttrace.egg-info/requires.txt +0 -0
  47. {crypttrace-0.8.0 → crypttrace-0.9.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.9.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
 
@@ -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
 
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.8.0"
2
+ __version__ = "0.9.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()
@@ -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.9.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
 
@@ -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