crypttrace 0.6.0__tar.gz → 0.8.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.8.0}/PKG-INFO +25 -10
  2. {crypttrace-0.6.0 → crypttrace-0.8.0}/README.md +24 -9
  3. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/assess.py +1 -1
  5. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/chains.py +12 -0
  6. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/cli.py +12 -9
  7. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/freeze.py +13 -2
  8. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/investigate.py +9 -7
  9. crypttrace-0.8.0/src/crypttrace/report.py +482 -0
  10. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/store.py +3 -1
  11. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/web/index.html +70 -4
  12. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/webapp.py +35 -1
  13. {crypttrace-0.6.0 → crypttrace-0.8.0/src/crypttrace.egg-info}/PKG-INFO +25 -10
  14. crypttrace-0.6.0/src/crypttrace/report.py +0 -175
  15. {crypttrace-0.6.0 → crypttrace-0.8.0}/LICENSE +0 -0
  16. {crypttrace-0.6.0 → crypttrace-0.8.0}/pyproject.toml +0 -0
  17. {crypttrace-0.6.0 → crypttrace-0.8.0}/setup.cfg +0 -0
  18. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/addresses.py +0 -0
  19. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/analysis.py +0 -0
  20. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/assets.py +0 -0
  21. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/bridges.py +0 -0
  22. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/config.py +0 -0
  23. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/fetchers/__init__.py +0 -0
  24. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  25. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  26. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/fetchers/http.py +0 -0
  27. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/fetchers/solana.py +0 -0
  28. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/fetchers/tron.py +0 -0
  29. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/funder.py +0 -0
  30. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/labels/__init__.py +0 -0
  31. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/labels/audit.py +0 -0
  32. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/labels/bulk.py +0 -0
  33. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/labels/known.json +0 -0
  34. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/labels/labels.py +0 -0
  35. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/labels/partial.json +0 -0
  36. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/offramp.py +0 -0
  37. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/poisoning.py +0 -0
  38. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/prices.py +0 -0
  39. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/render.py +0 -0
  40. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/trace.py +0 -0
  41. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/verify.py +0 -0
  42. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace/watch.py +0 -0
  43. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
  44. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  45. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  46. {crypttrace-0.6.0 → crypttrace-0.8.0}/src/crypttrace.egg-info/requires.txt +0 -0
  47. {crypttrace-0.6.0 → crypttrace-0.8.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.8.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
@@ -225,7 +229,7 @@ crypttrace crosschain 0xADDRESS --window 48
225
229
  crypttrace watch add 0xADDRESS --note "my stolen ETH"
226
230
  crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
227
231
 
228
- # Full investigation report to disk (Markdown + JSON)
232
+ # Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
229
233
  crypttrace report 0xADDRESS --depth 3
230
234
 
231
235
  # Refresh label lists (OFAC sanctions, …)
@@ -460,10 +464,24 @@ are ignored, and every address is checked before it is stored.
460
464
 
461
465
  ### Reports
462
466
 
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.
467
+ `report` runs the full analysis and writes a **case file** to
468
+ `~/crypttrace-reports/` (override with `--out`); `investigate` saves the same
469
+ file with the victim's next steps in it. The case file is one HTML page, made
470
+ for whoever receives it — an exchange's compliance team, the police, a lawyer:
471
+
472
+ - the conclusion in plain words, and what to do now: stablecoins still at the
473
+ address and who can freeze them, the exchanges the money reached, address
474
+ poisoning;
475
+ - the fund-flow graph, each address linking to a block explorer;
476
+ - every transfer along the trace with its transaction hashes — the first thing
477
+ an exchange asks for;
478
+ - where each label comes from, how the tool checked its own arithmetic, and
479
+ what the method cannot tell you.
480
+
481
+ It has no scripts and loads nothing from the internet, so it opens in any
482
+ browser or mail client, prints (or saves as PDF) cleanly, and opening it tells
483
+ no one. The raw data is embedded for analysts and written next to it as JSON,
484
+ with its SHA-256 printed on the page; a Markdown version is written too.
467
485
 
468
486
  ### Self-verification
469
487
 
@@ -561,9 +579,6 @@ cases/ # worked investigations with their data
561
579
 
562
580
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
563
581
  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
582
  - Internal transactions (completes `funder` and contract-mediated transfers)
568
583
  - Per-mint filtering for Solana SPL tokens
569
584
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
@@ -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
@@ -191,7 +195,7 @@ crypttrace crosschain 0xADDRESS --window 48
191
195
  crypttrace watch add 0xADDRESS --note "my stolen ETH"
192
196
  crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
193
197
 
194
- # Full investigation report to disk (Markdown + JSON)
198
+ # Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
195
199
  crypttrace report 0xADDRESS --depth 3
196
200
 
197
201
  # Refresh label lists (OFAC sanctions, …)
@@ -426,10 +430,24 @@ are ignored, and every address is checked before it is stored.
426
430
 
427
431
  ### Reports
428
432
 
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.
433
+ `report` runs the full analysis and writes a **case file** to
434
+ `~/crypttrace-reports/` (override with `--out`); `investigate` saves the same
435
+ file with the victim's next steps in it. The case file is one HTML page, made
436
+ for whoever receives it — an exchange's compliance team, the police, a lawyer:
437
+
438
+ - the conclusion in plain words, and what to do now: stablecoins still at the
439
+ address and who can freeze them, the exchanges the money reached, address
440
+ poisoning;
441
+ - the fund-flow graph, each address linking to a block explorer;
442
+ - every transfer along the trace with its transaction hashes — the first thing
443
+ an exchange asks for;
444
+ - where each label comes from, how the tool checked its own arithmetic, and
445
+ what the method cannot tell you.
446
+
447
+ It has no scripts and loads nothing from the internet, so it opens in any
448
+ browser or mail client, prints (or saves as PDF) cleanly, and opening it tells
449
+ no one. The raw data is embedded for analysts and written next to it as JSON,
450
+ with its SHA-256 printed on the page; a Markdown version is written too.
433
451
 
434
452
  ### Self-verification
435
453
 
@@ -527,9 +545,6 @@ cases/ # worked investigations with their data
527
545
 
528
546
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
529
547
  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
548
  - Internal transactions (completes `funder` and contract-mediated transfers)
534
549
  - Per-mint filtering for Solana SPL tokens
535
550
  - 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.8.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"]