crypttrace 0.16.0__tar.gz → 0.18.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 (53) hide show
  1. {crypttrace-0.16.0/src/crypttrace.egg-info → crypttrace-0.18.0}/PKG-INFO +25 -1
  2. {crypttrace-0.16.0 → crypttrace-0.18.0}/README.md +24 -0
  3. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/cli.py +30 -0
  5. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/drain.py +3 -1
  6. crypttrace-0.18.0/src/crypttrace/evidence.py +233 -0
  7. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/labels/labels.py +7 -3
  8. crypttrace-0.18.0/src/crypttrace/labels/names.py +8 -0
  9. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/labels/tonapi.py +2 -7
  10. crypttrace-0.18.0/src/crypttrace/labels/tronscan.py +105 -0
  11. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/web/index.html +5 -0
  12. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/webapp.py +17 -0
  13. {crypttrace-0.16.0 → crypttrace-0.18.0/src/crypttrace.egg-info}/PKG-INFO +25 -1
  14. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace.egg-info/SOURCES.txt +3 -0
  15. {crypttrace-0.16.0 → crypttrace-0.18.0}/LICENSE +0 -0
  16. {crypttrace-0.16.0 → crypttrace-0.18.0}/pyproject.toml +0 -0
  17. {crypttrace-0.16.0 → crypttrace-0.18.0}/setup.cfg +0 -0
  18. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/addresses.py +0 -0
  19. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/analysis.py +0 -0
  20. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/assess.py +0 -0
  21. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/assets.py +0 -0
  22. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/bridges.py +0 -0
  23. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/chains.py +0 -0
  24. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/config.py +0 -0
  25. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/fetchers/__init__.py +0 -0
  26. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  27. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  28. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/fetchers/http.py +0 -0
  29. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/fetchers/solana.py +0 -0
  30. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/fetchers/ton.py +0 -0
  31. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/fetchers/tron.py +0 -0
  32. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/freeze.py +0 -0
  33. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/funder.py +0 -0
  34. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/investigate.py +0 -0
  35. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/labels/__init__.py +0 -0
  36. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/labels/audit.py +0 -0
  37. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/labels/bulk.py +0 -0
  38. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/labels/known.json +0 -0
  39. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/labels/partial.json +0 -0
  40. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/offramp.py +0 -0
  41. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/poisoning.py +0 -0
  42. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/prices.py +0 -0
  43. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/render.py +0 -0
  44. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/report.py +0 -0
  45. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/spam.py +0 -0
  46. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/store.py +0 -0
  47. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/trace.py +0 -0
  48. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/verify.py +0 -0
  49. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace/watch.py +0 -0
  50. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  51. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  52. {crypttrace-0.16.0 → crypttrace-0.18.0}/src/crypttrace.egg-info/requires.txt +0 -0
  53. {crypttrace-0.16.0 → crypttrace-0.18.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.18.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
@@ -104,6 +104,7 @@ export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
104
104
  export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana RPC
105
105
  export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
106
106
  export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
107
+ export TRONSCAN_API_KEY=xxxx # optional: Tron exchange and scam tags from Tronscan
107
108
  ```
108
109
 
109
110
  (In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
@@ -153,6 +154,22 @@ history and answers in plain language:
153
154
 
154
155
  The web UI has the same check in its **Was I drained?** tab.
155
156
 
157
+ ### An evidence pack for the exchange and the police
158
+
159
+ ```bash
160
+ crypttrace evidence YOUR_ADDRESS --chain ton --lang en # for the exchange
161
+ crypttrace evidence YOUR_ADDRESS --chain ton --lang ru # for a police report (Russian)
162
+ ```
163
+
164
+ Turns the check into a document to send: a summary of the theft, every
165
+ transfer out of the wallet with its explorer link, the path to the exchange
166
+ deposit and its memo with the hash of each hop, a precise request to the
167
+ exchange (freeze the account behind the memo, preserve KYC, login and
168
+ withdrawal records) and to the police (request them through the exchange's
169
+ law-enforcement channel), and how it was established — so anyone can check it.
170
+ Name, contact and signature are left blank to fill in. Saved as HTML (print to
171
+ PDF), Markdown and JSON; the **Was I drained?** tab has download buttons too.
172
+
156
173
  ## Web UI
157
174
 
158
175
  ```bash
@@ -521,6 +538,13 @@ accounts. These become labels of kind `explorer-tag`, cached for a week. A
521
538
  "kucoin-1.ton" — is not taken as a label. A trace stops at a DEX: the funds
522
539
  come back to the sender in another token.
523
540
 
541
+ **Tron: tags from Tronscan.** With a free Tronscan API key (`TRONSCAN_API_KEY`,
542
+ created in a Tronscan account), Tron addresses the curated set does not know
543
+ get Tronscan's tags: exchange wallets ("Binance-Cold 1", "Okex 1") become
544
+ exchange labels and a red tag (scam, phishing) makes the address a scam — both
545
+ as `explorer-tag` labels naming Tronscan as the source, cached for a week.
546
+ Without a key nothing is fetched.
547
+
524
548
  **Exchange deposits with a memo.** A TON exchange takes all customers' deposits
525
549
  into one hot wallet and tells them apart by the transfer comment, the *memo*.
526
550
  The memo is the lead: it names one account at the exchange, which holds that
@@ -70,6 +70,7 @@ export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
70
70
  export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana RPC
71
71
  export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
72
72
  export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
73
+ export TRONSCAN_API_KEY=xxxx # optional: Tron exchange and scam tags from Tronscan
73
74
  ```
74
75
 
75
76
  (In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
@@ -119,6 +120,22 @@ history and answers in plain language:
119
120
 
120
121
  The web UI has the same check in its **Was I drained?** tab.
121
122
 
123
+ ### An evidence pack for the exchange and the police
124
+
125
+ ```bash
126
+ crypttrace evidence YOUR_ADDRESS --chain ton --lang en # for the exchange
127
+ crypttrace evidence YOUR_ADDRESS --chain ton --lang ru # for a police report (Russian)
128
+ ```
129
+
130
+ Turns the check into a document to send: a summary of the theft, every
131
+ transfer out of the wallet with its explorer link, the path to the exchange
132
+ deposit and its memo with the hash of each hop, a precise request to the
133
+ exchange (freeze the account behind the memo, preserve KYC, login and
134
+ withdrawal records) and to the police (request them through the exchange's
135
+ law-enforcement channel), and how it was established — so anyone can check it.
136
+ Name, contact and signature are left blank to fill in. Saved as HTML (print to
137
+ PDF), Markdown and JSON; the **Was I drained?** tab has download buttons too.
138
+
122
139
  ## Web UI
123
140
 
124
141
  ```bash
@@ -487,6 +504,13 @@ accounts. These become labels of kind `explorer-tag`, cached for a week. A
487
504
  "kucoin-1.ton" — is not taken as a label. A trace stops at a DEX: the funds
488
505
  come back to the sender in another token.
489
506
 
507
+ **Tron: tags from Tronscan.** With a free Tronscan API key (`TRONSCAN_API_KEY`,
508
+ created in a Tronscan account), Tron addresses the curated set does not know
509
+ get Tronscan's tags: exchange wallets ("Binance-Cold 1", "Okex 1") become
510
+ exchange labels and a red tag (scam, phishing) makes the address a scam — both
511
+ as `explorer-tag` labels naming Tronscan as the source, cached for a week.
512
+ Without a key nothing is fetched.
513
+
490
514
  **Exchange deposits with a memo.** A TON exchange takes all customers' deposits
491
515
  into one hot wallet and tells them apart by the transfer comment, the *memo*.
492
516
  The memo is the lead: it names one account at the exchange, which holds that
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.16.0"
2
+ __version__ = "0.18.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
@@ -17,7 +17,7 @@ from typing import Optional, Dict, List
17
17
  import requests
18
18
 
19
19
  from crypttrace import addresses, config
20
- from crypttrace.labels import bulk, tonapi
20
+ from crypttrace.labels import bulk, tonapi, tronscan
21
21
 
22
22
  _HERE = Path(__file__).parent
23
23
  _IMPORTED = config.DATA_DIR / "imported_labels.json"
@@ -189,15 +189,19 @@ def lookup(address: str) -> Optional[dict]:
189
189
  found = _match_partial(address) or bulk.lookup(address)
190
190
  if not found and (len(address) == 48 or ":" in address):
191
191
  found = tonapi.lookup(address) # cached explorer tag, no network here
192
+ elif not found and len(address) == 34 and address.startswith("T"):
193
+ found = tronscan.lookup(address)
192
194
  return found
193
195
 
194
196
 
195
197
  def enrich(addrs, chain: str) -> None:
196
- """Fetch explorer tags the curated set lacks (TON: tonapi names). Network, cached."""
198
+ """Fetch explorer tags the curated set lacks (TON: tonapi, Tron: Tronscan). Network, cached."""
199
+ _load()
197
200
  if chain == "ton":
198
- _load()
199
201
  tonapi.enrich(a for a in addrs
200
202
  if a and not _KNOWN.get((addresses.ton_friendly(a) or a).lower()))
203
+ elif chain == "tron":
204
+ tronscan.enrich(a for a in addrs if a and not _KNOWN.get(a.lower()))
201
205
 
202
206
 
203
207
  def is_deposit(address: str) -> bool:
@@ -0,0 +1,8 @@
1
+ """Exchange names as explorers spell them — shared by the explorer-tag sources."""
2
+ import re
3
+
4
+ # custodial platforms that hold KYC and can freeze: a deposit there is a lead
5
+ EXCHANGES = re.compile(
6
+ r"\b(binance|bybit|okx|okex|kucoin|htx|huobi|gate(\.io)?|mexc|bitget|kraken|coinbase|"
7
+ r"bitfinex|bingx|bitmart|lbank|whitebit|exmo|coinex|poloniex|crypto\.com|bitpanda|"
8
+ r"bithumb|upbit|cryptobot|crypto bot|xrocket|wallet in telegram|telegram wallet)\b", re.I)
@@ -26,6 +26,7 @@ import requests
26
26
 
27
27
  from crypttrace import addresses, config
28
28
  from crypttrace.fetchers import http
29
+ from crypttrace.labels.names import EXCHANGES
29
30
 
30
31
  API = "https://tonapi.io/v2/accounts/{}"
31
32
  CACHE = config.DATA_DIR / "tonapi_names.json"
@@ -33,12 +34,6 @@ MAX_AGE = 7 * 24 * 3600
33
34
  _KEY = os.environ.get("TONAPI_KEY")
34
35
  http.MIN_INTERVAL["tonapi.io"] = 0.12 if _KEY else 1.1
35
36
 
36
- # custodial platforms that hold KYC and can freeze: a deposit there is a lead
37
- _EXCHANGES = re.compile(
38
- r"\b(binance|bybit|okx|okex|kucoin|htx|huobi|gate(\.io)?|mexc|bitget|kraken|coinbase|"
39
- r"bitfinex|bingx|bitmart|lbank|whitebit|exmo|coinex|poloniex|crypto\.com|bitpanda|"
40
- r"bithumb|upbit|cryptobot|crypto bot|xrocket|wallet in telegram|telegram wallet)\b", re.I)
41
-
42
37
  _cache: Optional[Dict[str, dict]] = None
43
38
 
44
39
 
@@ -73,7 +68,7 @@ def classify(account: dict) -> Optional[dict]:
73
68
  return {"name": name or "DEX contract", "type": "dex"}
74
69
  if not name or self_named:
75
70
  return None
76
- if _EXCHANGES.search(name):
71
+ if EXCHANGES.search(name):
77
72
  memo = " (memo required)" if account.get("memo_required") else ""
78
73
  return {"name": f"{name}{memo}", "type": "exchange", "memo_required": bool(account.get("memo_required"))}
79
74
  return {"name": name, "type": "service"}
@@ -0,0 +1,105 @@
1
+ """Tron address tags from Tronscan — exchange wallets and risk warnings.
2
+
3
+ Tronscan tags exchange hot and cold wallets ("Binance-Cold 1", "OKX-Hot") and
4
+ puts red tags on addresses it flags as scams or phishing. Both become labels
5
+ of kind "explorer-tag", with Tronscan named as the source; a red tag makes the
6
+ address a scam. Grey tags and user feedback are not enough to call an address
7
+ anything.
8
+
9
+ Needs a free API key (TRONSCAN_API_KEY, created in a Tronscan account); without
10
+ one nothing is fetched and Tron labels come from the curated set alone.
11
+ Lookups are cached on disk for a week; only `enrich()` uses the network.
12
+ """
13
+ import datetime as dt
14
+ import json
15
+ import os
16
+ import sys
17
+ import time
18
+ from typing import Dict, Iterable, Optional
19
+
20
+ import requests
21
+
22
+ from crypttrace import config
23
+ from crypttrace.fetchers import http
24
+ from crypttrace.labels.names import EXCHANGES
25
+
26
+ API = "https://apilist.tronscanapi.com/api/accountv2"
27
+ CACHE = config.DATA_DIR / "tronscan_tags.json"
28
+ MAX_AGE = 7 * 24 * 3600
29
+ http.MIN_INTERVAL["apilist.tronscanapi.com"] = 0.25
30
+ _cache: Optional[Dict[str, dict]] = None
31
+
32
+
33
+ def _key() -> Optional[str]:
34
+ k = os.environ.get("TRONSCAN_API_KEY")
35
+ if not k and sys.platform == "win32": # set with [Environment]::SetEnvironmentVariable(..., "User")
36
+ try:
37
+ import winreg
38
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as reg:
39
+ k = winreg.QueryValueEx(reg, "TRONSCAN_API_KEY")[0]
40
+ except OSError:
41
+ k = None
42
+ return (k or "").strip() or None
43
+
44
+
45
+ def _load() -> Dict[str, dict]:
46
+ global _cache
47
+ if _cache is None:
48
+ try:
49
+ _cache = json.loads(CACHE.read_text(encoding="utf-8"))
50
+ except (OSError, ValueError):
51
+ _cache = {}
52
+ return _cache
53
+
54
+
55
+ def _save() -> None:
56
+ try:
57
+ CACHE.parent.mkdir(parents=True, exist_ok=True)
58
+ tmp = CACHE.with_suffix(".tmp")
59
+ tmp.write_text(json.dumps(_load(), ensure_ascii=False), encoding="utf-8")
60
+ tmp.replace(CACHE)
61
+ except OSError:
62
+ pass
63
+
64
+
65
+ def classify(account: dict) -> Optional[dict]:
66
+ """A label from one Tronscan account record, or None if it names nothing."""
67
+ red = (account.get("redTag") or "").strip()
68
+ tag = (account.get("publicTag") or account.get("addressTag") or "").strip()
69
+ if red:
70
+ return {"name": f"{tag or red} (Tronscan: {red})", "type": "scam"}
71
+ if not tag:
72
+ return None
73
+ if EXCHANGES.search(tag.replace("-", " ")):
74
+ return {"name": tag, "type": "exchange"}
75
+ return {"name": tag, "type": "service"}
76
+
77
+
78
+ def lookup(address: str) -> Optional[dict]:
79
+ """Cached Tronscan label, without touching the network."""
80
+ entry = _load().get(address)
81
+ if not entry or not entry.get("label"):
82
+ return None
83
+ return {**entry["label"], "chain": "tron", "source_kind": "explorer-tag",
84
+ "source": f"Tronscan address tag, fetched {entry.get('day', '')}"}
85
+
86
+
87
+ def enrich(addrs: Iterable[str]) -> None:
88
+ """Fetch Tronscan tags for addresses not in the cache (or stale). No key, no fetch."""
89
+ key = _key()
90
+ if not key:
91
+ return
92
+ cache, now, changed = _load(), time.time(), False
93
+ for a in dict.fromkeys(addrs):
94
+ if not a or not a.startswith("T") or len(a) != 34 or now - cache.get(a, {}).get("at", 0) < MAX_AGE:
95
+ continue
96
+ try:
97
+ rec = http.request_json(API, {"address": a}, timeout=20, headers={"TRON-PRO-API-KEY": key})
98
+ except (http.RateLimited, requests.RequestException, ValueError):
99
+ continue
100
+ if not isinstance(rec, dict) or "__status__" in rec:
101
+ continue
102
+ cache[a] = {"at": now, "day": dt.date.today().isoformat(), "label": classify(rec)}
103
+ changed = True
104
+ if changed:
105
+ _save()
@@ -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.18.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
@@ -104,6 +104,7 @@ export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
104
104
  export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana RPC
105
105
  export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
106
106
  export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
107
+ export TRONSCAN_API_KEY=xxxx # optional: Tron exchange and scam tags from Tronscan
107
108
  ```
108
109
 
109
110
  (In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
@@ -153,6 +154,22 @@ history and answers in plain language:
153
154
 
154
155
  The web UI has the same check in its **Was I drained?** tab.
155
156
 
157
+ ### An evidence pack for the exchange and the police
158
+
159
+ ```bash
160
+ crypttrace evidence YOUR_ADDRESS --chain ton --lang en # for the exchange
161
+ crypttrace evidence YOUR_ADDRESS --chain ton --lang ru # for a police report (Russian)
162
+ ```
163
+
164
+ Turns the check into a document to send: a summary of the theft, every
165
+ transfer out of the wallet with its explorer link, the path to the exchange
166
+ deposit and its memo with the hash of each hop, a precise request to the
167
+ exchange (freeze the account behind the memo, preserve KYC, login and
168
+ withdrawal records) and to the police (request them through the exchange's
169
+ law-enforcement channel), and how it was established — so anyone can check it.
170
+ Name, contact and signature are left blank to fill in. Saved as HTML (print to
171
+ PDF), Markdown and JSON; the **Was I drained?** tab has download buttons too.
172
+
156
173
  ## Web UI
157
174
 
158
175
  ```bash
@@ -521,6 +538,13 @@ accounts. These become labels of kind `explorer-tag`, cached for a week. A
521
538
  "kucoin-1.ton" — is not taken as a label. A trace stops at a DEX: the funds
522
539
  come back to the sender in another token.
523
540
 
541
+ **Tron: tags from Tronscan.** With a free Tronscan API key (`TRONSCAN_API_KEY`,
542
+ created in a Tronscan account), Tron addresses the curated set does not know
543
+ get Tronscan's tags: exchange wallets ("Binance-Cold 1", "Okex 1") become
544
+ exchange labels and a red tag (scam, phishing) makes the address a scam — both
545
+ as `explorer-tag` labels naming Tronscan as the source, cached for a week.
546
+ Without a key nothing is fetched.
547
+
524
548
  **Exchange deposits with a memo.** A TON exchange takes all customers' deposits
525
549
  into one hot wallet and tells them apart by the transfer comment, the *memo*.
526
550
  The memo is the lead: it names one account at the exchange, which holds that
@@ -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
@@ -43,6 +44,8 @@ src/crypttrace/labels/audit.py
43
44
  src/crypttrace/labels/bulk.py
44
45
  src/crypttrace/labels/known.json
45
46
  src/crypttrace/labels/labels.py
47
+ src/crypttrace/labels/names.py
46
48
  src/crypttrace/labels/partial.json
47
49
  src/crypttrace/labels/tonapi.py
50
+ src/crypttrace/labels/tronscan.py
48
51
  src/crypttrace/web/index.html
File without changes
File without changes
File without changes