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.
- {crypttrace-0.16.0/src/crypttrace.egg-info → crypttrace-0.17.0}/PKG-INFO +17 -1
- {crypttrace-0.16.0 → crypttrace-0.17.0}/README.md +16 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/cli.py +30 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/drain.py +3 -1
- crypttrace-0.17.0/src/crypttrace/evidence.py +233 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/web/index.html +5 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/webapp.py +17 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0/src/crypttrace.egg-info}/PKG-INFO +17 -1
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/LICENSE +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/pyproject.toml +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/setup.cfg +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/chains.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/solana.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/ton.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/fetchers/tron.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/freeze.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/investigate.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/known.json +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/labels.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/labels/tonapi.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/poisoning.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/render.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/spam.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/store.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace/watch.py +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.16.0 → crypttrace-0.17.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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> </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'> </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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|