crypttrace 0.6.0__tar.gz → 0.7.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.6.0/src/crypttrace.egg-info → crypttrace-0.7.0}/PKG-INFO +20 -9
  2. {crypttrace-0.6.0 → crypttrace-0.7.0}/README.md +19 -8
  3. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/assess.py +1 -1
  5. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/chains.py +12 -0
  6. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/cli.py +12 -9
  7. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/freeze.py +13 -2
  8. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/investigate.py +9 -7
  9. crypttrace-0.7.0/src/crypttrace/report.py +470 -0
  10. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/store.py +3 -1
  11. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/web/index.html +3 -3
  12. {crypttrace-0.6.0 → crypttrace-0.7.0/src/crypttrace.egg-info}/PKG-INFO +20 -9
  13. crypttrace-0.6.0/src/crypttrace/report.py +0 -175
  14. {crypttrace-0.6.0 → crypttrace-0.7.0}/LICENSE +0 -0
  15. {crypttrace-0.6.0 → crypttrace-0.7.0}/pyproject.toml +0 -0
  16. {crypttrace-0.6.0 → crypttrace-0.7.0}/setup.cfg +0 -0
  17. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/addresses.py +0 -0
  18. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/analysis.py +0 -0
  19. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/assets.py +0 -0
  20. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/bridges.py +0 -0
  21. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/config.py +0 -0
  22. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/__init__.py +0 -0
  23. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  24. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  25. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/http.py +0 -0
  26. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/solana.py +0 -0
  27. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/tron.py +0 -0
  28. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/funder.py +0 -0
  29. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/labels/__init__.py +0 -0
  30. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/labels/audit.py +0 -0
  31. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/labels/bulk.py +0 -0
  32. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/labels/known.json +0 -0
  33. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/labels/labels.py +0 -0
  34. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/labels/partial.json +0 -0
  35. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/offramp.py +0 -0
  36. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/poisoning.py +0 -0
  37. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/prices.py +0 -0
  38. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/render.py +0 -0
  39. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/trace.py +0 -0
  40. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/verify.py +0 -0
  41. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/watch.py +0 -0
  42. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/webapp.py +0 -0
  43. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
  44. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  45. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  46. {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace.egg-info/requires.txt +0 -0
  47. {crypttrace-0.6.0 → crypttrace-0.7.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.6.0
3
+ Version: 0.7.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
@@ -225,7 +225,7 @@ crypttrace crosschain 0xADDRESS --window 48
225
225
  crypttrace watch add 0xADDRESS --note "my stolen ETH"
226
226
  crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
227
227
 
228
- # Full investigation report to disk (Markdown + JSON)
228
+ # Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
229
229
  crypttrace report 0xADDRESS --depth 3
230
230
 
231
231
  # Refresh label lists (OFAC sanctions, …)
@@ -460,10 +460,24 @@ are ignored, and every address is checked before it is stored.
460
460
 
461
461
  ### Reports
462
462
 
463
- `report` runs the full analysis and writes to `~/crypttrace-reports/` (override
464
- with `--out`): a readable Markdown report (assessment, summary, key findings,
465
- counterparties, the full fund-flow trace, methodology note) plus a `.json` with
466
- the raw structured data.
463
+ `report` runs the full analysis and writes a **case file** to
464
+ `~/crypttrace-reports/` (override with `--out`); `investigate` saves the same
465
+ file with the victim's next steps in it. The case file is one HTML page, made
466
+ for whoever receives it — an exchange's compliance team, the police, a lawyer:
467
+
468
+ - the conclusion in plain words, and what to do now: stablecoins still at the
469
+ address and who can freeze them, the exchanges the money reached, address
470
+ poisoning;
471
+ - the fund-flow graph, each address linking to a block explorer;
472
+ - every transfer along the trace with its transaction hashes — the first thing
473
+ an exchange asks for;
474
+ - where each label comes from, how the tool checked its own arithmetic, and
475
+ what the method cannot tell you.
476
+
477
+ It has no scripts and loads nothing from the internet, so it opens in any
478
+ browser or mail client, prints (or saves as PDF) cleanly, and opening it tells
479
+ no one. The raw data is embedded for analysts and written next to it as JSON,
480
+ with its SHA-256 printed on the page; a Markdown version is written too.
467
481
 
468
482
  ### Self-verification
469
483
 
@@ -561,9 +575,6 @@ cases/ # worked investigations with their data
561
575
 
562
576
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
563
577
  and refreshing these lists as the exchanges republish them
564
- - `report --html`: one self-contained file with the interactive graph, to
565
- send to an exchange or attach to a police report
566
- - `report --pdf` for exchange and law-enforcement filings
567
578
  - Internal transactions (completes `funder` and contract-mediated transfers)
568
579
  - Per-mint filtering for Solana SPL tokens
569
580
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
@@ -191,7 +191,7 @@ crypttrace crosschain 0xADDRESS --window 48
191
191
  crypttrace watch add 0xADDRESS --note "my stolen ETH"
192
192
  crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
193
193
 
194
- # Full investigation report to disk (Markdown + JSON)
194
+ # Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
195
195
  crypttrace report 0xADDRESS --depth 3
196
196
 
197
197
  # Refresh label lists (OFAC sanctions, …)
@@ -426,10 +426,24 @@ are ignored, and every address is checked before it is stored.
426
426
 
427
427
  ### Reports
428
428
 
429
- `report` runs the full analysis and writes to `~/crypttrace-reports/` (override
430
- with `--out`): a readable Markdown report (assessment, summary, key findings,
431
- counterparties, the full fund-flow trace, methodology note) plus a `.json` with
432
- the raw structured data.
429
+ `report` runs the full analysis and writes a **case file** to
430
+ `~/crypttrace-reports/` (override with `--out`); `investigate` saves the same
431
+ file with the victim's next steps in it. The case file is one HTML page, made
432
+ for whoever receives it — an exchange's compliance team, the police, a lawyer:
433
+
434
+ - the conclusion in plain words, and what to do now: stablecoins still at the
435
+ address and who can freeze them, the exchanges the money reached, address
436
+ poisoning;
437
+ - the fund-flow graph, each address linking to a block explorer;
438
+ - every transfer along the trace with its transaction hashes — the first thing
439
+ an exchange asks for;
440
+ - where each label comes from, how the tool checked its own arithmetic, and
441
+ what the method cannot tell you.
442
+
443
+ It has no scripts and loads nothing from the internet, so it opens in any
444
+ browser or mail client, prints (or saves as PDF) cleanly, and opening it tells
445
+ no one. The raw data is embedded for analysts and written next to it as JSON,
446
+ with its SHA-256 printed on the page; a Markdown version is written too.
433
447
 
434
448
  ### Self-verification
435
449
 
@@ -527,9 +541,6 @@ cases/ # worked investigations with their data
527
541
 
528
542
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
529
543
  and refreshing these lists as the exchanges republish them
530
- - `report --html`: one self-contained file with the interactive graph, to
531
- send to an exchange or attach to a police report
532
- - `report --pdf` for exchange and law-enforcement filings
533
544
  - Internal transactions (completes `funder` and contract-mediated transfers)
534
545
  - Per-mint filtering for Solana SPL tokens
535
546
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.6.0"
2
+ __version__ = "0.7.0"
@@ -240,7 +240,7 @@ def assess(address: str, chain: str = "eth", asset: Optional[dict] = None,
240
240
  e = frozen[0]
241
241
  signals.append(Signal(
242
242
  name="frozen by issuer",
243
- observed=f"{e['frozen_amount']:,.2f} {e['token']} at this address is frozen by {e['issuer']}",
243
+ observed=freeze.describe_frozen(e),
244
244
  implication="the issuer blocked it — usually at the request of law enforcement "
245
245
  "or under sanctions",
246
246
  confidence="high", weight=40, evidence={"freezes": frozen}))
@@ -30,6 +30,14 @@ EXPLORER = {
30
30
  "sol": "https://solscan.io/account/{}",
31
31
  }
32
32
 
33
+ TX_EXPLORER = {
34
+ "eth": "https://etherscan.io/tx/{}", "bsc": "https://bscscan.com/tx/{}",
35
+ "polygon": "https://polygonscan.com/tx/{}", "arbitrum": "https://arbiscan.io/tx/{}",
36
+ "optimism": "https://optimistic.etherscan.io/tx/{}", "base": "https://basescan.org/tx/{}",
37
+ "btc": "https://mempool.space/tx/{}", "tron": "https://tronscan.org/#/transaction/{}",
38
+ "sol": "https://solscan.io/tx/{}",
39
+ }
40
+
33
41
  _UPSTREAM_ERRORS = (etherscan.EtherscanError, bitcoin.BitcoinError,
34
42
  tron.TronError, solana.SolanaError)
35
43
 
@@ -59,6 +67,10 @@ def explorer_url(address: str, chain: str) -> str:
59
67
  return EXPLORER.get(chain, EXPLORER["eth"]).format(address)
60
68
 
61
69
 
70
+ def tx_url(tx_hash: str, chain: str) -> str:
71
+ return TX_EXPLORER.get(chain, TX_EXPLORER["eth"]).format(tx_hash)
72
+
73
+
62
74
  def check(chain: str) -> None:
63
75
  if chain not in ALL_CHAINS:
64
76
  raise ChainError(f"unsupported chain '{chain}'. Options: {ALL_CHAINS}")
@@ -224,7 +224,7 @@ def report(
224
224
  help="Folder to save the report in",
225
225
  ),
226
226
  ):
227
- """Run a full investigation and save a Markdown + JSON report to disk."""
227
+ """Run a full investigation and save it as an HTML case file (plus Markdown and JSON)."""
228
228
  try:
229
229
  asset_desc = assets.resolve_asset(asset, chain)
230
230
  except ValueError as e:
@@ -232,12 +232,14 @@ def report(
232
232
  raise typer.Exit(1)
233
233
  try:
234
234
  with console.status("Gathering on-chain data and tracing funds…"):
235
- md_path = report_mod.generate(address, chain, depth, branching, out, asset_desc)
236
- except etherscan.EtherscanError as e:
235
+ paths = report_mod.generate(address, chain, depth, branching, out, asset_desc)
236
+ except (chains_mod.ChainError, etherscan.EtherscanError) as e:
237
237
  console.print(f"[red]Error:[/red] {e}")
238
238
  raise typer.Exit(1)
239
- console.print(f"[green]✓ Report saved:[/green] {md_path}")
240
- console.print(f"[dim] Raw data (JSON) saved alongside it in the same folder.[/dim]")
239
+ console.print(f"[green]✓ Case file saved:[/green] {paths['html']}")
240
+ console.print("[dim] One self-contained page: open it in a browser, print it to PDF, or "
241
+ "send it as it is.[/dim]")
242
+ console.print("[dim] Markdown and raw JSON are saved next to it.[/dim]")
241
243
 
242
244
 
243
245
  @app.command()
@@ -279,14 +281,15 @@ def freeze(
279
281
  if e.get("error"):
280
282
  console.print(f"{head}: [yellow]could not be read[/yellow] — {e['error']}")
281
283
  elif e["frozen"]:
282
- console.print(f"{head}: [bold green]FROZEN[/bold green] — {e['frozen_amount']:,.2f} "
283
- f"{e['token']} cannot move" + (f"; {e['movable']:,.2f} still can"
284
- if e["movable"] else ""))
285
- elif e["balance"]:
284
+ console.print(f"{head}: [bold green]FROZEN[/bold green] — {freeze_mod.describe_frozen(e)}"
285
+ + (f"; {e['movable']:,.2f} still can move" if e["actionable"] else ""))
286
+ elif e["actionable"]:
286
287
  console.print(f"{head}: [bold red]{e['balance']:,.2f} {e['token']} NOT frozen[/bold red]"
287
288
  " — it can still be moved")
288
289
  console.print(f" {e['how']}")
289
290
  console.print(f" [dim]{e['url']}[/dim]")
291
+ elif e["balance"]:
292
+ console.print(f"{head}: only dust ({e['balance']:.6f}) — nothing to freeze")
290
293
  else:
291
294
  console.print(f"{head}: none at this address")
292
295
  if labels.type_of(address) == "exchange":
@@ -48,6 +48,7 @@ ISSUERS = {
48
48
  }
49
49
 
50
50
  SUPPORTED = ("eth", "tron", "sol")
51
+ MIN_ACTIONABLE = 1.0 # USDT/USDC below this is dust
51
52
 
52
53
 
53
54
  class FreezeError(RuntimeError):
@@ -117,8 +118,18 @@ def _one(address: str, chain: str, symbol: str, contract: str) -> Dict:
117
118
  balance = sum(a["amount"] for a in accts)
118
119
  frozen_amount = sum(a["amount"] for a in accts if a["state"] == "frozen")
119
120
  frozen = any(a["state"] == "frozen" for a in accts)
121
+ movable = balance - frozen_amount
120
122
  return {"token": symbol, "balance": balance, "frozen": frozen,
121
- "frozen_amount": frozen_amount, "movable": balance - frozen_amount}
123
+ "frozen_amount": frozen_amount, "movable": movable,
124
+ # leftover dust is not worth a freeze request, or a victim's panic
125
+ "actionable": movable >= MIN_ACTIONABLE}
126
+
127
+
128
+ def describe_frozen(e: Dict) -> str:
129
+ """'4,021.97 USDT here is frozen by Tether', or the blacklisting alone when empty."""
130
+ if (e.get("frozen_amount") or 0) >= MIN_ACTIONABLE:
131
+ return f"{e['frozen_amount']:,.2f} {e['token']} here is frozen by {e['issuer']}"
132
+ return f"This address is blacklisted by {e['issuer']} (no {e['token']} left on it)"
122
133
 
123
134
 
124
135
  def check(address: str, chain: str) -> List[Dict]:
@@ -144,7 +155,7 @@ def check(address: str, chain: str) -> List[Dict]:
144
155
  entry.update(_one(address, chain, symbol, tok["contract"]))
145
156
  except (FreezeError, KeyError, TypeError, ValueError) as e:
146
157
  entry.update({"balance": None, "frozen": None, "frozen_amount": None,
147
- "movable": None, "error": str(e)})
158
+ "movable": None, "actionable": None, "error": str(e)})
148
159
  out.append(entry)
149
160
  return out
150
161
 
@@ -32,7 +32,7 @@ def analyse(address: str, chain: str = "eth", asset: Optional[dict] = None,
32
32
  """Run every check and return a structured result (no printing)."""
33
33
  result = {
34
34
  "address": address, "chain": chain,
35
- "asset": asset["symbol"] if asset else chains.symbol(chain),
35
+ "asset": asset["symbol"] if asset else chains.symbol(chain), "depth": depth,
36
36
  "generated": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
37
37
  "errors": [],
38
38
  }
@@ -154,13 +154,13 @@ def build_guidance(r: dict) -> dict:
154
154
  for e in r.get("freeze") or []:
155
155
  if e.get("frozen"):
156
156
  steps.insert(0, {
157
- "title": f"{e['frozen_amount']:,.2f} {e['token']} here is already frozen by {e['issuer']}",
157
+ "title": freeze_mod.describe_frozen(e),
158
158
  "body": ("It cannot be moved. Frozen funds can be returned to victims, but only "
159
159
  f"through a legal process: tell the police and {e['issuer']} that you "
160
160
  "are a victim of this address, and include this report."),
161
161
  "urgent": True,
162
162
  })
163
- elif e.get("movable"):
163
+ elif e.get("actionable"):
164
164
  steps.insert(0, {
165
165
  "title": f"Ask for a freeze: {e['movable']:,.2f} {e['token']} is still here",
166
166
  "body": (f"{e['how']} It can be moved at any moment, so this is the most "
@@ -226,11 +226,13 @@ def guidance_markdown(r: dict) -> str:
226
226
 
227
227
 
228
228
  def save_case(r: dict, out_dir: Path, asset: Optional[dict] = None) -> Path:
229
- """Write the full report plus the guidance the victim can act on."""
230
- md_path = report_mod.generate(r["address"], r["chain"], 3, 3, out_dir, asset)
229
+ """Write the case file (HTML, plus Markdown and JSON) with the victim's next steps;
230
+ returns the HTML one — the file to send."""
231
+ paths = report_mod.generate(r["address"], r["chain"], r.get("depth", 3), 3, out_dir, asset,
232
+ guidance=r.get("guidance"))
231
233
  try:
232
- with open(md_path, "a", encoding="utf-8") as fh:
234
+ with open(paths["md"], "a", encoding="utf-8") as fh:
233
235
  fh.write(guidance_markdown(r))
234
236
  except OSError:
235
237
  pass
236
- return md_path
238
+ return paths["html"]
@@ -0,0 +1,470 @@
1
+ """Investigation reports: an HTML case file to hand over, plus Markdown and JSON.
2
+
3
+ The HTML file is the one a victim sends to an exchange or attaches to a police
4
+ report, so it is built for the person receiving it:
5
+
6
+ * one file, nothing external — no scripts, fonts or CDN, so it opens in a
7
+ locked-down browser or mail client, prints, and "opening it" tells nobody;
8
+ * the fund-flow graph as a static SVG, each address linking to a block
9
+ explorer, so every line can be checked independently;
10
+ * the transfers along the trace with their transaction hashes — what an
11
+ exchange's compliance team asks for first;
12
+ * where every label comes from, how the tool checked its own arithmetic, and
13
+ what the method cannot tell you.
14
+
15
+ The same data is written as JSON (for analysts) and Markdown. Everything is read
16
+ through the shared chain layer, so reports work on every supported chain.
17
+ """
18
+ import hashlib
19
+ import html
20
+ import json
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Dict, List, Optional
24
+
25
+ from rich.console import Console
26
+
27
+ from crypttrace import __version__, chains, prices
28
+ from crypttrace import freeze as freeze_mod
29
+ from crypttrace import trace as trace_mod
30
+ from crypttrace.labels import labels
31
+
32
+ SOURCES = {"btc": "mempool.space", "tron": "TronGrid", "sol": "Solana JSON-RPC"}
33
+
34
+
35
+ def _now() -> str:
36
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
37
+
38
+
39
+ def _ts(unix) -> str:
40
+ try:
41
+ return datetime.fromtimestamp(int(unix), tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
42
+ except (ValueError, TypeError, OSError):
43
+ return "?"
44
+
45
+
46
+ def _tree_text(tree) -> str:
47
+ """Render the rich trace tree to plain text (ANSI stripped)."""
48
+ con = Console(record=True, width=100, file=None)
49
+ with con.capture() as cap:
50
+ con.print(tree)
51
+ return cap.get()
52
+
53
+
54
+ def _headline(findings: list) -> str:
55
+ types = {f["type"] for f in findings}
56
+ if "sanctioned" in types:
57
+ return ("Funds from this address reach a **sanctioned / known-criminal wallet** — "
58
+ "escalate to law enforcement.")
59
+ if "mixer" in types:
60
+ return ("Funds from this address flow into a **mixer** (privacy pool), where on-chain "
61
+ "tracing terminates. Recovery from here requires timing/amount heuristics or "
62
+ "off-chain data.")
63
+ if types & {"exchange", "offramp"}:
64
+ return ("Funds from this address reach a **centralised exchange** — a KYC handoff point. "
65
+ "Identifying the owner requires a legal request to that exchange.")
66
+ return ("No labelled entities were reached within the traced depth. Increase --depth or "
67
+ "extend the label database, then re-run.")
68
+
69
+
70
+ def _counterparties(me: str, rows: List[dict], top: int = 15) -> List[dict]:
71
+ agg: Dict[str, list] = {}
72
+ for r in rows:
73
+ frm, to = r.get("from") or "", r.get("to") or ""
74
+ other = to if frm == me else frm if to == me else ""
75
+ if not other or other == me:
76
+ continue
77
+ rec = agg.setdefault(other, [0.0, 0.0, 0])
78
+ rec[1 if frm == me else 0] += r.get("value", 0) or 0
79
+ rec[2] += 1
80
+ ranked = sorted(agg.items(), key=lambda kv: kv[1][0] + kv[1][1], reverse=True)[:top]
81
+ return [{"address": a, "in": round(i, 6), "out": round(o, 6), "txs": n,
82
+ "label": labels.label_of(a), "type": labels.type_of(a)}
83
+ for a, (i, o, n) in ranked]
84
+
85
+
86
+ def _edge_evidence(graph: dict, chain: str, asset) -> None:
87
+ """Attach the transactions behind each graph edge (hashes, first/last time).
88
+
89
+ The history is already in the local store from the trace, so this costs no
90
+ requests."""
91
+ for e in graph["edges"]:
92
+ try:
93
+ rows = chains.transfers(e["from"], chain, 1000, asset=asset)
94
+ except chains.ChainError:
95
+ rows = []
96
+ hits = sorted((r for r in rows if r.get("from") == e["from"] and r.get("to") == e["to"]),
97
+ key=lambda r: r.get("timestamp", 0))
98
+ e["hashes"] = [r["hash"] for r in hits if r.get("hash")][:10]
99
+ e["first_ts"] = hits[0].get("timestamp") if hits else None
100
+ e["last_ts"] = hits[-1].get("timestamp") if hits else None
101
+
102
+
103
+ def collect(address: str, chain: str, depth: int, branching: int, asset=None) -> dict:
104
+ """Everything a report shows, as plain data (also the JSON file)."""
105
+ from crypttrace import assess as assess_mod, freeze as freeze_mod
106
+ from crypttrace.labels import audit
107
+
108
+ me = chains.norm_addr(address, chain)
109
+ symbol = asset["symbol"] if asset else chains.symbol(chain)
110
+ errors: List[str] = []
111
+
112
+ balance, rows = None, []
113
+ try:
114
+ balance = chains.balance(address, chain)
115
+ except chains.ChainError as e:
116
+ errors.append(f"balance: {e}")
117
+ try:
118
+ rows = chains.transfers(address, chain, 1000, asset=asset)
119
+ except chains.ChainError as e:
120
+ errors.append(f"history: {e}")
121
+
122
+ tree_text, findings, graph = "", [], {"nodes": [], "edges": [], "symbol": symbol}
123
+ try:
124
+ tree, found = trace_mod.build(address, chain, depth, branching, asset)
125
+ tree_text = _tree_text(tree)
126
+ best: Dict[str, dict] = {}
127
+ for f in found:
128
+ if f["address"] not in best or f["value_reached"] > best[f["address"]]["value_reached"]:
129
+ best[f["address"]] = f
130
+ findings = sorted(best.values(), key=lambda f: f["risk"], reverse=True)
131
+ graph = trace_mod.build_graph(address, chain, depth, branching, asset)
132
+ _edge_evidence(graph, chain, asset)
133
+ except chains.ChainError as e:
134
+ errors.append(f"trace: {e}")
135
+
136
+ try:
137
+ assessment = assess_mod.assess(address, chain, asset)
138
+ except Exception as e: # a report must still be written without it
139
+ assessment = {"error": str(e)}
140
+ errors.append(f"assessment: {e}")
141
+
142
+ hit = labels.lookup(address)
143
+ evidence = {n["id"]: audit.evidence(n["id"]) for n in graph["nodes"] if n.get("label")}
144
+ if hit and me not in evidence:
145
+ evidence[me] = audit.evidence(address)
146
+
147
+ return {
148
+ "tool": f"crypttrace v{__version__}",
149
+ "generated": _now(),
150
+ "subject": address, "chain": chain, "traced_asset": symbol,
151
+ "trace_depth": depth, "trace_branching": branching,
152
+ "data_source": SOURCES.get(chain, "Etherscan v2"),
153
+ "summary": {
154
+ "balance_native": None if balance is None else round(balance, 8),
155
+ "native_symbol": chains.symbol(chain),
156
+ "transfers_analysed": len(rows),
157
+ "first_seen": _ts(rows[-1]["timestamp"]) if rows else None,
158
+ "last_seen": _ts(rows[0]["timestamp"]) if rows else None,
159
+ "label": hit["name"] if hit else None,
160
+ "type": labels.type_of(address),
161
+ "risk_score": labels.risk_score(address),
162
+ },
163
+ "assessment": assessment,
164
+ "freeze": [] if labels.type_of(address) == "exchange" else freeze_mod.check(address, chain),
165
+ "key_findings": findings,
166
+ "graph": graph,
167
+ "label_evidence": evidence,
168
+ "top_counterparties": _counterparties(me, rows),
169
+ "tree_text": tree_text,
170
+ "errors": errors,
171
+ }
172
+
173
+
174
+ def generate(address: str, chain: str, depth: int, branching: int,
175
+ out_dir: Path, asset=None, guidance: Optional[dict] = None) -> Dict[str, Path]:
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
180
+ out_dir.mkdir(parents=True, exist_ok=True)
181
+ base = f"{chain}_{address[:10]}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
182
+ 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
+ # bytes, not text mode: on Windows text mode turns LF into CRLF, and the hash
185
+ # printed in the HTML would no longer match the file next to it
186
+ paths["json"].write_bytes(raw.encode("utf-8"))
187
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
188
+ paths["md"].write_text(_render_md(data, paths["json"].name), encoding="utf-8")
189
+ paths["html"].write_text(render_html(data, raw, digest, paths["json"].name), encoding="utf-8")
190
+ return paths
191
+
192
+
193
+ # ------------------------------------------------------------------ markdown
194
+
195
+ def _render_md(d: dict, json_name: str) -> str:
196
+ s = d["summary"]
197
+ a = d.get("assessment") or {}
198
+ L = [f"# crypttrace investigation report\n\n",
199
+ f"**Generated:** {d['generated']} • **Tool:** {d['tool']} \n",
200
+ f"**Subject:** `{d['subject']}` • **Chain:** {d['chain']} • "
201
+ f"**Traced asset:** {d['traced_asset']}\n",
202
+ "\n## Assessment\n", (a.get("assessment") or _headline(d["key_findings"])) + "\n"]
203
+ if a.get("risk") is not None:
204
+ L.append(f"\nRisk {a['risk']}/100, confidence {a.get('confidence')}.\n")
205
+ for f in d.get("freeze") or []:
206
+ if f.get("frozen"):
207
+ L.append(f"\n**{freeze_mod.describe_frozen(f)}.**\n")
208
+ elif f.get("actionable"):
209
+ L.append(f"\n**{f['movable']:,.2f} {f['token']} is still at this address and not "
210
+ f"frozen.** {f['how']}\n")
211
+ L += ["\n## Summary\n", "| Field | Value |\n|---|---|\n",
212
+ f"| Balance | {s['balance_native']} {s['native_symbol']} |\n",
213
+ f"| Transfers analysed | {s['transfers_analysed']} |\n",
214
+ f"| First seen | {s['first_seen']} |\n", f"| Last seen | {s['last_seen']} |\n",
215
+ f"| Label | {s['label'] or '—'} |\n", f"| Risk score | {s['risk_score']}/100 |\n",
216
+ "\n## Key findings\n"]
217
+ if d["key_findings"]:
218
+ L.append("| Entity | Type | Risk | Value reached | ≈ USD |\n|---|---|---|---|---|\n")
219
+ for f in d["key_findings"]:
220
+ L.append(f"| {f['label']} | {f['type']} | {f['risk']}/100 | {f['value_reached']} "
221
+ f"{f.get('symbol', '')} | {prices.fmt_usd(f.get('usd_reached'))} |\n")
222
+ else:
223
+ L.append("_None within the traced depth._\n")
224
+ L += ["\n## Top counterparties\n", "| Address | Label | In | Out | Txs |\n|---|---|---|---|---|\n"]
225
+ for c in d["top_counterparties"]:
226
+ L.append(f"| `{c['address']}` | {c['label'] or '—'} | {c['in']} | {c['out']} | {c['txs']} |\n")
227
+ L += [f"\n## Fund-flow trace (depth {d['trace_depth']})\n",
228
+ "```\n" + (d["tree_text"] or "").rstrip() + "\n```\n",
229
+ "\n## Methodology & limitations\n", _METHOD + "\n",
230
+ f"\n---\n_Raw structured data: `{json_name}` (same folder)._\n"]
231
+ return "".join(L)
232
+
233
+
234
+ _METHOD = (
235
+ "Data comes from the public blockchain. The trace follows the largest outgoing transfers "
236
+ "from each address and stops at identifiable entities (exchanges, mixers, sanctioned "
237
+ "wallets). Exchange deposit addresses are recognised either from lists exchanges publish "
238
+ "themselves or by behaviour (forwarding most funds to one exchange), which is a strong "
239
+ "lead, not proof. The blockchain is pseudonymous: reaching an address does not identify "
240
+ "its owner — an exchange can, on a legal request. Mixers sever the trail. This report is "
241
+ "an investigative aid, not proof of wrongdoing.")
242
+
243
+
244
+ # ---------------------------------------------------------------------- html
245
+
246
+ _TYPE_COLOUR = {"exchange": "#1f9d55", "offramp": "#1f9d55", "mixer": "#7c3aed",
247
+ "sanctioned": "#dc2626", "scam": "#dc2626", "bridge": "#0891b2",
248
+ "unknown": "#64748b"}
249
+
250
+
251
+ def _e(v) -> str:
252
+ return html.escape("" if v is None else str(v), quote=True)
253
+
254
+
255
+ def _link(url: str, text: str, mono: bool = True) -> str:
256
+ cls = ' class="mono"' if mono else ""
257
+ return f'<a href="{_e(url)}" target="_blank" rel="noopener"{cls}>{_e(text)}</a>'
258
+
259
+
260
+ def _amount(v, symbol="") -> str:
261
+ if v is None:
262
+ return "—"
263
+ return f"{v:,.4f}".rstrip("0").rstrip(".") + (f" {symbol}" if symbol else "")
264
+
265
+
266
+ def _svg(graph: dict, chain: str) -> str:
267
+ """Left-to-right layered drawing: one column per hop from the subject."""
268
+ nodes = {n["id"]: n for n in graph["nodes"]}
269
+ if not nodes:
270
+ return '<p class="muted">No transfers to draw at this depth.</p>'
271
+ W, H, COL, ROW, PAD = 210, 46, 290, 74, 20
272
+ levels: Dict[int, List[str]] = {}
273
+ for n in graph["nodes"]:
274
+ levels.setdefault(n.get("level", 0), []).append(n["id"])
275
+ # order each column by where its parents sit, which keeps lines from crossing
276
+ ypos: Dict[str, float] = {}
277
+ for lvl in sorted(levels):
278
+ def parent_y(nid):
279
+ ys = [ypos[e["from"]] for e in graph["edges"] if e["to"] == nid and e["from"] in ypos]
280
+ return sum(ys) / len(ys) if ys else 0
281
+ levels[lvl].sort(key=parent_y)
282
+ for i, nid in enumerate(levels[lvl]):
283
+ ypos[nid] = i
284
+ tallest = max(len(v) for v in levels.values())
285
+ width = PAD * 2 + (max(levels) + 1) * COL - (COL - W)
286
+ height = PAD * 2 + tallest * ROW
287
+ xy = {}
288
+ for lvl, ids in levels.items():
289
+ top = PAD + (tallest - len(ids)) * ROW / 2
290
+ for i, nid in enumerate(ids):
291
+ xy[nid] = (PAD + lvl * COL, top + i * ROW)
292
+
293
+ sym = graph.get("symbol", "")
294
+ out = [f'<svg viewBox="0 0 {width} {height}" width="{width}" role="img" '
295
+ f'aria-label="Fund-flow graph" xmlns="http://www.w3.org/2000/svg">',
296
+ '<defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" '
297
+ 'markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" '
298
+ 'fill="#94a3b8"/></marker></defs>']
299
+ for e in graph["edges"]:
300
+ if e["from"] not in xy or e["to"] not in xy:
301
+ continue
302
+ (x1, y1), (x2, y2) = xy[e["from"]], xy[e["to"]]
303
+ sx, sy, tx, ty = x1 + W, y1 + H / 2, x2, y2 + H / 2
304
+ mid = (sx + tx) / 2
305
+ out.append(f'<path d="M{sx},{sy} C{mid},{sy} {mid},{ty} {tx - 2},{ty}" fill="none" '
306
+ f'stroke="#94a3b8" stroke-width="1.6" marker-end="url(#arr)"/>')
307
+ label = f"{_amount(e['value'])} {sym} · {e['tx']} tx"
308
+ out.append(f'<text x="{mid}" y="{(sy + ty) / 2 - 5}" text-anchor="middle" '
309
+ f'class="edge">{_e(label)}</text>')
310
+ for nid, (x, y) in xy.items():
311
+ n = nodes[nid]
312
+ colour = _TYPE_COLOUR.get(n.get("type"), _TYPE_COLOUR["unknown"])
313
+ title = (n.get("label") + "\n" if n.get("label") else "") + nid
314
+ name = n.get("label") or ("subject" if n.get("root") else "unlabelled")
315
+ out.append(f'<a href="{_e(chains.explorer_url(nid, chain))}" target="_blank" rel="noopener">'
316
+ f'<title>{_e(title)}</title>'
317
+ f'<rect x="{x}" y="{y}" width="{W}" height="{H}" rx="8" fill="{colour}" '
318
+ f'fill-opacity="0.12" stroke="{colour}" stroke-width="{3 if n.get("root") else 1.4}"/>'
319
+ f'<text x="{x + 10}" y="{y + 19}" class="nname">{_e(name[:30])}</text>'
320
+ f'<text x="{x + 10}" y="{y + 36}" class="naddr">{_e(n["short"])}</text></a>')
321
+ out.append("</svg>")
322
+ return "".join(out)
323
+
324
+
325
+ _CSS = """
326
+ :root{--fg:#0f172a;--muted:#64748b;--line:#e2e8f0;--bg:#fff;--card:#f8fafc;--red:#b91c1c;--green:#15803d}
327
+ @media (prefers-color-scheme:dark){:root{--fg:#e2e8f0;--muted:#94a3b8;--line:#334155;--bg:#0b1120;--card:#111827}}
328
+ body{font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--fg);background:var(--bg);
329
+ max-width:1100px;margin:0 auto;padding:24px 16px}
330
+ h1{font-size:24px;margin:0 0 4px}h2{font-size:18px;margin:28px 0 10px;border-bottom:1px solid var(--line);padding-bottom:4px}
331
+ .muted{color:var(--muted)}.mono{font-family:ui-monospace,Consolas,monospace;font-size:13px;word-break:break-all}
332
+ .box{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin:10px 0}
333
+ .alert{border-color:var(--red)}.ok{border-color:var(--green)}
334
+ table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
335
+ th{color:var(--muted);font-weight:600}td.num{text-align:right;white-space:nowrap}
336
+ .graph{overflow-x:auto;border:1px solid var(--line);border-radius:10px;padding:8px;background:var(--card)}
337
+ svg text{fill:var(--fg);font-family:system-ui,sans-serif}svg .edge{font-size:11px;fill:var(--muted)}
338
+ svg .nname{font-size:12px;font-weight:600}svg .naddr{font-size:11px;font-family:ui-monospace,Consolas,monospace}
339
+ a{color:#2563eb}ol li{margin-bottom:8px}.pill{display:inline-block;padding:1px 8px;border-radius:99px;font-size:12px;border:1px solid var(--line)}
340
+ @media print{:root{--fg:#000;--muted:#444;--line:#bbb;--bg:#fff;--card:#fff}
341
+ body{max-width:none;padding:0}.graph{overflow:visible}a{color:inherit;text-decoration:none}}
342
+ """
343
+
344
+
345
+ def render_html(d: dict, raw_json: str, digest: str, json_name: str) -> str:
346
+ s, a, chain = d["summary"], d.get("assessment") or {}, d["chain"]
347
+ sym = d["traced_asset"]
348
+ P: List[str] = [
349
+ "<!doctype html><html lang='en'><head><meta charset='utf-8'>",
350
+ "<meta name='viewport' content='width=device-width,initial-scale=1'>",
351
+ f"<title>Case file — {_e(d['subject'][:12])}…</title><style>{_CSS}</style></head><body>",
352
+ "<h1>Crypto investigation case file</h1>",
353
+ f"<p class='muted'>Generated {_e(d['generated'])} by {_e(d['tool'])} · chain {_e(chain)} · "
354
+ f"traced asset {_e(sym)} · data from {_e(d['data_source'])}</p>",
355
+ f"<div class='box'><b>Subject address</b><br>{_link(chains.explorer_url(d['subject'], chain), d['subject'])}"
356
+ + (f"<br>Known as: <b>{_e(s['label'])}</b>" if s["label"] else "") + "</div>",
357
+ ]
358
+
359
+ # in short
360
+ P.append("<h2>In short</h2>")
361
+ P.append(f"<p>{_e(a.get('assessment') or _headline(d['key_findings']).replace('**', ''))}</p>")
362
+ if a.get("risk") is not None:
363
+ ver = (a.get("verification") or {}).get("status", "—")
364
+ P.append(f"<p><span class='pill'>risk {a['risk']}/100</span> <span class='pill'>confidence "
365
+ f"{_e(a.get('confidence'))}</span> <span class='pill'>own arithmetic: {_e(ver)}</span></p>")
366
+
367
+ # act now
368
+ acts = []
369
+ for f in d.get("freeze") or []:
370
+ if f.get("frozen"):
371
+ acts.append(f"<div class='box ok'><b>{_e(freeze_mod.describe_frozen(f))}.</b> Frozen "
372
+ "funds cannot be moved and can be returned to victims through a legal "
373
+ "process.</div>")
374
+ elif f.get("actionable"):
375
+ acts.append(f"<div class='box alert'><b>{f['movable']:,.2f} {_e(f['token'])} is still at this "
376
+ f"address and not frozen.</b> {_e(f['how'])} "
377
+ f"{_link(f['url'], f['issuer'] + ' policy', mono=False)}</div>")
378
+ for sig in a.get("signals") or []:
379
+ if sig["name"] in ("address poisoning", "paid a look-alike"):
380
+ acts.append(f"<div class='box alert'><b>Address poisoning.</b> {_e(sig['observed'])} — "
381
+ f"{_e(sig['implication'])}.</div>")
382
+ exch = sorted({labels.company(f["label"]) for f in d["key_findings"]
383
+ if f["type"] in ("exchange", "offramp")})
384
+ if exch:
385
+ acts.append(f"<div class='box'><b>The funds reach {_e(', '.join(exch))}.</b> An exchange knows "
386
+ "who owns the receiving account and can freeze it on request. Give its compliance "
387
+ "team the transaction hashes below.</div>")
388
+ if acts:
389
+ P.append("<h2>Act now</h2>" + "".join(acts))
390
+
391
+ guidance = d.get("guidance")
392
+ if guidance and guidance.get("steps"):
393
+ P.append("<h2>What to do next</h2><ol>")
394
+ for st in guidance["steps"]:
395
+ P.append(f"<li><b>{_e(st['title'])}</b>{' <span class=pill>do this first</span>' if st.get('urgent') else ''}"
396
+ f"<br>{_e(st['body'])}</li>")
397
+ P.append("</ol>")
398
+
399
+ # graph
400
+ P.append(f"<h2>Where the money went (depth {d['trace_depth']})</h2>")
401
+ P.append("<p class='muted'>Each box links to the address on a block explorer; hover for the full "
402
+ "address. Green: exchange · purple: mixer · red: sanctioned or scam · teal: bridge · "
403
+ "grey: unlabelled.</p>")
404
+ P.append(f"<div class='graph'>{_svg(d['graph'], chain)}</div>")
405
+
406
+ # transfers with hashes
407
+ edges = d["graph"]["edges"]
408
+ if edges:
409
+ P.append("<h2>Transfers along the trace</h2><table><tr><th>From</th><th>To</th>"
410
+ "<th>Amount</th><th>Txs</th><th>First / last (UTC)</th><th>Transactions</th></tr>")
411
+ for e in edges:
412
+ to_lbl = labels.label_of(e["to"])
413
+ hashes = " ".join(_link(chains.tx_url(h, chain), h[:10] + "…") for h in e.get("hashes", []))
414
+ P.append(f"<tr><td>{_link(chains.explorer_url(e['from'], chain), e['from'])}</td>"
415
+ f"<td>{_link(chains.explorer_url(e['to'], chain), e['to'])}"
416
+ + (f"<br><b>{_e(to_lbl)}</b>" if to_lbl else "") + "</td>"
417
+ f"<td class='num'>{_e(_amount(e['value'], sym))}</td><td class='num'>{e['tx']}</td>"
418
+ f"<td>{_e(_ts(e.get('first_ts')))}<br>{_e(_ts(e.get('last_ts')))}</td>"
419
+ f"<td>{hashes or '—'}</td></tr>")
420
+ P.append("</table>")
421
+
422
+ # signals
423
+ if a.get("signals"):
424
+ P.append("<h2>What the assessment rests on</h2><table><tr><th>Signal</th><th>Observed</th>"
425
+ "<th>Means</th><th>Confidence</th></tr>")
426
+ for sig in a["signals"]:
427
+ P.append(f"<tr><td>{_e(sig['name'])}</td><td>{_e(sig['observed'])}</td>"
428
+ f"<td>{_e(sig['implication'])}</td><td>{_e(sig['confidence'])}</td></tr>")
429
+ P.append("</table>")
430
+ for c in a.get("caveats") or []:
431
+ P.append(f"<p class='muted'>Caveat: {_e(c)}</p>")
432
+
433
+ # label evidence
434
+ ev = [v for v in (d.get("label_evidence") or {}).values() if v.get("known")]
435
+ if ev:
436
+ P.append("<h2>Where each label comes from</h2><table><tr><th>Address</th><th>Label</th>"
437
+ "<th>Source</th><th>Kind</th></tr>")
438
+ for v in ev:
439
+ P.append(f"<tr><td>{_link(chains.explorer_url(v['address'], chain), v['address'])}</td>"
440
+ f"<td>{_e(v['name'])}</td><td>{_e(v['source']) or '<i>none recorded</i>'}</td>"
441
+ f"<td>{_e(v['source_kind']) or '—'}</td></tr>")
442
+ P.append("</table>")
443
+
444
+ # summary + counterparties
445
+ P.append("<h2>Subject address</h2><table>")
446
+ for k, v in [("Balance", f"{s['balance_native']} {s['native_symbol']}"),
447
+ ("Transfers analysed", s["transfers_analysed"]), ("First seen", s["first_seen"]),
448
+ ("Last seen", s["last_seen"]), ("Label", s["label"] or "—")]:
449
+ P.append(f"<tr><th>{_e(k)}</th><td>{_e(v)}</td></tr>")
450
+ P.append("</table>")
451
+ if d["top_counterparties"]:
452
+ P.append(f"<h2>Largest counterparties</h2><table><tr><th>Address</th><th>Label</th>"
453
+ f"<th>In ({_e(sym)})</th><th>Out ({_e(sym)})</th><th>Txs</th></tr>")
454
+ for c in d["top_counterparties"]:
455
+ P.append(f"<tr><td>{_link(chains.explorer_url(c['address'], chain), c['address'])}</td>"
456
+ f"<td>{_e(c['label']) or '—'}</td><td class='num'>{_e(_amount(c['in']))}</td>"
457
+ f"<td class='num'>{_e(_amount(c['out']))}</td><td class='num'>{c['txs']}</td></tr>")
458
+ P.append("</table>")
459
+
460
+ P.append(f"<h2>Method and limits</h2><p>{_e(_METHOD)}</p>")
461
+ if d.get("errors"):
462
+ 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>")
465
+ # JSON inside <script type=application/json> is data, never executed. Every "<" is
466
+ # written as < (still valid JSON), so a crafted token or label name can neither
467
+ # close the element nor push the parser into its "<!--<script" escaping states.
468
+ P.append("<script type='application/json' id='case-data'>" + raw_json.replace("<", "\\u003c")
469
+ + "</script></body></html>")
470
+ return "".join(P)
@@ -132,7 +132,9 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
132
132
  " AND (contract IS NULL OR contract='')"
133
133
  + (" AND symbol=?" if native_symbol else "") + " ORDER BY ts DESC")
134
134
  args = (chain, address, address) + ((native_symbol,) if native_symbol else ())
135
- rows = c.execute(q, args).fetchall()
135
+ # Stores written before 0.5.0 hold Tron Approval events read as transfers of
136
+ # ~1e59 tokens (an unlimited allowance). No real transfer comes near 1e30.
137
+ rows = [r for r in c.execute(q, args).fetchall() if (r[2] or 0) < 1e30]
136
138
  finally:
137
139
  c.close()
138
140
  out = []
@@ -447,13 +447,13 @@ async function inspect(addr){
447
447
  <b>KYC identification point</b>.</div>`,'alert');
448
448
  }
449
449
  // Tether/Circle can freeze USDT/USDC in place — the one thing that stops them
450
- const fzs=(fz.freeze||[]).filter(e=>e.error||e.frozen||e.balance>0);
450
+ const fzs=(fz.freeze||[]).filter(e=>e.error||e.frozen||e.actionable);
451
451
  if(fzs.length&&!fz.exchange){
452
452
  const amt=v=>Number(v).toLocaleString(undefined,{maximumFractionDigits:2});
453
- const open=fzs.filter(e=>!e.frozen&&e.balance>0);
453
+ const open=fzs.filter(e=>!e.frozen&&e.actionable);
454
454
  h+=card('Stablecoin freeze',
455
455
  fzs.map(e=>kv(esc(e.token), e.error ? '<span class="muted">could not be read</span>'
456
- : e.frozen ? `<b style="color:var(--green)">${amt(e.frozen_amount)} frozen by ${esc(e.issuer)}</b>`
456
+ : e.frozen ? `<b style="color:var(--green)">${e.frozen_amount>=1 ? amt(e.frozen_amount)+' frozen' : 'address blacklisted'} by ${esc(e.issuer)}</b>`
457
457
  : `<b style="color:#ff6b6b">${amt(e.balance)} not frozen</b>`)).join('')+
458
458
  (open.length?`<div class="vfy-note">${esc(open[0].how)}
459
459
  <a href="${safeUrl(open[0].url)}" target="_blank" rel="noopener">${esc(open[0].issuer)} policy ↗</a></div>`:''),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -225,7 +225,7 @@ crypttrace crosschain 0xADDRESS --window 48
225
225
  crypttrace watch add 0xADDRESS --note "my stolen ETH"
226
226
  crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
227
227
 
228
- # Full investigation report to disk (Markdown + JSON)
228
+ # Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
229
229
  crypttrace report 0xADDRESS --depth 3
230
230
 
231
231
  # Refresh label lists (OFAC sanctions, …)
@@ -460,10 +460,24 @@ are ignored, and every address is checked before it is stored.
460
460
 
461
461
  ### Reports
462
462
 
463
- `report` runs the full analysis and writes to `~/crypttrace-reports/` (override
464
- with `--out`): a readable Markdown report (assessment, summary, key findings,
465
- counterparties, the full fund-flow trace, methodology note) plus a `.json` with
466
- the raw structured data.
463
+ `report` runs the full analysis and writes a **case file** to
464
+ `~/crypttrace-reports/` (override with `--out`); `investigate` saves the same
465
+ file with the victim's next steps in it. The case file is one HTML page, made
466
+ for whoever receives it — an exchange's compliance team, the police, a lawyer:
467
+
468
+ - the conclusion in plain words, and what to do now: stablecoins still at the
469
+ address and who can freeze them, the exchanges the money reached, address
470
+ poisoning;
471
+ - the fund-flow graph, each address linking to a block explorer;
472
+ - every transfer along the trace with its transaction hashes — the first thing
473
+ an exchange asks for;
474
+ - where each label comes from, how the tool checked its own arithmetic, and
475
+ what the method cannot tell you.
476
+
477
+ It has no scripts and loads nothing from the internet, so it opens in any
478
+ browser or mail client, prints (or saves as PDF) cleanly, and opening it tells
479
+ no one. The raw data is embedded for analysts and written next to it as JSON,
480
+ with its SHA-256 printed on the page; a Markdown version is written too.
467
481
 
468
482
  ### Self-verification
469
483
 
@@ -561,9 +575,6 @@ cases/ # worked investigations with their data
561
575
 
562
576
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
563
577
  and refreshing these lists as the exchanges republish them
564
- - `report --html`: one self-contained file with the interactive graph, to
565
- send to an exchange or attach to a police report
566
- - `report --pdf` for exchange and law-enforcement filings
567
578
  - Internal transactions (completes `funder` and contract-mediated transfers)
568
579
  - Per-mint filtering for Solana SPL tokens
569
580
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
@@ -1,175 +0,0 @@
1
- """Generate a full investigation report (Markdown + JSON) for an address.
2
-
3
- Runs the same analysis as `profile` + `trace`, then writes a self-contained
4
- report to a folder the user can easily find (default: ~/crypttrace-reports).
5
- """
6
- import json
7
- from datetime import datetime, timezone
8
- from pathlib import Path
9
-
10
- from rich.console import Console
11
-
12
- from crypttrace import __version__, config, prices
13
- from crypttrace.fetchers import etherscan
14
- from crypttrace.labels import labels
15
- from crypttrace import trace as trace_mod
16
-
17
-
18
- def _now() -> str:
19
- return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
20
-
21
-
22
- def _ts(unix: str) -> str:
23
- try:
24
- return datetime.fromtimestamp(int(unix), tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
25
- except (ValueError, TypeError):
26
- return "?"
27
-
28
-
29
- def _counterparties(address: str, txs: list, top: int = 15):
30
- me = address.lower()
31
- agg = {}
32
- for tx in txs:
33
- frm, to = tx.get("from", "").lower(), tx.get("to", "").lower()
34
- val = int(tx.get("value", 0)) / config.WEI
35
- other = to if frm == me else frm
36
- if not other:
37
- continue
38
- rec = agg.setdefault(other, [0.0, 0.0, 0])
39
- if frm == me:
40
- rec[1] += val
41
- else:
42
- rec[0] += val
43
- rec[2] += 1
44
- rows = sorted(agg.items(), key=lambda kv: kv[1][0] + kv[1][1], reverse=True)[:top]
45
- return [
46
- {"address": a, "in": round(vin, 6), "out": round(vout, 6), "txs": cnt,
47
- "label": labels.label_of(a), "type": labels.type_of(a)}
48
- for a, (vin, vout, cnt) in rows
49
- ]
50
-
51
-
52
- def _tree_text(tree) -> str:
53
- """Render the rich trace tree to plain text (ANSI stripped)."""
54
- con = Console(record=True, width=100, file=None)
55
- with con.capture() as cap:
56
- con.print(tree)
57
- return cap.get()
58
-
59
-
60
- def _headline(findings: list) -> str:
61
- types = {f["type"] for f in findings}
62
- if "sanctioned" in types:
63
- return ("Funds from this address reach a **sanctioned / known-criminal wallet** — "
64
- "escalate to law enforcement.")
65
- if "mixer" in types:
66
- return ("Funds from this address flow into a **mixer** (privacy pool), where on-chain "
67
- "tracing terminates. Recovery from here requires timing/amount heuristics or "
68
- "off-chain data.")
69
- if "exchange" in types:
70
- return ("Funds from this address reach a **centralised exchange** — a KYC handoff point. "
71
- "Identifying the owner requires a legal request to that exchange.")
72
- return ("No labelled entities were reached within the traced depth. Increase --depth or "
73
- "extend the label database, then re-run.")
74
-
75
-
76
- def generate(address: str, chain: str, depth: int, branching: int,
77
- out_dir: Path, asset=None) -> Path:
78
- balance = etherscan.get_balance(address, chain)
79
- txs = etherscan.get_txs(address, chain, limit=1000)
80
- tree, findings = trace_mod.build(address, chain, depth, branching, asset)
81
-
82
- # de-duplicate findings by address, keep highest value_reached
83
- uniq = {}
84
- for f in findings:
85
- cur = uniq.get(f["address"])
86
- if cur is None or f["value_reached"] > cur["value_reached"]:
87
- uniq[f["address"]] = f
88
- findings = sorted(uniq.values(), key=lambda f: f["risk"], reverse=True)
89
-
90
- counterparties = _counterparties(address, txs)
91
- hit = labels.lookup(address)
92
-
93
- data = {
94
- "tool": f"crypttrace v{__version__}",
95
- "generated": _now(),
96
- "subject": address,
97
- "chain": chain,
98
- "trace_depth": depth,
99
- "trace_branching": branching,
100
- "summary": {
101
- "balance_native": round(balance, 6),
102
- "txs_analysed": len(txs),
103
- "first_seen": _ts(txs[-1]["timeStamp"]) if txs else None,
104
- "last_seen": _ts(txs[0]["timeStamp"]) if txs else None,
105
- "label": hit["name"] if hit else None,
106
- "type": labels.type_of(address),
107
- "risk_score": labels.risk_score(address),
108
- },
109
- "traced_asset": asset["symbol"] if asset else "ETH (native)",
110
- "key_findings": findings,
111
- "top_counterparties": counterparties,
112
- }
113
-
114
- out_dir.mkdir(parents=True, exist_ok=True)
115
- stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
116
- base = f"{chain}_{address[:10]}_{stamp}"
117
- json_path = out_dir / f"{base}.json"
118
- md_path = out_dir / f"{base}.md"
119
-
120
- json_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
121
- md_path.write_text(_render_md(data, _tree_text(tree), json_path.name), encoding="utf-8")
122
- return md_path
123
-
124
-
125
- def _render_md(d: dict, tree_text: str, json_name: str) -> str:
126
- s = d["summary"]
127
- L = []
128
- L.append(f"# crypttrace investigation report\n")
129
- L.append(f"**Generated:** {d['generated']} • **Tool:** {d['tool']} \n")
130
- L.append(f"**Subject:** `{d['subject']}` • **Chain:** {d['chain']} • "
131
- f"**Traced asset:** {d.get('traced_asset', 'ETH (native)')}\n")
132
-
133
- L.append(f"\n## Assessment\n")
134
- L.append(_headline(d["key_findings"]) + "\n")
135
-
136
- L.append(f"\n## Summary\n")
137
- L.append(f"| Field | Value |\n|---|---|\n")
138
- L.append(f"| Balance | {s['balance_native']} (native) |\n")
139
- L.append(f"| Transactions analysed | {s['txs_analysed']} |\n")
140
- L.append(f"| First seen | {s['first_seen']} |\n")
141
- L.append(f"| Last seen | {s['last_seen']} |\n")
142
- L.append(f"| Label | {s['label'] or '—'} |\n")
143
- L.append(f"| Risk score | {s['risk_score']}/100 |\n")
144
-
145
- L.append(f"\n## Key findings\n")
146
- if d["key_findings"]:
147
- L.append("Labelled entities reached while tracing funds outward from the subject:\n\n")
148
- L.append("| Entity | Type | Risk | Value reached | ≈ USD |\n|---|---|---|---|---|\n")
149
- for f in d["key_findings"]:
150
- sym = f.get("symbol", "")
151
- usd = prices.fmt_usd(f.get("usd_reached"))
152
- L.append(f"| {f['label']} | {f['type']} | {f['risk']}/100 | "
153
- f"{f['value_reached']} {sym} | {usd} |\n")
154
- else:
155
- L.append("_None within the traced depth._\n")
156
-
157
- L.append(f"\n## Top counterparties\n")
158
- L.append("| Address | Label | In | Out | Txs |\n|---|---|---|---|---|\n")
159
- for c in d["top_counterparties"]:
160
- L.append(f"| `{c['address']}` | {c['label'] or '—'} | {c['in']} | {c['out']} | {c['txs']} |\n")
161
-
162
- L.append(f"\n## Fund-flow trace (depth {d['trace_depth']})\n")
163
- L.append("```\n" + tree_text.rstrip() + "\n```\n")
164
-
165
- L.append(f"\n## Methodology & limitations\n")
166
- L.append(
167
- "Data is sourced from the public blockchain via Etherscan. Tracing follows the largest "
168
- "outgoing transfers from each address and stops at identifiable entities (exchanges, "
169
- "mixers, sanctioned wallets). Note: the blockchain is pseudonymous — reaching an address "
170
- "does not identify its owner. Mixers sever the on-chain trail; exchanges require a legal "
171
- "request to attribute ownership. This report is an investigative aid, not proof of "
172
- "wrongdoing.\n")
173
-
174
- L.append(f"\n---\n_Raw structured data: `{json_name}` (same folder)._\n")
175
- return "".join(L)
File without changes
File without changes
File without changes