crypttrace 0.32.0__tar.gz → 0.33.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 (56) hide show
  1. {crypttrace-0.32.0/src/crypttrace.egg-info → crypttrace-0.33.0}/PKG-INFO +22 -1
  2. {crypttrace-0.32.0 → crypttrace-0.33.0}/README.md +21 -0
  3. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/__init__.py +1 -1
  4. crypttrace-0.33.0/src/crypttrace/case.py +187 -0
  5. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/cli.py +119 -0
  6. {crypttrace-0.32.0 → crypttrace-0.33.0/src/crypttrace.egg-info}/PKG-INFO +22 -1
  7. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
  8. {crypttrace-0.32.0 → crypttrace-0.33.0}/LICENSE +0 -0
  9. {crypttrace-0.32.0 → crypttrace-0.33.0}/pyproject.toml +0 -0
  10. {crypttrace-0.32.0 → crypttrace-0.33.0}/setup.cfg +0 -0
  11. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/addresses.py +0 -0
  12. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/analysis.py +0 -0
  13. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/assess.py +0 -0
  14. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/assets.py +0 -0
  15. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/bridges.py +0 -0
  16. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/chains.py +0 -0
  17. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/config.py +0 -0
  18. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/drain.py +0 -0
  19. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/evidence.py +0 -0
  20. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/export.py +0 -0
  21. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/__init__.py +0 -0
  22. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  23. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  24. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/http.py +0 -0
  25. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/solana.py +0 -0
  26. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/ton.py +0 -0
  27. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/fetchers/tron.py +0 -0
  28. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/freeze.py +0 -0
  29. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/funder.py +0 -0
  30. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/gascluster.py +0 -0
  31. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/investigate.py +0 -0
  32. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/__init__.py +0 -0
  33. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/audit.py +0 -0
  34. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/bulk.py +0 -0
  35. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/known.json +0 -0
  36. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/labels.py +0 -0
  37. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/names.py +0 -0
  38. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/partial.json +0 -0
  39. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/tonapi.py +0 -0
  40. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/labels/tronscan.py +0 -0
  41. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/offramp.py +0 -0
  42. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/poisoning.py +0 -0
  43. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/prices.py +0 -0
  44. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/render.py +0 -0
  45. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/report.py +0 -0
  46. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/spam.py +0 -0
  47. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/store.py +0 -0
  48. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/trace.py +0 -0
  49. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/verify.py +0 -0
  50. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/watch.py +0 -0
  51. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/web/index.html +0 -0
  52. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace/webapp.py +0 -0
  53. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  54. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  55. {crypttrace-0.32.0 → crypttrace-0.33.0}/src/crypttrace.egg-info/requires.txt +0 -0
  56. {crypttrace-0.32.0 → crypttrace-0.33.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.32.0
3
+ Version: 0.33.0
4
4
  Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron, Solana and TON
5
5
  Author: bobslayerX
6
6
  License-Expression: MIT
@@ -329,6 +329,26 @@ entities — that's the OSINT handoff point. `--direction out` (default) answers
329
329
  money come from", which is how you vet a suspicious address or find a victim's
330
330
  source of funds.
331
331
 
332
+ ### Cases — an investigation kept as a file
333
+
334
+ An investigation rarely fits one address. `case` keeps the addresses (any
335
+ chain, each with a role: victim, suspect, intermediary, exchange, mixer, bridge
336
+ or other), a log of notes, and the last snapshot of each address. `refresh`
337
+ reads them all again and says what changed: a balance that went down, new
338
+ activity since the last look.
339
+
340
+ ```bash
341
+ crypttrace case new ronin --note "Ronin bridge exploit, March 2022"
342
+ crypttrace case add ronin 0x098b…2f96 --chain eth --role suspect --note "exploiter"
343
+ crypttrace case note ronin "asked Binance for KYC on the first-gas withdrawal"
344
+ crypttrace case refresh ronin # what moved since the last refresh
345
+ crypttrace case show ronin # addresses, current labels, last snapshot, the log
346
+ ```
347
+
348
+ A case is one JSON file in `~/.crypttrace/cases/`. Every `case` command also
349
+ takes a path to a `.json` file, so handing a case to a colleague is sending the
350
+ file.
351
+
332
352
  ### Watch & alerts — catching the cash-out
333
353
 
334
354
  The only window to freeze stolen funds is the moment they reach an exchange
@@ -727,6 +747,7 @@ src/crypttrace/
727
747
  offramp.py # exchange-deposit detection
728
748
  bridges.py # cross-chain matching
729
749
  watch.py # watchlist + alerts
750
+ case.py # investigations as files: addresses, roles, notes, snapshots
730
751
  report.py # Markdown + JSON reports
731
752
  export.py # traced graph as GraphML (Gephi) and edge-list CSV (Maltego)
732
753
  prices.py # USD valuation
@@ -295,6 +295,26 @@ entities — that's the OSINT handoff point. `--direction out` (default) answers
295
295
  money come from", which is how you vet a suspicious address or find a victim's
296
296
  source of funds.
297
297
 
298
+ ### Cases — an investigation kept as a file
299
+
300
+ An investigation rarely fits one address. `case` keeps the addresses (any
301
+ chain, each with a role: victim, suspect, intermediary, exchange, mixer, bridge
302
+ or other), a log of notes, and the last snapshot of each address. `refresh`
303
+ reads them all again and says what changed: a balance that went down, new
304
+ activity since the last look.
305
+
306
+ ```bash
307
+ crypttrace case new ronin --note "Ronin bridge exploit, March 2022"
308
+ crypttrace case add ronin 0x098b…2f96 --chain eth --role suspect --note "exploiter"
309
+ crypttrace case note ronin "asked Binance for KYC on the first-gas withdrawal"
310
+ crypttrace case refresh ronin # what moved since the last refresh
311
+ crypttrace case show ronin # addresses, current labels, last snapshot, the log
312
+ ```
313
+
314
+ A case is one JSON file in `~/.crypttrace/cases/`. Every `case` command also
315
+ takes a path to a `.json` file, so handing a case to a colleague is sending the
316
+ file.
317
+
298
318
  ### Watch & alerts — catching the cash-out
299
319
 
300
320
  The only window to freeze stolen funds is the moment they reach an exchange
@@ -693,6 +713,7 @@ src/crypttrace/
693
713
  offramp.py # exchange-deposit detection
694
714
  bridges.py # cross-chain matching
695
715
  watch.py # watchlist + alerts
716
+ case.py # investigations as files: addresses, roles, notes, snapshots
696
717
  report.py # Markdown + JSON reports
697
718
  export.py # traced graph as GraphML (Gephi) and edge-list CSV (Maltego)
698
719
  prices.py # USD valuation
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.32.0"
2
+ __version__ = "0.33.0"
@@ -0,0 +1,187 @@
1
+ """An investigation kept as a file: its addresses, their roles, notes, and what
2
+ changed since you last looked.
3
+
4
+ A case is one JSON file — by default ~/.crypttrace/cases/NAME.json, or any path
5
+ — so handing a case to a colleague is sending them the file. It holds:
6
+
7
+ - addresses across chains, each with a role (victim, suspect, exchange, …)
8
+ and a note;
9
+ - a log of timestamped notes;
10
+ - for each address, the last snapshot taken by `refresh`: native balance
11
+ and the time of the latest transfer (tokens included where the chain has
12
+ them). The next `refresh` compares against it, which is how a case tells
13
+ you the money moved again.
14
+ """
15
+ import datetime as dt
16
+ import json
17
+ import re
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Callable, List, Optional
21
+
22
+ from crypttrace import addresses, chains, config
23
+
24
+ CASES_DIR = config.DATA_DIR / "cases"
25
+ ROLES = ("victim", "suspect", "intermediary", "exchange", "mixer", "bridge", "other")
26
+ FORMAT = 1
27
+ _ALL_TOKENS = {"contract": None, "symbol": "*"}
28
+
29
+
30
+ class CaseError(ValueError):
31
+ pass
32
+
33
+
34
+ def path_for(name_or_path: str) -> Path:
35
+ """A case named in the cases folder, or a .json file anywhere."""
36
+ p = Path(name_or_path)
37
+ if p.suffix.lower() == ".json" or p.parent != Path("."):
38
+ return p
39
+ if not re.fullmatch(r"[\w.-]{1,64}", name_or_path):
40
+ raise CaseError("a case name is letters, digits, '.', '-' or '_' (up to 64)")
41
+ return CASES_DIR / f"{name_or_path}.json"
42
+
43
+
44
+ def _now() -> int:
45
+ return int(time.time())
46
+
47
+
48
+ def new(name: str, note: str = "") -> Path:
49
+ p = path_for(name)
50
+ if p.exists():
51
+ raise CaseError(f"case already exists: {p}")
52
+ case = {"crypttrace_case": FORMAT, "name": p.stem, "created": _now(), "updated": _now(),
53
+ "addresses": [], "notes": []}
54
+ if note:
55
+ case["notes"].append({"at": _now(), "text": note})
56
+ save(case, p)
57
+ return p
58
+
59
+
60
+ def load(name: str) -> dict:
61
+ p = path_for(name)
62
+ try:
63
+ case = json.loads(p.read_text(encoding="utf-8"))
64
+ except FileNotFoundError:
65
+ raise CaseError(f"no such case: {p}")
66
+ except ValueError as e:
67
+ raise CaseError(f"{p} is not a readable case file: {e}")
68
+ if not isinstance(case, dict) or "crypttrace_case" not in case:
69
+ raise CaseError(f"{p} is not a crypttrace case file")
70
+ case["_path"] = str(p)
71
+ return case
72
+
73
+
74
+ def save(case: dict, p: Optional[Path] = None) -> Path:
75
+ p = Path(p or case["_path"])
76
+ p.parent.mkdir(parents=True, exist_ok=True)
77
+ data = {k: v for k, v in case.items() if not k.startswith("_")}
78
+ data["updated"] = _now()
79
+ tmp = p.with_suffix(p.suffix + ".tmp")
80
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
81
+ tmp.replace(p) # a half-written case never replaces a good one
82
+ return p
83
+
84
+
85
+ def all_cases() -> List[dict]:
86
+ out = []
87
+ for p in sorted(CASES_DIR.glob("*.json")):
88
+ try:
89
+ c = load(str(p))
90
+ except CaseError:
91
+ continue
92
+ out.append({"name": c.get("name", p.stem), "path": str(p), "addresses": len(c.get("addresses", [])),
93
+ "notes": len(c.get("notes", [])), "updated": c.get("updated")})
94
+ return out
95
+
96
+
97
+ def add(name: str, address: str, chain: str, role: str = "other", note: str = "") -> dict:
98
+ chains.check(chain)
99
+ if role not in ROLES:
100
+ raise CaseError(f"role must be one of: {', '.join(ROLES)}")
101
+ ok, why = addresses.validate(address, chain)
102
+ if not ok:
103
+ raise CaseError(f"not a valid {chain} address: {why}")
104
+ case = load(name)
105
+ key = chains.norm_addr(address, chain)
106
+ for a in case["addresses"]:
107
+ if a["chain"] == chain and a["address"] == key:
108
+ a["role"] = role # adding it again updates it
109
+ if note:
110
+ a["note"] = note
111
+ save(case)
112
+ return a
113
+ entry = {"address": key, "chain": chain, "role": role, "note": note, "added": _now(), "snapshot": None}
114
+ case["addresses"].append(entry)
115
+ save(case)
116
+ return entry
117
+
118
+
119
+ def remove(name: str, address: str, chain: Optional[str] = None) -> bool:
120
+ case = load(name)
121
+ before = len(case["addresses"])
122
+ case["addresses"] = [a for a in case["addresses"]
123
+ if not (a["address"] == chains.norm_addr(address, a["chain"])
124
+ and (chain is None or a["chain"] == chain))]
125
+ if len(case["addresses"]) == before:
126
+ return False
127
+ save(case)
128
+ return True
129
+
130
+
131
+ def note(name: str, text: str) -> dict:
132
+ case = load(name)
133
+ entry = {"at": _now(), "text": text}
134
+ case["notes"].append(entry)
135
+ save(case)
136
+ return entry
137
+
138
+
139
+ def snapshot(address: str, chain: str) -> dict:
140
+ """Native balance and the time of the latest transfer, read fresh."""
141
+ snap = {"at": _now(), "balance": None, "last_seen": None, "error": None}
142
+ try:
143
+ snap["balance"] = chains.balance(address, chain)
144
+ rows = list(chains.transfers(address, chain, 50, fresh=True))
145
+ if not chains.is_utxo(chain):
146
+ try:
147
+ rows += chains.transfers(address, chain, 50, asset=_ALL_TOKENS, fresh=True)
148
+ except chains.ChainError:
149
+ pass
150
+ stamps = [int(r.get("timestamp") or 0) for r in rows if r.get("timestamp")]
151
+ snap["last_seen"] = max(stamps) if stamps else None
152
+ except chains.ChainError as e:
153
+ snap["error"] = str(e)
154
+ return snap
155
+
156
+
157
+ def changes(prev: Optional[dict], cur: dict) -> List[str]:
158
+ """What is different from the last snapshot, in words."""
159
+ if cur.get("error"):
160
+ return [f"could not be read: {cur['error']}"]
161
+ if not prev or prev.get("error"):
162
+ return ["first snapshot"]
163
+ out = []
164
+ a, b = prev.get("balance"), cur.get("balance")
165
+ if a is not None and b is not None and abs(b - a) > max(1e-9, abs(a) * 1e-9):
166
+ out.append(f"balance {a:,.8f} -> {b:,.8f} ({b - a:+,.8f})")
167
+ if (cur.get("last_seen") or 0) > (prev.get("last_seen") or 0):
168
+ when = dt.datetime.fromtimestamp(cur["last_seen"], dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
169
+ out.append(f"new activity, latest {when}")
170
+ return out
171
+
172
+
173
+ def refresh(name: str, take: Callable[[str, str], dict] = snapshot) -> List[dict]:
174
+ """Snapshot every address; report what changed since the last refresh."""
175
+ case = load(name)
176
+ report = []
177
+ for a in case["addresses"]:
178
+ cur = take(a["address"], a["chain"])
179
+ diff = changes(a.get("snapshot"), cur)
180
+ report.append({**{k: a[k] for k in ("address", "chain", "role")}, "changes": diff,
181
+ "moved": any(not d.startswith(("first", "could not")) for d in diff),
182
+ "balance": cur.get("balance"), "previous": a.get("snapshot")})
183
+ if not cur.get("error"):
184
+ a["snapshot"] = cur
185
+ case.setdefault("refreshes", []).append(_now())
186
+ save(case)
187
+ return report
@@ -1034,5 +1034,124 @@ def watch_run(
1034
1034
  console.print("\n[dim]Stopped.[/dim]")
1035
1035
 
1036
1036
 
1037
+ # ---------------------------------------------------------------- cases
1038
+ case_app = typer.Typer(help="Keep an investigation as a file: addresses, roles, notes, what changed.")
1039
+ app.add_typer(case_app, name="case")
1040
+ CASE_ARG = typer.Argument(..., help="Case name (kept in ~/.crypttrace/cases) or a path to a .json case file")
1041
+
1042
+
1043
+ def _case_call(fn, *a, **kw):
1044
+ from crypttrace import case as case_mod
1045
+ try:
1046
+ return fn(*a, **kw)
1047
+ except (case_mod.CaseError, chains_mod.ChainError) as e:
1048
+ console.print(f"[red]Error:[/red] {e}")
1049
+ raise typer.Exit(1)
1050
+
1051
+
1052
+ @case_app.command("new")
1053
+ def case_new(name: str = CASE_ARG,
1054
+ note: str = typer.Option("", "--note", "-n", help="What the case is about")):
1055
+ """Start a case."""
1056
+ from crypttrace import case as case_mod
1057
+ p = _case_call(case_mod.new, name, note)
1058
+ console.print(f"[green]✓ Case created[/green] {p}\n[dim]Add addresses with "
1059
+ f"`crypttrace case add {name} ADDRESS --chain … --role suspect`[/dim]")
1060
+
1061
+
1062
+ @case_app.command("list")
1063
+ def case_list():
1064
+ """Cases in ~/.crypttrace/cases."""
1065
+ from crypttrace import case as case_mod
1066
+ cases = case_mod.all_cases()
1067
+ if not cases:
1068
+ console.print("[dim]No cases yet. Start one with `crypttrace case new NAME`.[/dim]")
1069
+ return
1070
+ t = render.Table(header_style="bold")
1071
+ for c in ("Case", "Addresses", "Notes", "Updated (UTC)"):
1072
+ t.add_column(c)
1073
+ for c in cases:
1074
+ t.add_row(c["name"], str(c["addresses"]), str(c["notes"]), render._ts(str(c["updated"] or 0)))
1075
+ console.print(t)
1076
+
1077
+
1078
+ @case_app.command("add")
1079
+ def case_add(name: str = CASE_ARG,
1080
+ address: str = typer.Argument(..., help="Address to add"),
1081
+ chain: str = CHAIN_OPT,
1082
+ role: str = typer.Option("other", "--role", "-r",
1083
+ help="victim, suspect, intermediary, exchange, mixer, bridge or other"),
1084
+ note: str = typer.Option("", "--note", "-n", help="Why it is in the case")):
1085
+ """Add an address to a case (adding it again updates its role and note)."""
1086
+ from crypttrace import case as case_mod
1087
+ a = _case_call(case_mod.add, name, address, chain, role, note)
1088
+ console.print(f"[green]✓[/green] {a['address']} ({a['chain']}) as {a['role']}"
1089
+ + (f" — {a['note']}" if a.get("note") else ""))
1090
+
1091
+
1092
+ @case_app.command("remove")
1093
+ def case_remove(name: str = CASE_ARG, address: str = typer.Argument(..., help="Address to drop"),
1094
+ chain: Optional[str] = typer.Option(None, "--chain", "-c", help="Only on this chain")):
1095
+ """Drop an address from a case."""
1096
+ from crypttrace import case as case_mod
1097
+ if _case_call(case_mod.remove, name, address, chain):
1098
+ console.print(f"[green]✓ Removed[/green] {address}")
1099
+ else:
1100
+ console.print("[dim]That address is not in the case.[/dim]")
1101
+
1102
+
1103
+ @case_app.command("note")
1104
+ def case_note(name: str = CASE_ARG, text: str = typer.Argument(..., help="The note")):
1105
+ """Add a timestamped note to the case log."""
1106
+ from crypttrace import case as case_mod
1107
+ _case_call(case_mod.note, name, text)
1108
+ console.print("[green]✓ Noted[/green]")
1109
+
1110
+
1111
+ @case_app.command("show")
1112
+ def case_show(name: str = CASE_ARG):
1113
+ """The case: its addresses with current labels, the last snapshot, the log."""
1114
+ from crypttrace import case as case_mod
1115
+ c = _case_call(case_mod.load, name)
1116
+ console.print(f"[bold]{c['name']}[/bold] [dim]{c['_path']}[/dim]")
1117
+ if c["addresses"]:
1118
+ t = render.Table(header_style="bold")
1119
+ for col in ("Role", "Chain", "Address", "Label", "Note", "Last snapshot"):
1120
+ t.add_column(col)
1121
+ for a in c["addresses"]:
1122
+ s = a.get("snapshot") or {}
1123
+ snap = "—" if not s else (
1124
+ (f"{s['balance']:,.8f}" if s.get("balance") is not None else "?") + " "
1125
+ f"{chains_mod.symbol(a['chain'])} · seen "
1126
+ + (render._ts(str(s['last_seen'])) if s.get("last_seen") else "never")
1127
+ + f" · taken {render._ts(str(s['at']))}")
1128
+ t.add_row(a["role"], a["chain"], a["address"], labels.label_of(a["address"]) or "",
1129
+ a.get("note") or "", snap)
1130
+ console.print(t)
1131
+ else:
1132
+ console.print("[dim]No addresses yet.[/dim]")
1133
+ for n in c.get("notes", []):
1134
+ console.print(f" [dim]{render._ts(str(n['at']))}[/dim] {n['text']}")
1135
+
1136
+
1137
+ @case_app.command("refresh")
1138
+ def case_refresh(name: str = CASE_ARG):
1139
+ """Read every address again and say what changed since the last refresh."""
1140
+ from crypttrace import case as case_mod
1141
+ rep = _case_call(case_mod.refresh, name)
1142
+ if not rep:
1143
+ console.print("[dim]The case has no addresses.[/dim]")
1144
+ return
1145
+ moved = [r for r in rep if r["moved"]]
1146
+ for r in rep:
1147
+ mark = "[bold yellow]●[/bold yellow]" if r["moved"] else "[dim]·[/dim]"
1148
+ console.print(f"{mark} {r['role']:<12} {r['chain']:<8} {r['address']}")
1149
+ for d in r["changes"]:
1150
+ console.print(f" {d}")
1151
+ console.print(f"\n[bold]{len(moved)}[/bold] of {len(rep)} address(es) changed since the last refresh."
1152
+ if any(r["previous"] for r in rep) else "\nFirst snapshot taken; the next refresh "
1153
+ "will say what changed.")
1154
+
1155
+
1037
1156
  if __name__ == "__main__":
1038
1157
  app()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.32.0
3
+ Version: 0.33.0
4
4
  Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron, Solana and TON
5
5
  Author: bobslayerX
6
6
  License-Expression: MIT
@@ -329,6 +329,26 @@ entities — that's the OSINT handoff point. `--direction out` (default) answers
329
329
  money come from", which is how you vet a suspicious address or find a victim's
330
330
  source of funds.
331
331
 
332
+ ### Cases — an investigation kept as a file
333
+
334
+ An investigation rarely fits one address. `case` keeps the addresses (any
335
+ chain, each with a role: victim, suspect, intermediary, exchange, mixer, bridge
336
+ or other), a log of notes, and the last snapshot of each address. `refresh`
337
+ reads them all again and says what changed: a balance that went down, new
338
+ activity since the last look.
339
+
340
+ ```bash
341
+ crypttrace case new ronin --note "Ronin bridge exploit, March 2022"
342
+ crypttrace case add ronin 0x098b…2f96 --chain eth --role suspect --note "exploiter"
343
+ crypttrace case note ronin "asked Binance for KYC on the first-gas withdrawal"
344
+ crypttrace case refresh ronin # what moved since the last refresh
345
+ crypttrace case show ronin # addresses, current labels, last snapshot, the log
346
+ ```
347
+
348
+ A case is one JSON file in `~/.crypttrace/cases/`. Every `case` command also
349
+ takes a path to a `.json` file, so handing a case to a colleague is sending the
350
+ file.
351
+
332
352
  ### Watch & alerts — catching the cash-out
333
353
 
334
354
  The only window to freeze stolen funds is the moment they reach an exchange
@@ -727,6 +747,7 @@ src/crypttrace/
727
747
  offramp.py # exchange-deposit detection
728
748
  bridges.py # cross-chain matching
729
749
  watch.py # watchlist + alerts
750
+ case.py # investigations as files: addresses, roles, notes, snapshots
730
751
  report.py # Markdown + JSON reports
731
752
  export.py # traced graph as GraphML (Gephi) and edge-list CSV (Maltego)
732
753
  prices.py # USD valuation
@@ -7,6 +7,7 @@ src/crypttrace/analysis.py
7
7
  src/crypttrace/assess.py
8
8
  src/crypttrace/assets.py
9
9
  src/crypttrace/bridges.py
10
+ src/crypttrace/case.py
10
11
  src/crypttrace/chains.py
11
12
  src/crypttrace/cli.py
12
13
  src/crypttrace/config.py
File without changes
File without changes
File without changes