crypttrace 0.2.0__py3-none-any.whl

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.
crypttrace/cli.py ADDED
@@ -0,0 +1,795 @@
1
+ """crypttrace CLI — OSINT crypto investigation from your terminal.
2
+
3
+ Give it a suspicious address; it pulls the public on-chain history, labels
4
+ known entities (exchanges, mixers, sanctioned wallets), and traces where the
5
+ funds went.
6
+ """
7
+ import sys
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import typer
13
+ from rich.console import Console
14
+
15
+ from crypttrace import __version__, config
16
+ from crypttrace.fetchers import etherscan
17
+ from crypttrace.labels import labels
18
+ import time
19
+
20
+ from crypttrace import render, trace as trace_mod, report as report_mod, assets, prices, funder as funder_mod, offramp as offramp_mod, bridges as bridges_mod, watch as watch_mod
21
+
22
+ ASSET_OPT = typer.Option(
23
+ "eth", "--asset", "-a",
24
+ help="Asset to trace: eth (default), a token symbol (usdt, usdc, dai, weth…), or a 0x contract",
25
+ )
26
+
27
+ app = typer.Typer(add_completion=False, help=__doc__)
28
+
29
+ # Output redirected to a file or run from Task Scheduler on Windows gets the ANSI
30
+ # code page (e.g. cp1251), and the first arrow or emoji would crash the command.
31
+ for _stream in (sys.stdout, sys.stderr):
32
+ if _stream and (getattr(_stream, "encoding", "") or "").lower().replace("-", "") != "utf8":
33
+ try:
34
+ _stream.reconfigure(encoding="utf-8", errors="replace")
35
+ except (AttributeError, ValueError):
36
+ pass
37
+
38
+ console = Console()
39
+
40
+
41
+ @app.callback()
42
+ def _main(
43
+ fresh: bool = typer.Option(False, "--fresh",
44
+ help="Ignore stored data and re-fetch from the network"),
45
+ offline: bool = typer.Option(False, "--offline",
46
+ help="Work only from locally stored data, no network"),
47
+ ):
48
+ """Options that apply to every command."""
49
+ # --fresh re-fetches but still stores the result; it doesn't disable the store
50
+ chains_mod.FORCE_FRESH = fresh
51
+ chains_mod.OFFLINE = offline
52
+
53
+ from crypttrace import chains as chains_mod
54
+
55
+ CHAIN_OPT = typer.Option("eth", "--chain", "-c",
56
+ help=f"One of: {chains_mod.ALL_CHAINS}")
57
+
58
+
59
+ @app.command()
60
+ def investigate(
61
+ address: str = typer.Argument(..., help="The address your funds were sent to"),
62
+ chain: str = CHAIN_OPT,
63
+ asset: str = ASSET_OPT,
64
+ depth: int = typer.Option(3, "--depth", "-d", help="How many hops to follow"),
65
+ out: Path = typer.Option(Path.home() / "crypttrace-reports", "--out", "-o",
66
+ help="Where to save the case file"),
67
+ ):
68
+ """Start here. Runs the whole investigation and tells you what to do next."""
69
+ from crypttrace import investigate as inv
70
+ from rich.panel import Panel
71
+
72
+ try:
73
+ asset_desc = assets.resolve_asset(asset, chain)
74
+ except ValueError as e:
75
+ console.print(f"[red]Error:[/red] {e}")
76
+ raise typer.Exit(1)
77
+
78
+ console.print(Panel.fit(
79
+ f"[bold]Investigating[/bold] {address}\n[dim]chain: {chain}[/dim]",
80
+ border_style="cyan"))
81
+
82
+ with console.status("Reading the blockchain and following the money…"):
83
+ r = inv.analyse(address, chain, asset_desc, depth, 3)
84
+
85
+ if r["errors"]:
86
+ for e in r["errors"]:
87
+ console.print(f"[yellow]![/yellow] {e}")
88
+
89
+ # --- what we found ---
90
+ console.print("\n[bold]What we found[/bold]")
91
+ bal = f"{r['balance']:.6f} {chains_mod.symbol(chain)}"
92
+ usd = prices.usd(r["balance"], prices.native_price(chain))
93
+ if usd is not None:
94
+ bal += f" ≈ {prices.fmt_usd(usd)}"
95
+ console.print(f" Balance still on this address: {bal}")
96
+ console.print(f" Transfers analysed: {r['transfers']}")
97
+ if r["label"]:
98
+ console.print(f" This address is known: [bold red]{r['label']}[/bold red]")
99
+
100
+ if r["tree"] is not None and r["findings"]:
101
+ console.print("\n[bold]Where the money went[/bold]")
102
+ console.print(r["tree"])
103
+ elif r["tree"] is not None:
104
+ console.print("\n[dim]No onward movement to known services found at this depth.[/dim]")
105
+
106
+ if r["findings"]:
107
+ console.print("\n[bold]Key destinations[/bold]")
108
+ for f in r["findings"]:
109
+ colour = {"exchange": "green", "offramp": "green", "mixer": "magenta",
110
+ "sanctioned": "red", "bridge": "cyan"}.get(f["type"], "white")
111
+ amount = f"{f['value_reached']} {f.get('symbol','')}"
112
+ if f.get("usd_reached") is not None:
113
+ amount += f" ≈ {prices.fmt_usd(f['usd_reached'])}"
114
+ console.print(f" [{colour}]{f['label']}[/{colour}] — {amount}")
115
+
116
+ # --- guidance ---
117
+ g = r["guidance"]
118
+ console.print(Panel(g["headline"], title="[bold]In plain terms[/bold]",
119
+ border_style="cyan", padding=(1, 2)))
120
+
121
+ console.print("\n[bold]What to do next[/bold]\n")
122
+ for i, s in enumerate(g["steps"], 1):
123
+ tag = " [bold red](do this first)[/bold red]" if s.get("urgent") else ""
124
+ console.print(f"[bold]{i}. {s['title']}[/bold]{tag}")
125
+ for line in s["body"].split("\n"):
126
+ console.print(f" {line}")
127
+ console.print()
128
+
129
+ console.print(Panel(g["warning"], title="[bold red]Beware of recovery scams[/bold red]",
130
+ border_style="red", padding=(1, 2)))
131
+ console.print(Panel(g["expectation"], title="[bold]Realistic expectations[/bold]",
132
+ border_style="yellow", padding=(1, 2)))
133
+
134
+ try:
135
+ path = inv.save_case(r, out, asset_desc)
136
+ console.print(f"\n[green]✓ Case file saved:[/green] {path}")
137
+ console.print("[dim] Send this file to the exchange and attach it to your police report.[/dim]")
138
+ except Exception as e:
139
+ console.print(f"[yellow]Could not save the case file:[/yellow] {e}")
140
+
141
+
142
+ @app.command()
143
+ def profile(
144
+ address: str = typer.Argument(..., help="Address to investigate (0x…)"),
145
+ chain: str = CHAIN_OPT,
146
+ ):
147
+ """Summary of an address: balance, activity window, label, top counterparties."""
148
+ try:
149
+ bal = chains_mod.balance(address, chain)
150
+ rows = chains_mod.transfers(address, chain, limit=1000)
151
+ except (chains_mod.ChainError, etherscan.EtherscanError) as e:
152
+ console.print(f"[red]Error:[/red] {e}")
153
+ raise typer.Exit(1)
154
+
155
+ console.print(render.profile_rows_table(address, chain, bal, rows,
156
+ prices.native_price(chain),
157
+ chains_mod.symbol(chain)))
158
+ if rows:
159
+ console.print(render.counterparties_rows_table(address, chain, rows))
160
+ else:
161
+ console.print("[dim]No transactions found for this address on this chain.[/dim]")
162
+
163
+
164
+ @app.command()
165
+ def trace(
166
+ address: str = typer.Argument(..., help="Starting address (0x…)"),
167
+ chain: str = CHAIN_OPT,
168
+ asset: str = ASSET_OPT,
169
+ depth: int = typer.Option(3, "--depth", "-d", help="How many hops to follow"),
170
+ branching: int = typer.Option(3, "--branching", "-b",
171
+ help="Top-N outflows to follow per address"),
172
+ direction: str = typer.Option("out", "--direction", "-D",
173
+ help="'out' = where funds went, 'in' = where they came from"),
174
+ ):
175
+ """Trace where funds moved, hop by hop, as a coloured tree (ETH or a token)."""
176
+ try:
177
+ asset_desc = assets.resolve_asset(asset, chain)
178
+ except ValueError as e:
179
+ console.print(f"[red]Error:[/red] {e}")
180
+ raise typer.Exit(1)
181
+ if direction not in ("out", "in"):
182
+ console.print("[red]Error:[/red] --direction must be 'out' or 'in'")
183
+ raise typer.Exit(1)
184
+ try:
185
+ tree = trace_mod.build_tree(address, chain, depth, branching, asset_desc, direction)
186
+ except (chains_mod.ChainError, etherscan.EtherscanError) as e:
187
+ console.print(f"[red]Error:[/red] {e}")
188
+ raise typer.Exit(1)
189
+ console.print(tree)
190
+ console.print(
191
+ "\n[dim]Legend: \U0001F7E2 exchange \U0001F7E3 mixer \U0001F534 sanctioned/scam"
192
+ " \U0001F309 bridge ⚪ unknown[/dim]"
193
+ )
194
+
195
+
196
+ @app.command()
197
+ def tokens(
198
+ address: str = typer.Argument(..., help="Address to inspect (0x…)"),
199
+ chain: str = CHAIN_OPT,
200
+ ):
201
+ """Show an address's token holdings (approx from transfer history) with USD."""
202
+ try:
203
+ holdings = chains_mod.token_holdings(address, chain)
204
+ except (chains_mod.ChainError, etherscan.EtherscanError) as e:
205
+ console.print(f"[red]Error:[/red] {e}")
206
+ raise typer.Exit(1)
207
+ if not holdings:
208
+ console.print("[dim]No token transfers found for this address on this chain.[/dim]")
209
+ return
210
+ console.print(render.holdings_table(address, chain, holdings))
211
+
212
+
213
+ @app.command()
214
+ def report(
215
+ address: str = typer.Argument(..., help="Address to investigate (0x…)"),
216
+ chain: str = CHAIN_OPT,
217
+ asset: str = ASSET_OPT,
218
+ depth: int = typer.Option(3, "--depth", "-d", help="How many hops to trace"),
219
+ branching: int = typer.Option(3, "--branching", "-b", help="Top-N outflows per address"),
220
+ out: Path = typer.Option(
221
+ Path.home() / "crypttrace-reports", "--out", "-o",
222
+ help="Folder to save the report in",
223
+ ),
224
+ ):
225
+ """Run a full investigation and save a Markdown + JSON report to disk."""
226
+ try:
227
+ asset_desc = assets.resolve_asset(asset, chain)
228
+ except ValueError as e:
229
+ console.print(f"[red]Error:[/red] {e}")
230
+ raise typer.Exit(1)
231
+ try:
232
+ with console.status("Gathering on-chain data and tracing funds…"):
233
+ md_path = report_mod.generate(address, chain, depth, branching, out, asset_desc)
234
+ except etherscan.EtherscanError as e:
235
+ console.print(f"[red]Error:[/red] {e}")
236
+ raise typer.Exit(1)
237
+ console.print(f"[green]✓ Report saved:[/green] {md_path}")
238
+ console.print(f"[dim] Raw data (JSON) saved alongside it in the same folder.[/dim]")
239
+
240
+
241
+ @app.command()
242
+ def crosschain(
243
+ address: str = typer.Argument(..., help="Address that may have bridged funds (0x…)"),
244
+ chain: str = CHAIN_OPT,
245
+ window: int = typer.Option(48, "--window", "-w", help="Hours after a bridge-out to search"),
246
+ tol: float = typer.Option(0.05, "--tol", help="Amount tolerance (0.05 = 5%, for bridge fees)"),
247
+ ):
248
+ """Follow funds across bridges: find likely arrivals of the same address on other chains."""
249
+ try:
250
+ results = bridges_mod.trace_cross(address, chain, tol=tol, window_h=window)
251
+ except etherscan.EtherscanError as e:
252
+ console.print(f"[red]Error:[/red] {e}")
253
+ raise typer.Exit(1)
254
+ if not results:
255
+ console.print("[dim]No transfers into known bridge contracts found for this "
256
+ "address on this chain.[/dim]")
257
+ return
258
+ console.print(render.crosschain_tree(address, chain, results))
259
+ console.print("\n[dim]Cross-chain links are heuristic (same-address arrival by amount+time), "
260
+ "not proof. Verify each candidate before relying on it.[/dim]")
261
+
262
+
263
+ @app.command()
264
+ def offramp(
265
+ address: str = typer.Argument(..., help="Address to check (0x…)"),
266
+ chain: str = CHAIN_OPT,
267
+ ):
268
+ """Check whether an address is an exchange deposit address (cash-out / off-ramp)."""
269
+ try:
270
+ hit = offramp_mod.detect(address, chain)
271
+ except etherscan.EtherscanError as e:
272
+ console.print(f"[red]Error:[/red] {e}")
273
+ raise typer.Exit(1)
274
+ if hit:
275
+ pct = int(hit["fraction"] * 100)
276
+ console.print(
277
+ f"[green]➜ Likely off-ramp:[/green] this address forwarded ~{pct}% of outgoing "
278
+ f"funds ({hit['forwarded']:.4f}) to [bold]{hit['exchange']}[/bold].\n"
279
+ f" It is probably a {hit['exchange']} deposit address — a KYC identification point."
280
+ )
281
+ else:
282
+ console.print("[dim]No exchange-forwarding pattern detected. "
283
+ "Not an obvious off-ramp (or funds moved as tokens).[/dim]")
284
+
285
+
286
+ @app.command()
287
+ def funder(
288
+ address: str = typer.Argument(..., help="Address to trace funding for (0x…)"),
289
+ chain: str = CHAIN_OPT,
290
+ hops: int = typer.Option(6, "--hops", "-H", help="How far back to follow the funding chain"),
291
+ ):
292
+ """Follow who funded a wallet's first gas, backward, toward a KYC/exchange point."""
293
+ try:
294
+ chain_hops = funder_mod.funding_chain(address, chain, hops)
295
+ except etherscan.EtherscanError as e:
296
+ console.print(f"[red]Error:[/red] {e}")
297
+ raise typer.Exit(1)
298
+ console.print(render.funding_tree(address, chain_hops))
299
+ if chain_hops and chain_hops[-1]["terminal"] and chain_hops[-1]["funder_type"] == "exchange":
300
+ console.print("\n[green]➜ Funding chain reaches an exchange — a KYC identification "
301
+ "point. A legal request to that exchange can reveal the owner.[/green]")
302
+
303
+
304
+ @app.command()
305
+ def label(
306
+ address: str = typer.Argument(..., help="Address to look up"),
307
+ ):
308
+ """Look up what an address is (from the local label DB) and its risk score."""
309
+ hit = labels.lookup(address)
310
+ if hit:
311
+ console.print(f"{labels.icon(address)} [bold]{hit['name']}[/bold] "
312
+ f"— type: {hit['type']}, risk: {labels.risk_score(address)}/100")
313
+ else:
314
+ console.print(f"⚪ [dim]Unknown address[/dim] — no label, risk 0/100")
315
+
316
+
317
+ labels_app = typer.Typer(help="Inspect the label database and the evidence behind it.")
318
+ app.add_typer(labels_app, name="labels")
319
+
320
+
321
+ @labels_app.command("audit")
322
+ def labels_audit():
323
+ """Check every label: address validity, checksum, and whether a source is recorded."""
324
+ from crypttrace.labels import audit as audit_mod
325
+ a = audit_mod.audit()
326
+
327
+ console.print(f" entries : [bold]{a['total']}[/bold]")
328
+ console.print(f" well-formed : {a['valid']}")
329
+ console.print(f" with a source : {a['total']-len(a['unsourced'])} "
330
+ f"({a['sourced_share']*100:.0f}%)")
331
+
332
+ if a["by_source"]:
333
+ console.print("\n [bold]evidence behind the claims[/bold]")
334
+ titles = {"self-published": "published by the service itself",
335
+ "official-list": "official/regulator list",
336
+ "explorer-tag": "block-explorer tag",
337
+ "research": "named research or own tracing",
338
+ "community": "crowd-sourced", "none": "no source recorded"}
339
+ for kind, n in sorted(a["by_source"].items(), key=lambda kv: -kv[1]):
340
+ console.print(f" {titles.get(kind, kind):<34} {n}")
341
+
342
+ if a["problems"]:
343
+ console.print(f"\n [bold red]invalid addresses: {len(a['problems'])}[/bold red]")
344
+ for p in a["problems"][:10]:
345
+ console.print(f" {p['address'][:24]}… — {p['issue']}")
346
+ else:
347
+ console.print("\n [green]every address passes its checksum[/green]")
348
+
349
+ if a["unsourced"]:
350
+ console.print(f"\n [yellow]{len(a['unsourced'])} labels carry no source[/yellow] "
351
+ "— they are inherited, not evidenced. Treat them as weaker.")
352
+ for u in a["unsourced"][:8]:
353
+ console.print(f" [dim]{u['name']} ({u['type']})[/dim]")
354
+
355
+
356
+ @labels_app.command("why")
357
+ def labels_why(
358
+ address: str = typer.Argument(..., help="Address to explain"),
359
+ ):
360
+ """Why does the tool claim this address is what it says? Shows the evidence."""
361
+ from crypttrace.labels import audit as audit_mod
362
+ e = audit_mod.evidence(address)
363
+ if not e["known"]:
364
+ console.print("[dim]No label for this address — the tool makes no claim about it.[/dim]")
365
+ console.print("[dim]Unlabelled does not mean innocent; it means unknown.[/dim]")
366
+ raise typer.Exit()
367
+ console.print(f" claim : [bold]{e['name']}[/bold] ({e['type']})")
368
+ console.print(f" source : {e['source'] or '[yellow]none recorded[/yellow]'}")
369
+ console.print(f" kind : {e['source_kind'] or '—'} (strength {e['strength']}/3)")
370
+ if e.get("added"):
371
+ console.print(f" added : {e['added']}")
372
+ if not e["source"]:
373
+ console.print("\n [yellow]This claim is not evidenced in the database.[/yellow] "
374
+ "Verify it independently before acting on it.")
375
+
376
+
377
+ @labels_app.command("check")
378
+ def labels_check(
379
+ address: str = typer.Argument(..., help="Address to validate"),
380
+ chain: str = CHAIN_OPT,
381
+ ):
382
+ """Validate an address and its checksum, without touching the network."""
383
+ from crypttrace import addresses as addr_mod
384
+ if addr_mod.looks_like_txid(address):
385
+ console.print("[yellow]That is a 64-character hex string — a transaction id, "
386
+ "not an address.[/yellow]")
387
+ raise typer.Exit(1)
388
+ ok, why = addr_mod.validate(address, chain)
389
+ style = "green" if ok else "red"
390
+ console.print(f"[{style}]{'valid' if ok else 'invalid'}[/{style}] — {why}")
391
+ if not ok:
392
+ raise typer.Exit(1)
393
+
394
+
395
+ @app.command(name="update-labels")
396
+ def update_labels():
397
+ """Download the latest label lists (OFAC sanctions, etc.) into the local DB."""
398
+ console.print("Updating label database…")
399
+ for name, cnt, err in labels.update():
400
+ if err:
401
+ console.print(f" [red]✗[/red] {name}: {err}")
402
+ else:
403
+ console.print(f" [green]✓[/green] {name}: [bold]{cnt}[/bold] addresses")
404
+ console.print(f"[green]Done.[/green] {labels.count()} labelled addresses now loaded.")
405
+ console.print(f"[dim]Cache: {config.DATA_DIR / 'imported_labels.json'}[/dim]")
406
+
407
+
408
+ @app.command()
409
+ def victims(
410
+ address: str = typer.Argument(..., help="The address funds were consolidated into"),
411
+ chain: str = CHAIN_OPT,
412
+ asset: str = ASSET_OPT,
413
+ depth: int = typer.Option(1, "--depth", "-d", help="How many hops back to collect sources"),
414
+ out: Optional[Path] = typer.Option(None, "--out", "-o", help="CSV file to write"),
415
+ top: int = typer.Option(25, "--top", help="How many rows to print"),
416
+ min_value: Optional[float] = typer.Option(
417
+ None, "--min-value", help="Ignore transfers below this amount (default: per-chain dust level)"),
418
+ include_dust: bool = typer.Option(False, "--include-dust", help="Keep dust-sized transfers"),
419
+ ):
420
+ """List every address that fed this wallet — in a mass theft, the victim list."""
421
+ from crypttrace import analysis
422
+ try:
423
+ asset_desc = assets.resolve_asset(asset, chain)
424
+ except ValueError as e:
425
+ console.print(f"[red]Error:[/red] {e}")
426
+ raise typer.Exit(1)
427
+ floor = 0.0 if include_dust else min_value
428
+ try:
429
+ with console.status("Walking the money backwards…"):
430
+ rows = analysis.collect_sources(address, chain, depth, asset_desc,
431
+ min_value=floor)
432
+ except (chains_mod.ChainError, etherscan.EtherscanError) as e:
433
+ console.print(f"[red]Error:[/red] {e}")
434
+ raise typer.Exit(1)
435
+
436
+ if not rows:
437
+ console.print("[dim]No incoming transfers found — nothing fed this address.[/dim]")
438
+ return
439
+
440
+ sym = asset_desc["symbol"] if asset_desc else chains_mod.symbol(chain)
441
+ console.print(render.sources_table(rows, sym, top))
442
+ total = sum(r["value"] for r in rows)
443
+ console.print(f"\n[bold]{len(rows)}[/bold] addresses sent a total of "
444
+ f"[bold]{total:.8f} {sym}[/bold] into this wallet.")
445
+
446
+ # This list is about to become evidence — say whether it reconciles.
447
+ from crypttrace import verify as verify_mod
448
+ v = verify_mod.reconcile(address, chain, asset_desc)
449
+ style = _verdict_style(v["status"])
450
+ console.print(f"[{style}]{v['status'].upper()}[/{style}] — {verify_mod.headline(v)}")
451
+ for n in v["notes"][:2]:
452
+ console.print(f" [dim]• {n}[/dim]")
453
+
454
+ path = out or (Path.home() / "crypttrace-reports" /
455
+ f"sources_{chain}_{address[:12]}_{datetime.now():%Y%m%d_%H%M%S}.csv")
456
+ try:
457
+ analysis.export_csv(rows, path, chain, sym)
458
+ console.print(f"[green]✓ CSV saved:[/green] {path}")
459
+ console.print("[dim] Attach this to an exchange request or police report.[/dim]")
460
+ except OSError as e:
461
+ console.print(f"[yellow]Could not write CSV:[/yellow] {e}")
462
+
463
+
464
+ @app.command()
465
+ def timeline(
466
+ address: str = typer.Argument(..., help="Address to analyse"),
467
+ chain: str = CHAIN_OPT,
468
+ asset: str = ASSET_OPT,
469
+ buckets: int = typer.Option(24, "--buckets", "-b", help="Number of time buckets"),
470
+ min_value: Optional[float] = typer.Option(
471
+ None, "--min-value", help="Ignore transfers below this amount (default: per-chain dust level)"),
472
+ include_dust: bool = typer.Option(False, "--include-dust", help="Keep dust-sized transfers"),
473
+ ):
474
+ """When did the money move? Reveals automated sweeps vs ordinary use."""
475
+ from crypttrace import analysis
476
+ try:
477
+ asset_desc = assets.resolve_asset(asset, chain)
478
+ except ValueError as e:
479
+ console.print(f"[red]Error:[/red] {e}")
480
+ raise typer.Exit(1)
481
+ floor = 0.0 if include_dust else min_value
482
+ try:
483
+ with console.status("Reading transfer history…"):
484
+ tl = analysis.timeline(address, chain, asset_desc, buckets=buckets,
485
+ min_value=floor)
486
+ except (chains_mod.ChainError, etherscan.EtherscanError) as e:
487
+ console.print(f"[red]Error:[/red] {e}")
488
+ raise typer.Exit(1)
489
+
490
+ if not tl["events"]:
491
+ console.print("[dim]No dated transfers found for this address.[/dim]")
492
+ return
493
+
494
+ sym = asset_desc["symbol"] if asset_desc else chains_mod.symbol(chain)
495
+ console.print(render.timeline_chart(tl, sym))
496
+ console.print(f"\n first activity : {render._ts(str(tl['first_ts']))} UTC")
497
+ console.print(f" last activity : {render._ts(str(tl['last_ts']))} UTC")
498
+ console.print(f" transfers : {tl['events']}")
499
+ console.print(f" received / sent: {tl['in_total']:.6f} / {tl['out_total']:.6f} {sym}")
500
+ if tl.get("dust_skipped"):
501
+ console.print(f" [dim]dust ignored : {tl['dust_skipped']} tiny transfers "
502
+ f"(spam sent to well-known addresses; use --include-dust to keep)[/dim]")
503
+
504
+ note = analysis.describe_burst(tl["burst"], tl["events"])
505
+ if note:
506
+ style = "bold yellow" if "automated" in note else "dim"
507
+ console.print(f"\n[{style}]{note}[/{style}]")
508
+
509
+
510
+ @app.command()
511
+ def assess(
512
+ address: str = typer.Argument(..., help="Address to assess"),
513
+ chain: str = CHAIN_OPT,
514
+ asset: str = ASSET_OPT,
515
+ depth: int = typer.Option(2, "--depth", "-d", help="How far to look at onward flows"),
516
+ ):
517
+ """What does the evidence support? A stated conclusion, with its reasoning."""
518
+ from crypttrace import assess as assess_mod
519
+ from rich.panel import Panel
520
+ try:
521
+ asset_desc = assets.resolve_asset(asset, chain)
522
+ except ValueError as e:
523
+ console.print(f"[red]Error:[/red] {e}")
524
+ raise typer.Exit(1)
525
+
526
+ with console.status("Gathering evidence…"):
527
+ a = assess_mod.assess(address, chain, asset_desc, depth)
528
+
529
+ if a.get("error"):
530
+ console.print(f"[red]Error:[/red] {a['error']}")
531
+ raise typer.Exit(1)
532
+
533
+ colour = "red" if a["risk"] >= 60 else "yellow" if a["risk"] >= 25 else "green"
534
+ console.print(Panel(a["assessment"], title="[bold]Assessment[/bold]",
535
+ border_style=colour, padding=(1, 2)))
536
+
537
+ if a["signals"]:
538
+ console.print("\n[bold]What this rests on[/bold]\n")
539
+ for s in a["signals"]:
540
+ c = {"high": "green", "medium": "yellow", "low": "dim"}[s["confidence"]]
541
+ console.print(f" [bold]{s['name']}[/bold] [{c}]{s['confidence']} confidence[/{c}]")
542
+ console.print(f" observed : {s['observed']}")
543
+ console.print(f" means : {s['implication']}")
544
+ console.print()
545
+
546
+ console.print(f" risk : [{colour}]{a['risk']}/100[/{colour}]")
547
+ console.print(f" confidence : {a['confidence']}")
548
+ console.print(f" arithmetic : {a['verification']['status']}")
549
+
550
+ if a["caveats"]:
551
+ console.print("\n[bold yellow]Read this before quoting the above[/bold yellow]")
552
+ for c in a["caveats"]:
553
+ console.print(f" • {c}")
554
+
555
+
556
+ def _verdict_style(status: str) -> str:
557
+ return {"verified": "green", "consistent": "cyan", "partial": "yellow",
558
+ "mismatch": "bold red", "unchecked": "dim"}.get(status, "dim")
559
+
560
+
561
+ @app.command()
562
+ def verify(
563
+ address: str = typer.Argument(..., help="Address to cross-check"),
564
+ chain: str = CHAIN_OPT,
565
+ asset: str = ASSET_OPT,
566
+ limit: int = typer.Option(1000, "--limit", help="How many transfers to read"),
567
+ ):
568
+ """Check the tool's own totals against the chain. Run this before trusting a report."""
569
+ from crypttrace import verify as verify_mod
570
+ try:
571
+ asset_desc = assets.resolve_asset(asset, chain)
572
+ except ValueError as e:
573
+ console.print(f"[red]Error:[/red] {e}")
574
+ raise typer.Exit(1)
575
+
576
+ with console.status("Re-deriving totals and comparing with the chain…"):
577
+ v = verify_mod.reconcile(address, chain, asset_desc, limit)
578
+
579
+ sym = asset_desc["symbol"] if asset_desc else chains_mod.symbol(chain)
580
+ style = _verdict_style(v["status"])
581
+ console.print(f"\n[{style}]{v['status'].upper()}[/{style}] {verify_mod.headline(v)}\n")
582
+
583
+ if v["checks"]:
584
+ t = render.Table(title="Cross-check against the chain", header_style="bold")
585
+ t.add_column("Figure"); t.add_column("Computed", justify="right")
586
+ t.add_column("Chain", justify="right"); t.add_column("", justify="center")
587
+ for c in v["checks"]:
588
+ t.add_row(c["name"], f"{c['computed']:.8f} {sym}",
589
+ f"{c['chain']:.8f} {sym}", "✓" if c["ok"] else "✗")
590
+ console.print(t)
591
+
592
+ console.print(f"\n transfers analysed : {v['analysed']}")
593
+ if v.get("chain_tx_count") is not None:
594
+ console.print(f" transactions on chain: {v['chain_tx_count']}")
595
+ for n in v["notes"]:
596
+ console.print(f" [dim]• {n}[/dim]")
597
+
598
+
599
+ @app.command()
600
+ def cluster(
601
+ address: str = typer.Argument(..., help="Bitcoin address (bc1…/1…/3…)"),
602
+ ):
603
+ """Bitcoin only: find addresses likely owned by the same person (common-input-ownership)."""
604
+ from crypttrace.fetchers import bitcoin
605
+ try:
606
+ peers = bitcoin.cluster(address)
607
+ except bitcoin.BitcoinError as e:
608
+ console.print(f"[red]Error:[/red] {e}")
609
+ raise typer.Exit(1)
610
+ if not peers:
611
+ console.print("[dim]No co-spending found — this address never signed inputs "
612
+ "alongside others (or has too little history).[/dim]")
613
+ return
614
+ console.print(render.cluster_table(address, peers))
615
+ console.print("\n[dim]Heuristic: addresses that co-sign inputs of one transaction are "
616
+ "almost always controlled by the same party. Strong lead, not proof.[/dim]")
617
+
618
+
619
+ store_app = typer.Typer(help="Inspect and manage locally stored chain data.")
620
+ app.add_typer(store_app, name="store")
621
+
622
+
623
+ @store_app.command("info")
624
+ def store_info():
625
+ """What is held locally — re-analysis of this data needs no network."""
626
+ from crypttrace import store as store_mod
627
+ s = store_mod.stats()
628
+ console.print(f" transfers stored : [bold]{s['transfers']}[/bold]")
629
+ console.print(f" addresses fetched: [bold]{s['addresses']}[/bold]")
630
+ for ch, n in sorted(s["by_chain"].items(), key=lambda kv: -kv[1]):
631
+ console.print(f" {ch:<10} {n}")
632
+ console.print(f" file : {s['path']}")
633
+ console.print(f" size : {s['size_bytes']/1024:.0f} KB")
634
+ if s["addresses"]:
635
+ console.print("\n[dim]Add --offline to any command to work from this alone.[/dim]")
636
+
637
+
638
+ @store_app.command("clear")
639
+ def store_clear(
640
+ yes: bool = typer.Option(False, "--yes", "-y", help="Don't ask for confirmation"),
641
+ ):
642
+ """Delete everything stored locally (chain data can always be re-fetched)."""
643
+ from crypttrace import store as store_mod
644
+ if not yes:
645
+ s = store_mod.stats()
646
+ confirm = typer.confirm(f"Delete {s['transfers']} stored transfers?")
647
+ if not confirm:
648
+ console.print("[dim]Kept.[/dim]")
649
+ raise typer.Exit()
650
+ store_mod.clear()
651
+ console.print("[green]✓ Local store cleared.[/green]")
652
+
653
+
654
+ @store_app.command("link")
655
+ def store_link(
656
+ src: str = typer.Argument(..., help="Start address"),
657
+ dst: str = typer.Argument(..., help="Address to look for"),
658
+ chain: str = CHAIN_OPT,
659
+ hops: int = typer.Option(4, "--hops", "-H", help="Maximum hops to search"),
660
+ ):
661
+ """Are two addresses connected in the data already collected?"""
662
+ from crypttrace import store as store_mod
663
+ path = store_mod.path_exists(chain, src, dst, hops)
664
+ if not path:
665
+ console.print("[dim]No path found in stored data. Trace both addresses first, "
666
+ "or raise --hops.[/dim]")
667
+ raise typer.Exit(1)
668
+ console.print(f"[green]Connected in {len(path)-1} hop(s):[/green]\n")
669
+ for i, a in enumerate(path):
670
+ console.print((" " * i) + ("└─▶ " if i else "") + a)
671
+
672
+
673
+ @app.command()
674
+ def chains():
675
+ """List supported chains."""
676
+ console.print("[bold]EVM[/bold] (Etherscan v2, needs ETHERSCAN_API_KEY):")
677
+ for name, cid in config.CHAINS.items():
678
+ console.print(f" {name:<10} chainid {cid}")
679
+ console.print("\n[bold]Non-EVM[/bold] (no API key needed):")
680
+ console.print(" btc Bitcoin — mempool.space (UTXO)")
681
+ console.print(" tron Tron — TronGrid (TRX + USDT-TRC20)")
682
+ console.print(" sol Solana — public JSON-RPC")
683
+
684
+
685
+ @app.command()
686
+ def serve(
687
+ host: str = typer.Option("127.0.0.1", "--host", help="Host to bind"),
688
+ port: int = typer.Option(8000, "--port", "-p", help="Port to serve on"),
689
+ ):
690
+ """Launch the local web UI (interactive graph) in your browser."""
691
+ try:
692
+ from crypttrace import webapp
693
+ except ImportError:
694
+ console.print("[red]Flask is not installed.[/red] Run: pip install 'crypttrace[web]' "
695
+ "(or pip install flask)")
696
+ raise typer.Exit(1)
697
+ info = webapp.where()
698
+ console.print(f"[green]crypttrace web UI[/green] → http://{host}:{port} (Ctrl-C to stop)")
699
+ console.print(f"[dim]serving: {info['index_html']}[/dim]")
700
+ console.print(f"[dim] {info['size']} bytes, modified {info['modified']}[/dim]")
701
+ if not info["editable"]:
702
+ console.print("[yellow]![/yellow] Running from an installed copy, not your source tree — "
703
+ "edits to src/ will NOT appear.\n"
704
+ " Fix with: [bold]pip uninstall -y crypttrace && pip install -e \".[web]\"[/bold]")
705
+ webapp.serve(host=host, port=port)
706
+
707
+
708
+ # ---- watch: monitor addresses and alert on movement / cash-out ----
709
+ watch_app = typer.Typer(help="Monitor addresses and alert when funds move (esp. to an exchange).")
710
+ app.add_typer(watch_app, name="watch")
711
+
712
+
713
+ @watch_app.command("add")
714
+ def watch_add(
715
+ address: str = typer.Argument(..., help="Address to watch (0x…)"),
716
+ chain: str = CHAIN_OPT,
717
+ note: str = typer.Option("", "--note", "-n", help="A label for this case, e.g. 'my stolen ETH'"),
718
+ ):
719
+ """Add an address to the watchlist (alerts only on activity from now on)."""
720
+ watch_mod.add(address, chain, note)
721
+ console.print(f"[green]✓ Watching[/green] {address} ({chain})"
722
+ + (f" — {note}" if note else ""))
723
+
724
+
725
+ @watch_app.command("list")
726
+ def watch_list():
727
+ """Show the watchlist."""
728
+ d = watch_mod.all_watched()
729
+ if not d:
730
+ console.print("[dim]Watchlist is empty. Add one with `crypttrace watch add 0x…`[/dim]")
731
+ return
732
+ for addr, m in d.items():
733
+ console.print(f" {addr} ({m.get('chain','eth')})"
734
+ + (f" — {m['note']}" if m.get("note") else ""))
735
+
736
+
737
+ @watch_app.command("remove")
738
+ def watch_remove(address: str = typer.Argument(..., help="Address to stop watching")):
739
+ """Remove an address from the watchlist."""
740
+ if watch_mod.remove(address):
741
+ console.print(f"[green]✓ Removed[/green] {address}")
742
+ else:
743
+ console.print("[dim]Address was not on the watchlist.[/dim]")
744
+
745
+
746
+ def _render_alert(e: dict) -> None:
747
+ icon = {"high": "🚨", "move": "🔔", "info": "•"}.get(e["sev"], "•")
748
+ style = {"high": "bold red", "move": "yellow", "info": "dim"}.get(e["sev"], "white")
749
+ when = render._ts(str(e["timestamp"]))
750
+ line = (f"{icon} [{style}]{e['sev'].upper()}[/{style}] {e['address'][:12]}…"
751
+ + (f" ({e['note']})" if e.get("note") else "")
752
+ + f" {e['value']:.4f} {e['reason']} [dim]{when}[/dim]")
753
+ if e["sev"] == "high":
754
+ console.bell() # audible bell for cash-out events
755
+ console.print(line)
756
+
757
+
758
+ @watch_app.command("run")
759
+ def watch_run(
760
+ interval: int = typer.Option(300, "--interval", "-i", help="Seconds between checks"),
761
+ once: bool = typer.Option(False, "--once", help="Check a single time and exit (good for cron)"),
762
+ telegram: bool = typer.Option(False, "--telegram", help="Also send alerts to Telegram "
763
+ "(set CRYPTTRACE_TG_TOKEN and CRYPTTRACE_TG_CHAT)"),
764
+ ):
765
+ """Poll the watchlist and alert on new activity. Loud alert on likely cash-out."""
766
+ if not watch_mod.all_watched():
767
+ console.print("[dim]Watchlist is empty. Add one with `crypttrace watch add 0x…`[/dim]")
768
+ raise typer.Exit(1)
769
+
770
+ def _pass():
771
+ alerts = watch_mod.poll_once()
772
+ if not alerts:
773
+ console.print(f"[dim]{render._ts(str(int(time.time())))} — no new activity[/dim]")
774
+ return
775
+ for e in alerts:
776
+ _render_alert(e)
777
+ if telegram and e["sev"] in ("high", "move"):
778
+ msg = f"crypttrace {e['sev'].upper()}: {e['address']} {e['value']:.4f} {e['reason']}"
779
+ watch_mod.telegram_notify(msg)
780
+
781
+ if once:
782
+ _pass()
783
+ return
784
+ console.print(f"[green]Watching {len(watch_mod.all_watched())} address(es)[/green] "
785
+ f"every {interval}s. Ctrl-C to stop.")
786
+ try:
787
+ while True:
788
+ _pass()
789
+ time.sleep(interval)
790
+ except KeyboardInterrupt:
791
+ console.print("\n[dim]Stopped.[/dim]")
792
+
793
+
794
+ if __name__ == "__main__":
795
+ app()