crypttrace 0.7.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.7.0/src/crypttrace.egg-info → crypttrace-0.9.0}/PKG-INFO +16 -5
  2. {crypttrace-0.7.0 → crypttrace-0.9.0}/README.md +15 -4
  3. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/cli.py +22 -7
  5. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/report.py +20 -8
  6. crypttrace-0.9.0/src/crypttrace/watch.py +194 -0
  7. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/web/index.html +67 -1
  8. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/webapp.py +35 -1
  9. {crypttrace-0.7.0 → crypttrace-0.9.0/src/crypttrace.egg-info}/PKG-INFO +16 -5
  10. crypttrace-0.7.0/src/crypttrace/watch.py +0 -138
  11. {crypttrace-0.7.0 → crypttrace-0.9.0}/LICENSE +0 -0
  12. {crypttrace-0.7.0 → crypttrace-0.9.0}/pyproject.toml +0 -0
  13. {crypttrace-0.7.0 → crypttrace-0.9.0}/setup.cfg +0 -0
  14. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/addresses.py +0 -0
  15. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/analysis.py +0 -0
  16. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/assess.py +0 -0
  17. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/assets.py +0 -0
  18. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/bridges.py +0 -0
  19. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/chains.py +0 -0
  20. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/config.py +0 -0
  21. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/fetchers/__init__.py +0 -0
  22. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  23. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  24. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/fetchers/http.py +0 -0
  25. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/fetchers/solana.py +0 -0
  26. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/fetchers/tron.py +0 -0
  27. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/freeze.py +0 -0
  28. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/funder.py +0 -0
  29. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/investigate.py +0 -0
  30. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/labels/__init__.py +0 -0
  31. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/labels/audit.py +0 -0
  32. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/labels/bulk.py +0 -0
  33. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/labels/known.json +0 -0
  34. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/labels/labels.py +0 -0
  35. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/labels/partial.json +0 -0
  36. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/offramp.py +0 -0
  37. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/poisoning.py +0 -0
  38. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/prices.py +0 -0
  39. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/render.py +0 -0
  40. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/store.py +0 -0
  41. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/trace.py +0 -0
  42. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace/verify.py +0 -0
  43. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
  44. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  45. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  46. {crypttrace-0.7.0 → crypttrace-0.9.0}/src/crypttrace.egg-info/requires.txt +0 -0
  47. {crypttrace-0.7.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.7.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
@@ -131,7 +131,11 @@ crypttrace serve # then open http://127.0.0.1:8000
131
131
  Paste an address, pick a chain and direction, and get an interactive fund-flow
132
132
  graph: nodes coloured by what they are, arrows showing where value went, click a
133
133
  node for its full profile, click a transfer line to open it on the block
134
- explorer. A side panel shows the profile, first-funder chain and off-ramp check.
134
+ explorer. A side panel shows the profile, first-funder chain, off-ramp check
135
+ and whether USDT/USDC at the address is frozen. Tabs add the assessment, the
136
+ timeline, the list of who sent funds here, and address poisoning (look-alikes
137
+ planted in the wallet's history, and payments lured by this address). **Case
138
+ file** downloads the same one-page HTML report `crypttrace report` writes.
135
139
  Everything runs on your machine — nothing is uploaded anywhere.
136
140
 
137
141
  This exists so non-technical victims can use the tool at all: a form and a
@@ -222,7 +226,7 @@ crypttrace timeline bc1qADDRESS --chain btc
222
226
  crypttrace crosschain 0xADDRESS --window 48
223
227
 
224
228
  # Watch addresses; alert loudly the moment funds head for an exchange
225
- crypttrace watch add 0xADDRESS --note "my stolen ETH"
229
+ crypttrace watch add TADDRESS --chain tron --note "my stolen USDT"
226
230
  crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
227
231
 
228
232
  # Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
@@ -273,15 +277,22 @@ toward an exchange (directly, or to a detected deposit address); quieter notices
273
277
  for other movement. It only alerts on activity *after* an address is added, and
274
278
  never double-alerts.
275
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
+
276
286
  ```bash
277
- crypttrace watch add 0xADDRESS --note "victim funds"
287
+ crypttrace watch add TADDRESS --chain tron --note "victim funds"
278
288
  crypttrace watch list
279
289
  crypttrace watch run --interval 300 # continuous
280
290
  crypttrace watch run --once # single check, for scheduled tasks
281
291
  ```
282
292
 
283
293
  Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
284
- then pass `--telegram`.
294
+ then pass `--telegram`. Each alert carries the chain, amount, asset and a link
295
+ to the transaction.
285
296
 
286
297
  ### Stablecoin freezes
287
298
 
@@ -97,7 +97,11 @@ crypttrace serve # then open http://127.0.0.1:8000
97
97
  Paste an address, pick a chain and direction, and get an interactive fund-flow
98
98
  graph: nodes coloured by what they are, arrows showing where value went, click a
99
99
  node for its full profile, click a transfer line to open it on the block
100
- explorer. A side panel shows the profile, first-funder chain and off-ramp check.
100
+ explorer. A side panel shows the profile, first-funder chain, off-ramp check
101
+ and whether USDT/USDC at the address is frozen. Tabs add the assessment, the
102
+ timeline, the list of who sent funds here, and address poisoning (look-alikes
103
+ planted in the wallet's history, and payments lured by this address). **Case
104
+ file** downloads the same one-page HTML report `crypttrace report` writes.
101
105
  Everything runs on your machine — nothing is uploaded anywhere.
102
106
 
103
107
  This exists so non-technical victims can use the tool at all: a form and a
@@ -188,7 +192,7 @@ crypttrace timeline bc1qADDRESS --chain btc
188
192
  crypttrace crosschain 0xADDRESS --window 48
189
193
 
190
194
  # Watch addresses; alert loudly the moment funds head for an exchange
191
- crypttrace watch add 0xADDRESS --note "my stolen ETH"
195
+ crypttrace watch add TADDRESS --chain tron --note "my stolen USDT"
192
196
  crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
193
197
 
194
198
  # Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
@@ -239,15 +243,22 @@ toward an exchange (directly, or to a detected deposit address); quieter notices
239
243
  for other movement. It only alerts on activity *after* an address is added, and
240
244
  never double-alerts.
241
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
+
242
252
  ```bash
243
- crypttrace watch add 0xADDRESS --note "victim funds"
253
+ crypttrace watch add TADDRESS --chain tron --note "victim funds"
244
254
  crypttrace watch list
245
255
  crypttrace watch run --interval 300 # continuous
246
256
  crypttrace watch run --once # single check, for scheduled tasks
247
257
  ```
248
258
 
249
259
  Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
250
- then pass `--telegram`.
260
+ then pass `--telegram`. Each alert carries the chain, amount, asset and a link
261
+ to the transaction.
251
262
 
252
263
  ### Stablecoin freezes
253
264
 
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.7.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()
@@ -174,22 +174,32 @@ def collect(address: str, chain: str, depth: int, branching: int, asset=None) ->
174
174
  def generate(address: str, chain: str, depth: int, branching: int,
175
175
  out_dir: Path, asset=None, guidance: Optional[dict] = None) -> Dict[str, Path]:
176
176
  """Write <case>.html, <case>.md and <case>.json; returns their paths."""
177
- data = collect(address, chain, depth, branching, asset)
178
- if guidance:
179
- data["guidance"] = guidance
177
+ data, raw, digest = build(address, chain, depth, branching, asset, guidance)
180
178
  out_dir.mkdir(parents=True, exist_ok=True)
181
- base = f"{chain}_{address[:10]}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
179
+ base = case_name(address, chain)
182
180
  paths = {k: out_dir / f"{base}.{k}" for k in ("html", "md", "json")}
183
- raw = json.dumps(data, indent=2, ensure_ascii=False, default=str)
184
181
  # bytes, not text mode: on Windows text mode turns LF into CRLF, and the hash
185
182
  # printed in the HTML would no longer match the file next to it
186
183
  paths["json"].write_bytes(raw.encode("utf-8"))
187
- digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
188
184
  paths["md"].write_text(_render_md(data, paths["json"].name), encoding="utf-8")
189
185
  paths["html"].write_text(render_html(data, raw, digest, paths["json"].name), encoding="utf-8")
190
186
  return paths
191
187
 
192
188
 
189
+ def case_name(address: str, chain: str) -> str:
190
+ return f"{chain}_{address[:10]}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
191
+
192
+
193
+ def build(address: str, chain: str, depth: int, branching: int, asset=None,
194
+ guidance: Optional[dict] = None):
195
+ """(data, raw JSON, its SHA-256) — the web UI renders the page from this in memory."""
196
+ data = collect(address, chain, depth, branching, asset)
197
+ if guidance:
198
+ data["guidance"] = guidance
199
+ raw = json.dumps(data, indent=2, ensure_ascii=False, default=str)
200
+ return data, raw, hashlib.sha256(raw.encode("utf-8")).hexdigest()
201
+
202
+
193
203
  # ------------------------------------------------------------------ markdown
194
204
 
195
205
  def _render_md(d: dict, json_name: str) -> str:
@@ -460,8 +470,10 @@ def render_html(d: dict, raw_json: str, digest: str, json_name: str) -> str:
460
470
  P.append(f"<h2>Method and limits</h2><p>{_e(_METHOD)}</p>")
461
471
  if d.get("errors"):
462
472
  P.append("<p class='muted'>Parts that could not be read: " + _e("; ".join(d["errors"])) + "</p>")
463
- P.append(f"<p class='muted'>The raw data is saved next to this file as <span class='mono'>{_e(json_name)}"
464
- f"</span> (SHA-256 <span class='mono'>{digest}</span>) and embedded below for analysts.</p>")
473
+ where = (f"saved next to this file as <span class='mono'>{_e(json_name)}</span>"
474
+ if json_name else "embedded in this file")
475
+ P.append(f"<p class='muted'>The raw data is {where} (SHA-256 of the JSON: "
476
+ f"<span class='mono'>{digest}</span>) and embedded below for analysts.</p>")
465
477
  # JSON inside <script type=application/json> is data, never executed. Every "<" is
466
478
  # written as < (still valid JSON), so a crafted token or label name can neither
467
479
  # close the element nor push the parser into its "<!--<script" escaping states.
@@ -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
@@ -228,6 +228,7 @@
228
228
  <select id="depth" title="Depth"><option>2</option><option selected>3</option><option>4</option><option>5</option></select>
229
229
  <button id="go">Investigate</button>
230
230
  <button id="fit" class="ghost" title="Fit graph to view">Fit</button>
231
+ <button id="casefile" class="ghost" title="Download a one-page case file for an exchange or the police">Case file</button>
231
232
  </div>
232
233
 
233
234
  <div class="legend">
@@ -246,6 +247,7 @@
246
247
  <button class="tab" data-view="assess">Assessment</button>
247
248
  <button class="tab" data-view="timeline">Timeline</button>
248
249
  <button class="tab" data-view="victims">Who sent funds here</button>
250
+ <button class="tab" data-view="poison">Poisoning</button>
249
251
  </div>
250
252
  <div class="stage">
251
253
  <div class="loader" id="loader"></div>
@@ -255,6 +257,7 @@
255
257
  <div class="view" id="v-assess"></div>
256
258
  <div class="view" id="v-timeline"></div>
257
259
  <div class="view" id="v-victims"></div>
260
+ <div class="view" id="v-poison"></div>
258
261
  <div class="scaleview" id="scaleview" style="display:none">100%</div>
259
262
  <div class="zoomctl" id="zoomctl">
260
263
  <button id="zin" title="Zoom in">+</button>
@@ -474,7 +477,7 @@ async function run(){
474
477
  setStatus('tracing the chain…',false,true); loader(35);
475
478
  $('#hint').classList.add('hide');
476
479
  loaded={}; // new address -> other views must reload
477
- $('#v-timeline').innerHTML=''; $('#v-victims').innerHTML=''; $('#v-assess').innerHTML='';
480
+ $('#v-timeline').innerHTML=''; $('#v-victims').innerHTML=''; $('#v-assess').innerHTML=''; $('#v-poison').innerHTML='';
478
481
  try{
479
482
  const dir=$('#direction').value;
480
483
  const g=await api(`/api/trace?address=${q(a)}&chain=${chain()}&depth=${$('#depth').value}&asset=${q($('#asset').value)}&direction=${dir}`);
@@ -524,12 +527,14 @@ function showView(v){
524
527
  $('#v-assess').classList.toggle('on',v==='assess');
525
528
  $('#v-timeline').classList.toggle('on',v==='timeline');
526
529
  $('#v-victims').classList.toggle('on',v==='victims');
530
+ $('#v-poison').classList.toggle('on',v==='poison');
527
531
  const a=$('#addr').value.trim();
528
532
  if(a && !loaded[v+a]) {
529
533
  loaded[v+a]=true;
530
534
  if(v==='timeline') loadTimeline(a);
531
535
  if(v==='victims') loadVictims(a);
532
536
  if(v==='assess') loadAssessment(a);
537
+ if(v==='poison') loadPoisoning(a);
533
538
  }
534
539
  }
535
540
  document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>showView(t.dataset.view));
@@ -647,6 +652,67 @@ async function verifyInto(container, addr){
647
652
  }catch(e){ slot.innerHTML=`<span class="vfy vfy-unavailable">verification unavailable</span>`; }
648
653
  }
649
654
 
655
+ // Address poisoning: look-alikes planted in this wallet's history, and — seen from
656
+ // the other side — payments this address received right after luring the payer.
657
+ async function loadPoisoning(a){
658
+ const el=$('#v-poison');
659
+ el.innerHTML='<div class="muted"><span class="spin"></span>Comparing every address in this history…</div>';
660
+ try{
661
+ const r=await api(`/api/poisoning?address=${q(a)}&chain=${chain()}`);
662
+ const amt=v=>Number(v).toLocaleString(undefined,{maximumFractionDigits:2});
663
+ const addr=x=>`<a class="mono" href="${safeUrl(addrUrl(x))}" target="_blank" rel="noopener">${esc(x)}</a>`;
664
+ let h='';
665
+ for(const x of r.lured||[]){
666
+ h+=`<div class="verdict">Lured payment: ${addr(x.payer)} paid <b>${amt(x.paid)} ${esc(x.symbol)}</b>
667
+ on ${dstamp(x.paid_ts)} UTC, right after this address lured it (${esc(x.lure)}).
668
+ ${x.imitates?`It imitates ${addr(x.imitates)}, an address the payer had really used.`
669
+ :'<span class="muted">The imitated address was not found in the payer\'s recent history.</span>'}</div>`;
670
+ }
671
+ const paid=(r.lookalikes||[]).filter(p=>p.sent_to_lookalike>0);
672
+ for(const p of paid){
673
+ h+=`<div class="verdict">Money went to a look-alike: <b>${amt(p.sent_to_lookalike)} ${esc(p.symbols.join('/'))}</b>
674
+ <div class="kv"><span class="k">real address</span><span class="v">${addr(p.genuine)}</span></div>
675
+ <div class="kv"><span class="k">look-alike</span><span class="v">${addr(p.lookalike)}</span></div>
676
+ <div class="muted">Same first ${p.matching.prefix} and last ${p.matching.suffix} characters;
677
+ the look-alike first appeared ${dstamp(p.lookalike_first_seen)} UTC.</div></div>`;
678
+ }
679
+ if(r.campaign){
680
+ h+=`<div class="verdict">Poisoning campaign: this address sent ${r.campaign.bait_transfers} zero, dust or
681
+ counterfeit transfers to ${r.campaign.targets} different wallets.</div>`;
682
+ }
683
+ const planted=(r.lookalikes||[]).filter(p=>!(p.sent_to_lookalike>0));
684
+ if(planted.length){
685
+ h+=`<h3 style="font-size:11px;letter-spacing:.09em;color:var(--dim);margin:16px 0 8px">
686
+ ${planted.length} LOOK-ALIKE ADDRESS(ES) PLANTED IN THIS HISTORY — NEVER COPY AN ADDRESS FROM HERE</h3>
687
+ <table class="grid"><thead><tr><th>Look-alike</th><th>Imitates</th><th>Match</th>
688
+ <th style="text-align:right">Bait</th><th>First seen (UTC)</th></tr></thead><tbody>`+
689
+ planted.map(p=>`<tr><td>${addr(p.lookalike)}</td><td>${addr(p.genuine)}</td>
690
+ <td>${esc(p.resemblance)} (${p.matching.prefix}+${p.matching.suffix})</td>
691
+ <td class="num">${p.bait_transfers}</td><td>${dstamp(p.lookalike_first_seen)}</td></tr>`).join('')+
692
+ '</tbody></table>';
693
+ }
694
+ el.innerHTML=h||'<div class="muted">No look-alike addresses or poisoning pattern in this history.</div>';
695
+ }catch(e){ el.innerHTML=`<div class="err">${esc(e.message)}</div>`; }
696
+ }
697
+
698
+ // The case file: the same one-page HTML `crypttrace report` writes, built by the
699
+ // backend and handed to the browser as a download.
700
+ async function downloadCaseFile(){
701
+ const a=$('#addr').value.trim(), b=$('#casefile');
702
+ if(!a){ setStatus('enter an address',true); return; }
703
+ b.disabled=true; setStatus('building the case file…',false,true);
704
+ try{
705
+ const r=await fetch(`/api/report?address=${q(a)}&chain=${chain()}&asset=${q($('#asset').value)}&depth=${q($('#depth').value)}`);
706
+ if(!r.ok){ const j=await r.json().catch(()=>({})); throw new Error(j.error||`HTTP ${r.status}`); }
707
+ const name=((r.headers.get('Content-Disposition')||'').match(/filename="([^"]+)"/)||[])[1]||'case-file.html';
708
+ const u=URL.createObjectURL(await r.blob()), l=document.createElement('a');
709
+ l.href=u; l.download=name; l.click(); URL.revokeObjectURL(u);
710
+ setStatus(`case file saved: ${name}`);
711
+ }catch(e){ setStatus(e.message,true); }
712
+ finally{ b.disabled=false; }
713
+ }
714
+ $('#casefile').addEventListener('click',downloadCaseFile);
715
+
650
716
  function downloadCsv(){
651
717
  const head=['address','hop',`amount_sent_${victimSym}`,'transactions','first_seen_utc','last_seen_utc','label','explorer'];
652
718
  const body=victimRows.map(r=>[r.address,r.hop,r.value,r.txs,dstamp(r.first_seen),dstamp(r.last_seen),
@@ -10,7 +10,7 @@ Launch with: crypttrace serve (then open http://127.0.0.1:8000)
10
10
  from datetime import datetime
11
11
  from pathlib import Path
12
12
 
13
- from flask import Flask, jsonify, request, send_from_directory
13
+ from flask import Flask, Response, jsonify, request, send_from_directory
14
14
 
15
15
  from crypttrace.fetchers import etherscan
16
16
  from crypttrace.labels import labels
@@ -255,6 +255,40 @@ def create_app() -> Flask:
255
255
  return jsonify({"error": str(e)}), 400
256
256
  return jsonify({"hops": hops})
257
257
 
258
+ @app.route("/api/poisoning")
259
+ def api_poisoning():
260
+ addr = request.args.get("address", "")
261
+ chain = request.args.get("chain", "eth")
262
+ bad = validate(addr, chain)
263
+ if bad:
264
+ return jsonify({"error": bad}), 400
265
+ from crypttrace import poisoning
266
+ try:
267
+ return jsonify({"lookalikes": poisoning.lookalikes(addr, chain),
268
+ "lured": poisoning.baited_payments(addr, chain),
269
+ "campaign": poisoning.campaign(addr, chain)})
270
+ except _KNOWN_ERRORS as e:
271
+ return jsonify({"error": str(e)}), 400
272
+
273
+ @app.route("/api/report")
274
+ def api_report():
275
+ """The HTML case file, built in memory and handed over as a download."""
276
+ addr = request.args.get("address", "")
277
+ chain = request.args.get("chain", "eth")
278
+ bad = validate(addr, chain)
279
+ if bad:
280
+ return jsonify({"error": bad}), 400
281
+ from crypttrace import report as report_mod
282
+ try:
283
+ asset = assets.resolve_asset(request.args.get("asset") or None, chain)
284
+ depth = max(1, min(5, int(request.args.get("depth", 3))))
285
+ data, raw, digest = report_mod.build(addr, chain, depth, 3, asset)
286
+ except _KNOWN_ERRORS as e:
287
+ return jsonify({"error": str(e)}), 400
288
+ name = report_mod.case_name(addr, chain) + ".html"
289
+ return Response(report_mod.render_html(data, raw, digest, None), mimetype="text/html",
290
+ headers={"Content-Disposition": f'attachment; filename="{name}"'})
291
+
258
292
  @app.route("/api/freeze")
259
293
  def api_freeze():
260
294
  addr = request.args.get("address", "")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.7.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
@@ -131,7 +131,11 @@ crypttrace serve # then open http://127.0.0.1:8000
131
131
  Paste an address, pick a chain and direction, and get an interactive fund-flow
132
132
  graph: nodes coloured by what they are, arrows showing where value went, click a
133
133
  node for its full profile, click a transfer line to open it on the block
134
- explorer. A side panel shows the profile, first-funder chain and off-ramp check.
134
+ explorer. A side panel shows the profile, first-funder chain, off-ramp check
135
+ and whether USDT/USDC at the address is frozen. Tabs add the assessment, the
136
+ timeline, the list of who sent funds here, and address poisoning (look-alikes
137
+ planted in the wallet's history, and payments lured by this address). **Case
138
+ file** downloads the same one-page HTML report `crypttrace report` writes.
135
139
  Everything runs on your machine — nothing is uploaded anywhere.
136
140
 
137
141
  This exists so non-technical victims can use the tool at all: a form and a
@@ -222,7 +226,7 @@ crypttrace timeline bc1qADDRESS --chain btc
222
226
  crypttrace crosschain 0xADDRESS --window 48
223
227
 
224
228
  # Watch addresses; alert loudly the moment funds head for an exchange
225
- crypttrace watch add 0xADDRESS --note "my stolen ETH"
229
+ crypttrace watch add TADDRESS --chain tron --note "my stolen USDT"
226
230
  crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
227
231
 
228
232
  # Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
@@ -273,15 +277,22 @@ toward an exchange (directly, or to a detected deposit address); quieter notices
273
277
  for other movement. It only alerts on activity *after* an address is added, and
274
278
  never double-alerts.
275
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
+
276
286
  ```bash
277
- crypttrace watch add 0xADDRESS --note "victim funds"
287
+ crypttrace watch add TADDRESS --chain tron --note "victim funds"
278
288
  crypttrace watch list
279
289
  crypttrace watch run --interval 300 # continuous
280
290
  crypttrace watch run --once # single check, for scheduled tasks
281
291
  ```
282
292
 
283
293
  Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
284
- then pass `--telegram`.
294
+ then pass `--telegram`. Each alert carries the chain, amount, asset and a link
295
+ to the transaction.
285
296
 
286
297
  ### Stablecoin freezes
287
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