crypttrace 0.16.0__tar.gz → 0.17.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 (51) hide show
  1. {crypttrace-0.16.0/src/crypttrace.egg-info → crypttrace-0.17.0}/PKG-INFO +17 -1
  2. {crypttrace-0.16.0 → crypttrace-0.17.0}/README.md +16 -0
  3. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/cli.py +30 -0
  5. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/drain.py +3 -1
  6. crypttrace-0.17.0/src/crypttrace/evidence.py +233 -0
  7. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/web/index.html +5 -0
  8. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/webapp.py +17 -0
  9. {crypttrace-0.16.0 → crypttrace-0.17.0/src/crypttrace.egg-info}/PKG-INFO +17 -1
  10. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
  11. {crypttrace-0.16.0 → crypttrace-0.17.0}/LICENSE +0 -0
  12. {crypttrace-0.16.0 → crypttrace-0.17.0}/pyproject.toml +0 -0
  13. {crypttrace-0.16.0 → crypttrace-0.17.0}/setup.cfg +0 -0
  14. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/addresses.py +0 -0
  15. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/analysis.py +0 -0
  16. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/assess.py +0 -0
  17. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/assets.py +0 -0
  18. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/bridges.py +0 -0
  19. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/chains.py +0 -0
  20. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/config.py +0 -0
  21. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/__init__.py +0 -0
  22. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  23. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  24. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/http.py +0 -0
  25. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/solana.py +0 -0
  26. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/ton.py +0 -0
  27. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/tron.py +0 -0
  28. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/freeze.py +0 -0
  29. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/funder.py +0 -0
  30. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/investigate.py +0 -0
  31. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/__init__.py +0 -0
  32. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/audit.py +0 -0
  33. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/bulk.py +0 -0
  34. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/known.json +0 -0
  35. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/labels.py +0 -0
  36. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/partial.json +0 -0
  37. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/tonapi.py +0 -0
  38. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/offramp.py +0 -0
  39. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/poisoning.py +0 -0
  40. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/prices.py +0 -0
  41. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/render.py +0 -0
  42. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/report.py +0 -0
  43. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/spam.py +0 -0
  44. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/store.py +0 -0
  45. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/trace.py +0 -0
  46. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/verify.py +0 -0
  47. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/watch.py +0 -0
  48. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  49. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  50. {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace.egg-info/requires.txt +0 -0
  51. {crypttrace-0.16.0 → crypttrace-0.17.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.16.0
3
+ Version: 0.17.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
@@ -153,6 +153,22 @@ history and answers in plain language:
153
153
 
154
154
  The web UI has the same check in its **Was I drained?** tab.
155
155
 
156
+ ### An evidence pack for the exchange and the police
157
+
158
+ ```bash
159
+ crypttrace evidence YOUR_ADDRESS --chain ton --lang en # for the exchange
160
+ crypttrace evidence YOUR_ADDRESS --chain ton --lang ru # for a police report (Russian)
161
+ ```
162
+
163
+ Turns the check into a document to send: a summary of the theft, every
164
+ transfer out of the wallet with its explorer link, the path to the exchange
165
+ deposit and its memo with the hash of each hop, a precise request to the
166
+ exchange (freeze the account behind the memo, preserve KYC, login and
167
+ withdrawal records) and to the police (request them through the exchange's
168
+ law-enforcement channel), and how it was established — so anyone can check it.
169
+ Name, contact and signature are left blank to fill in. Saved as HTML (print to
170
+ PDF), Markdown and JSON; the **Was I drained?** tab has download buttons too.
171
+
156
172
  ## Web UI
157
173
 
158
174
  ```bash
@@ -119,6 +119,22 @@ history and answers in plain language:
119
119
 
120
120
  The web UI has the same check in its **Was I drained?** tab.
121
121
 
122
+ ### An evidence pack for the exchange and the police
123
+
124
+ ```bash
125
+ crypttrace evidence YOUR_ADDRESS --chain ton --lang en # for the exchange
126
+ crypttrace evidence YOUR_ADDRESS --chain ton --lang ru # for a police report (Russian)
127
+ ```
128
+
129
+ Turns the check into a document to send: a summary of the theft, every
130
+ transfer out of the wallet with its explorer link, the path to the exchange
131
+ deposit and its memo with the hash of each hop, a precise request to the
132
+ exchange (freeze the account behind the memo, preserve KYC, login and
133
+ withdrawal records) and to the police (request them through the exchange's
134
+ law-enforcement channel), and how it was established — so anyone can check it.
135
+ Name, contact and signature are left blank to fill in. Saved as HTML (print to
136
+ PDF), Markdown and JSON; the **Was I drained?** tab has download buttons too.
137
+
122
138
  ## Web UI
123
139
 
124
140
  ```bash
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.16.0"
2
+ __version__ = "0.17.0"
@@ -182,6 +182,36 @@ def check(
182
182
  console.print(render.drain_report(res))
183
183
 
184
184
 
185
+ @app.command()
186
+ def evidence(
187
+ address: str = typer.Argument(..., help="The victim's own wallet address"),
188
+ chain: str = CHAIN_OPT,
189
+ lang: str = typer.Option("en", "--lang", "-l", help="en (for exchanges) or ru (for the police)"),
190
+ out: Path = typer.Option(Path.home() / "crypttrace-reports", "--out", "-o", help="Folder to save into"),
191
+ ):
192
+ """An evidence pack for the exchange and the police: what was taken, the path, the memo, the requests."""
193
+ from crypttrace import evidence as ev
194
+ if lang not in ev.T:
195
+ console.print(f"[red]Error:[/red] --lang must be one of {', '.join(ev.T)}")
196
+ raise typer.Exit(1)
197
+ try:
198
+ with console.status("Reading the wallet's history…"):
199
+ doc = ev.build(address, chain)
200
+ except (chains_mod.ChainError, etherscan.EtherscanError) as e:
201
+ console.print(f"[red]Error:[/red] {e}")
202
+ raise typer.Exit(1)
203
+ if doc["check"]["verdict"] == "no_drain":
204
+ console.print("[green]No drain found[/green] for this wallet — nothing to put in an evidence pack.")
205
+ raise typer.Exit(0)
206
+ paths = ev.write(doc, out, lang)
207
+ memos = ", ".join(f"{d['company']} memo {d['memo']}" for d in doc["deposits"]) or "none found yet"
208
+ console.print(f"[bold]Evidence pack saved[/bold] — exchange deposits: {memos}")
209
+ for p in paths:
210
+ console.print(f" {p}")
211
+ console.print("[dim]Open the .html in a browser and print it to PDF. Fill in your name and contact, "
212
+ "then send it with your report.[/dim]")
213
+
214
+
185
215
  @app.command()
186
216
  def trace(
187
217
  address: str = typer.Argument(..., help="Starting address (0x…)"),
@@ -100,7 +100,9 @@ def check(address: str, chain: str, limit: int = 1000) -> Dict:
100
100
  res.update({"to": dest, "to_label": labels.label_of(dest), "to_type": labels.type_of(dest),
101
101
  "start": burst["start"], "end": burst["end"], "transfers": len({r.get("hash") for r in win}),
102
102
  "assets": assets, "nfts": nfts, "emptied": emptied, "new_destination": not paid_before,
103
- "tight_pairs": tight})
103
+ "tight_pairs": tight,
104
+ "drain_txs": [{"timestamp": r["timestamp"], "value": r.get("value", 0), "symbol": r.get("symbol", ""),
105
+ "hash": r.get("hash", "")} for r in sorted(win, key=lambda r: r["timestamp"])]})
104
106
 
105
107
  # a mass drain: the same address swept many wallets around the same time
106
108
  try:
@@ -0,0 +1,233 @@
1
+ """An evidence pack a victim can hand to an exchange and to the police.
2
+
3
+ Built from `drain.check` on the victim's own address: what was taken, when, by
4
+ which transactions, where the money went, and — the part that gets results — the
5
+ exchange deposit and its memo, with a precise request: freeze that account and
6
+ preserve its records. Everything is a fact read from the chain with a link to
7
+ check it; nothing about the victim is invented — name, contact and signature are
8
+ left blank to fill in.
9
+
10
+ English for exchanges' compliance teams, Russian for a police report.
11
+ """
12
+ import datetime as dt
13
+ import html
14
+ import json
15
+ from pathlib import Path
16
+ from typing import Dict, List, Optional
17
+
18
+ from crypttrace import __version__, chains, drain
19
+ from crypttrace.labels import labels
20
+
21
+ # law-enforcement request portals, only where checked against the exchange's own site
22
+ LE_PORTALS = {"KuCoin": "https://www.kucoin.com/legal/requests"}
23
+ SOURCES = {"ton": "toncenter.com (transactions), tonapi.io (account names)",
24
+ "tron": "TronGrid", "btc": "mempool.space", "sol": "Solana JSON-RPC"}
25
+
26
+
27
+ def _utc(ts) -> str:
28
+ return dt.datetime.fromtimestamp(int(ts or 0), dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
29
+
30
+
31
+ def _link_hop(src: str, dst: str, chain: str) -> Optional[dict]:
32
+ """The largest transfer src → dst: the link between two hops of the path."""
33
+ rows = [r for r in chains.transfers(src, chain) if r.get("from") == src and r.get("to") == dst]
34
+ if not rows:
35
+ return None
36
+ r = max(rows, key=lambda r: r.get("value", 0) or 0)
37
+ return {"from": src, "to": dst, "value": r.get("value", 0), "symbol": r.get("symbol", ""),
38
+ "timestamp": r.get("timestamp", 0), "hash": r.get("hash", "")}
39
+
40
+
41
+ def build(address: str, chain: str) -> Dict:
42
+ """Facts for the pack. `verdict` other than drained/suspicious means there is nothing to report."""
43
+ res = drain.check(address, chain)
44
+ doc = {"generated": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
45
+ "tool": f"crypttrace {__version__}", "chain": chain, "check": res, "path": [], "deposits": []}
46
+ if res["verdict"] == "no_drain":
47
+ return doc
48
+ dest = res["to"]
49
+ for d in res.get("next", []):
50
+ if not d.get("memo"):
51
+ continue
52
+ hops = []
53
+ if d["from"] != dest:
54
+ link = _link_hop(dest, d["from"], chain)
55
+ if link:
56
+ hops.append(link)
57
+ company = labels.company(d["exchange"])
58
+ doc["deposits"].append({**d, "company": company, "portal": LE_PORTALS.get(company),
59
+ "via": hops})
60
+ return doc
61
+
62
+
63
+ # ------------------------------------------------------------------ text
64
+ T = {
65
+ "en": {
66
+ "title": "Theft of cryptocurrency — evidence summary",
67
+ "victim": "Victim (to be completed)", "name": "Name", "contact": "Contact (e-mail / phone)",
68
+ "wallet": "Wallet", "discovered": "Date the theft was discovered",
69
+ "summary": "Summary",
70
+ "summary_text": ("On {start}, {n} transfer(s) moved {taken} out of the wallet {victim} to {to}{to_label}, "
71
+ "within {mins} minute(s). The wallet was left empty."),
72
+ "mass": ("The same receiving address collected funds from {k} other wallets within one hour of this "
73
+ "theft — a mass theft using leaked private keys, not an individual transaction made by the owner."),
74
+ "resweep": ("Money that arrived in the wallet after the theft was taken as well ({n} later transfer(s)): "
75
+ "the attacker still holds the wallet's key."),
76
+ "chronology": "Transfers out of the victim's wallet",
77
+ "cols": ["Time (UTC)", "Amount", "Transaction"],
78
+ "path": "Where the funds went",
79
+ "deposit": ("The funds collected by the receiving address (from this and other victims) were moved on: "
80
+ "{amount} was deposited on {time} to the hot wallet of {company} ({hot}) with the deposit "
81
+ "memo {memo}. The memo identifies one customer account at {company}."),
82
+ "via": "Path: {chain_text}",
83
+ "requests": "Requests",
84
+ "req_exchange": ("To {company}: please freeze the account identified by deposit memo {memo} (deposits to {hot}), "
85
+ "and preserve its KYC records, login IP addresses and withdrawal history. A police report "
86
+ "is being filed; law enforcement will request the data through your official channel."),
87
+ "req_police": ("To the police: please request from {company} the identity, login records and withdrawal "
88
+ "destinations of the account behind deposit memo {memo}{portal}."),
89
+ "portal": " — law-enforcement portal: {url}",
90
+ "none": "No deposit with a memo was found on the path yet; the receiving address above is the lead.",
91
+ "method": "How this was established",
92
+ "method_text": ("Read directly from the public {chain} blockchain ({sources}) with {tool} on {generated}. "
93
+ "Every transaction above links to a public block explorer and can be checked by anyone. "
94
+ "Exchange names are explorer tags; the account behind a memo can be confirmed only by the "
95
+ "exchange. To reproduce: {cmd}"),
96
+ "sign": "Signature / date", "nothing": "No drain was found for this wallet in the history read.",
97
+ },
98
+ "ru": {
99
+ "title": "Хищение криптовалюты — сводка доказательств",
100
+ "victim": "Потерпевший (заполнить)", "name": "ФИО", "contact": "Контакты (e-mail / телефон)",
101
+ "wallet": "Кошелёк", "discovered": "Дата обнаружения хищения",
102
+ "summary": "Кратко",
103
+ "summary_text": ("{start} с кошелька {victim} на адрес {to}{to_label} было выполнено {n} перевод(а/ов) "
104
+ "за {mins} мин.: {taken}. После этого кошелёк остался пустым."),
105
+ "mass": ("Тот же адрес-получатель в течение часа собрал средства ещё с {k} кошельков — это массовое хищение "
106
+ "с использованием утёкших ключей, а не перевод, сделанный владельцем."),
107
+ "resweep": ("Средства, поступившие на кошелёк после хищения, также были выведены ({n} перевод(а/ов)): "
108
+ "ключ от кошелька до сих пор у злоумышленника."),
109
+ "chronology": "Переводы с кошелька потерпевшего",
110
+ "cols": ["Время (UTC)", "Сумма", "Транзакция"],
111
+ "path": "Куда ушли средства",
112
+ "deposit": ("Средства, собранные адресом-получателем (в том числе с других потерпевших), выведены "
113
+ "дальше: {amount} зачислено {time} на горячий кошелёк биржи {company} ({hot}) с депозитным "
114
+ "memo {memo}. Memo однозначно указывает на один клиентский аккаунт на {company}."),
115
+ "via": "Путь: {chain_text}",
116
+ "requests": "Просьбы",
117
+ "req_exchange": ("Бирже {company}: заморозить аккаунт, определяемый депозитным memo {memo} (депозиты на {hot}), "
118
+ "и сохранить его данные KYC, IP-адреса входов и историю выводов. Подаётся заявление в "
119
+ "полицию; правоохранительные органы запросят данные через официальный канал биржи."),
120
+ "req_police": ("Полиции: запросить у {company} данные владельца, журнал входов и адреса выводов аккаунта "
121
+ "с депозитным memo {memo}{portal}."),
122
+ "portal": " — портал для правоохранительных органов: {url}",
123
+ "none": "Депозит на биржу с memo пока не найден; зацепкой остаётся адрес-получатель, указанный выше.",
124
+ "method": "Как это установлено",
125
+ "method_text": ("Данные получены напрямую из публичного блокчейна {chain} ({sources}) инструментом {tool}, "
126
+ "{generated}. Каждая транзакция выше снабжена ссылкой на публичный обозреватель и может быть "
127
+ "проверена любым лицом. Названия бирж — метки обозревателей; владельца аккаунта по memo "
128
+ "может подтвердить только биржа. Проверка: {cmd}"),
129
+ "sign": "Подпись / дата", "nothing": "Для этого кошелька хищение в прочитанной истории не обнаружено.",
130
+ },
131
+ }
132
+
133
+
134
+ def _facts(doc: Dict, lang: str) -> Dict:
135
+ t, res, chain = T[lang], doc["check"], doc["chain"]
136
+ taken = ", ".join(f"{v:,.4f} {k}" for k, v in res.get("assets", {}).items())
137
+ if res.get("nfts"):
138
+ taken += f"{', ' if taken else ''}{res['nfts']} NFT"
139
+ lab = res.get("to_label")
140
+ paras = [t["summary_text"].format(start=_utc(res["start"]), n=res["transfers"], taken=taken or "—",
141
+ victim=res["address"], to=res["to"], to_label=f" ({lab})" if lab else "",
142
+ mins=max(1, (res["end"] - res["start"]) // 60))]
143
+ if (res.get("other_wallets_same_hour") or 0) >= drain.MASS_SENDERS:
144
+ paras.append(t["mass"].format(k=res["other_wallets_same_hour"]))
145
+ if res.get("resweeps"):
146
+ paras.append(t["resweep"].format(n=res["resweeps"]))
147
+ rows = [(_utc(x["timestamp"]), f"{x['value']:,.4f} {x['symbol']}", x["hash"]) for x in res.get("drain_txs", [])]
148
+ deps = []
149
+ for d in doc["deposits"]:
150
+ hops = [res["address"], res["to"]] + [h["to"] for h in d["via"]] + [d["exchange_address"]]
151
+ deps.append({
152
+ "text": t["deposit"].format(amount=f"{d['value']:,.4f} {d['symbol']}", time=_utc(d["last_ts"]),
153
+ exchange=d["exchange"], hot=d["exchange_address"], memo=d["memo"],
154
+ company=d["company"]),
155
+ "via": t["via"].format(chain_text=" → ".join(hops)),
156
+ "links": [h["hash"] for h in d["via"]] + [d["hash"]],
157
+ "req_exchange": t["req_exchange"].format(company=d["company"], memo=d["memo"], hot=d["exchange_address"]),
158
+ "req_police": t["req_police"].format(company=d["company"], memo=d["memo"],
159
+ portal=t["portal"].format(url=d["portal"]) if d.get("portal") else ""),
160
+ })
161
+ method = t["method_text"].format(chain=chain.upper(), sources=SOURCES.get(chain, "public node APIs"),
162
+ tool=doc["tool"], generated=doc["generated"],
163
+ cmd=f"crypttrace check {res['address']} --chain {chain}")
164
+ return {"paras": paras, "rows": rows, "deps": deps, "method": method}
165
+
166
+
167
+ def render_markdown(doc: Dict, lang: str = "en") -> str:
168
+ t, res = T[lang], doc["check"]
169
+ if res["verdict"] == "no_drain":
170
+ return f"# {t['title']}\n\n{t['nothing']}\n"
171
+ f = _facts(doc, lang)
172
+ tx = lambda h: f"[{h[:16]}…]({chains.tx_url(h, doc['chain'])})"
173
+ L = [f"# {t['title']}\n", f"## {t['victim']}\n",
174
+ f"- {t['name']}: ____________________\n", f"- {t['contact']}: ____________________\n",
175
+ f"- {t['wallet']}: `{res['address']}` ({doc['chain'].upper()})\n", f"- {t['discovered']}: __________\n",
176
+ f"\n## {t['summary']}\n"] + [p + "\n\n" for p in f["paras"]]
177
+ L += [f"## {t['chronology']}\n", f"| {' | '.join(t['cols'])} |\n|---|---|---|\n"]
178
+ L += [f"| {a} | {b} | {tx(h)} |\n" for a, b, h in f["rows"]]
179
+ L += [f"\n## {t['path']}\n"]
180
+ if f["deps"]:
181
+ for d in f["deps"]:
182
+ L += [d["text"] + "\n\n", d["via"] + "\n\n", " · ".join(tx(h) for h in d["links"] if h) + "\n\n"]
183
+ L += [f"## {t['requests']}\n"] + [f"- {d['req_exchange']}\n- {d['req_police']}\n" for d in f["deps"]]
184
+ else:
185
+ L.append(t["none"] + "\n")
186
+ L += [f"\n## {t['method']}\n", f["method"] + "\n", f"\n{t['sign']}: ____________________\n"]
187
+ return "".join(L)
188
+
189
+
190
+ def render_html(doc: Dict, lang: str = "en") -> str:
191
+ t, res, e = T[lang], doc["check"], html.escape
192
+ head = (f"<!doctype html><html lang='{lang}'><head><meta charset='utf-8'><title>{e(t['title'])}</title>"
193
+ "<style>body{font:15px/1.55 Georgia,'Times New Roman',serif;max-width:860px;margin:32px auto;padding:0 20px;"
194
+ "color:#111}h1{font-size:24px}h2{font-size:18px;margin-top:28px;border-bottom:1px solid #999}"
195
+ "table{border-collapse:collapse;width:100%}td,th{border:1px solid #bbb;padding:5px 8px;font-size:13px;"
196
+ "text-align:left;vertical-align:top}code,.mono{font-family:Consolas,monospace;font-size:12.5px;"
197
+ "word-break:break-all}.box{border:2px solid #111;padding:10px 14px;margin:10px 0}.blank{display:inline-block;"
198
+ "min-width:280px;border-bottom:1px solid #111}@media print{a{color:#111}}</style></head><body>")
199
+ if res["verdict"] == "no_drain":
200
+ return head + f"<h1>{e(t['title'])}</h1><p>{e(t['nothing'])}</p></body></html>"
201
+ f = _facts(doc, lang)
202
+ a = lambda h: f"<a class='mono' href='{e(chains.tx_url(h, doc['chain']))}'>{e(h)}</a>"
203
+ P = [head, f"<h1>{e(t['title'])}</h1>", f"<h2>{e(t['victim'])}</h2><table>"]
204
+ for k, v in ((t["name"], ""), (t["contact"], ""), (t["wallet"], f"<span class='mono'>{e(res['address'])}</span> "
205
+ f"({e(doc['chain'].upper())})"),
206
+ (t["discovered"], "")):
207
+ P.append(f"<tr><th style='width:34%'>{e(k)}</th><td>{v or '<span class=blank>&nbsp;</span>'}</td></tr>")
208
+ P.append("</table>")
209
+ P.append(f"<h2>{e(t['summary'])}</h2>" + "".join(f"<p>{e(p)}</p>" for p in f["paras"]))
210
+ P.append(f"<h2>{e(t['chronology'])}</h2><table><tr>" + "".join(f"<th>{e(c)}</th>" for c in t["cols"]) + "</tr>")
211
+ P += [f"<tr><td>{e(x)}</td><td>{e(y)}</td><td>{a(h)}</td></tr>" for x, y, h in f["rows"]]
212
+ P.append(f"</table><h2>{e(t['path'])}</h2>")
213
+ if f["deps"]:
214
+ for d in f["deps"]:
215
+ P.append(f"<div class='box'><p><b>{e(d['text'])}</b></p><p class='mono'>{e(d['via'])}</p>"
216
+ + "".join(f"<div>{a(h)}</div>" for h in d["links"] if h) + "</div>")
217
+ P.append(f"<h2>{e(t['requests'])}</h2><ol>"
218
+ + "".join(f"<li>{e(d['req_exchange'])}</li><li>{e(d['req_police'])}</li>" for d in f["deps"]) + "</ol>")
219
+ else:
220
+ P.append(f"<p>{e(t['none'])}</p>")
221
+ P.append(f"<h2>{e(t['method'])}</h2><p>{e(f['method'])}</p>")
222
+ P.append(f"<p style='margin-top:40px'>{e(t['sign'])}: <span class='blank'>&nbsp;</span></p></body></html>")
223
+ return "".join(P)
224
+
225
+
226
+ def write(doc: Dict, out_dir: Path, lang: str = "en") -> List[Path]:
227
+ out_dir.mkdir(parents=True, exist_ok=True)
228
+ stem = f"evidence_{doc['chain']}_{doc['check']['address'][:12]}_{lang}"
229
+ paths = [out_dir / f"{stem}.html", out_dir / f"{stem}.md", out_dir / f"{stem}.json"]
230
+ paths[0].write_text(render_html(doc, lang), encoding="utf-8")
231
+ paths[1].write_text(render_markdown(doc, lang), encoding="utf-8")
232
+ paths[2].write_text(json.dumps(doc, ensure_ascii=False, indent=1, default=str), encoding="utf-8")
233
+ return paths
@@ -644,6 +644,11 @@ async function loadCheck(a){
644
644
  }
645
645
  const left=(r.remaining_tokens||[]).map(x=>`${Number(x.amount).toLocaleString()} ${esc(x.symbol)}`)
646
646
  .concat(r.remaining_nfts?[`${r.remaining_nfts} NFT`]:[]);
647
+ const ev=l=>`/api/evidence-pack?address=${q(a)}&chain=${chain()}&lang=${l}`;
648
+ h+=`<div style="margin-top:14px">
649
+ <a class="btnlink" href="${ev('en')}">Evidence pack for the exchange (EN) ⬇</a>
650
+ <a class="btnlink" href="${ev('ru')}">Для полиции (RU) ⬇</a>
651
+ <div class="muted" style="margin-top:6px">Open it in a browser, print to PDF, fill in your name and contact.</div></div>`;
647
652
  h+=`<h3 style="margin-top:16px">What to do now</h3><ol>
648
653
  <li>This seed phrase is burned. Make a new wallet with a new seed phrase; never type it into a site, bot or "support" chat again.</li>
649
654
  ${left.length?`<li>Move what is still here to the new wallet right away: ${left.join(', ')}.</li>`:''}
@@ -200,6 +200,23 @@ def create_app() -> Flask:
200
200
  res["to_explorer"] = chains.explorer_url(res["to"], chain) if res.get("to") else None
201
201
  return jsonify(res)
202
202
 
203
+ @app.route("/api/evidence-pack")
204
+ def api_evidence_pack():
205
+ from crypttrace import evidence as ev
206
+ addr = request.args.get("address", "")
207
+ chain = request.args.get("chain", "eth")
208
+ lang = request.args.get("lang", "en")
209
+ bad = validate(addr, chain)
210
+ if bad or lang not in ev.T:
211
+ return jsonify({"error": bad or "lang must be en or ru"}), 400
212
+ try:
213
+ doc = ev.build(addr, chain)
214
+ except _KNOWN_ERRORS as e:
215
+ return jsonify({"error": str(e)}), 400
216
+ resp = app.response_class(ev.render_html(doc, lang), mimetype="text/html")
217
+ resp.headers["Content-Disposition"] = f"attachment; filename=evidence_{chain}_{addr[:12]}_{lang}.html"
218
+ return resp
219
+
203
220
  @app.route("/api/victims")
204
221
  def api_victims():
205
222
  """Addresses that fed this wallet — in a mass theft, the victim list."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.16.0
3
+ Version: 0.17.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
@@ -153,6 +153,22 @@ history and answers in plain language:
153
153
 
154
154
  The web UI has the same check in its **Was I drained?** tab.
155
155
 
156
+ ### An evidence pack for the exchange and the police
157
+
158
+ ```bash
159
+ crypttrace evidence YOUR_ADDRESS --chain ton --lang en # for the exchange
160
+ crypttrace evidence YOUR_ADDRESS --chain ton --lang ru # for a police report (Russian)
161
+ ```
162
+
163
+ Turns the check into a document to send: a summary of the theft, every
164
+ transfer out of the wallet with its explorer link, the path to the exchange
165
+ deposit and its memo with the hash of each hop, a precise request to the
166
+ exchange (freeze the account behind the memo, preserve KYC, login and
167
+ withdrawal records) and to the police (request them through the exchange's
168
+ law-enforcement channel), and how it was established — so anyone can check it.
169
+ Name, contact and signature are left blank to fill in. Saved as HTML (print to
170
+ PDF), Markdown and JSON; the **Was I drained?** tab has download buttons too.
171
+
156
172
  ## Web UI
157
173
 
158
174
  ```bash
@@ -11,6 +11,7 @@ src/crypttrace/chains.py
11
11
  src/crypttrace/cli.py
12
12
  src/crypttrace/config.py
13
13
  src/crypttrace/drain.py
14
+ src/crypttrace/evidence.py
14
15
  src/crypttrace/freeze.py
15
16
  src/crypttrace/funder.py
16
17
  src/crypttrace/investigate.py
File without changes
File without changes
File without changes