crypttrace 0.2.0__tar.gz → 0.4.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.2.0/src/crypttrace.egg-info → crypttrace-0.4.0}/PKG-INFO +31 -8
- {crypttrace-0.2.0 → crypttrace-0.4.0}/README.md +30 -7
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/cli.py +37 -6
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/investigate.py +2 -11
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/labels/audit.py +3 -0
- crypttrace-0.4.0/src/crypttrace/labels/bulk.py +187 -0
- crypttrace-0.4.0/src/crypttrace/labels/known.json +284 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/labels/labels.py +18 -1
- crypttrace-0.4.0/src/crypttrace/offramp.py +76 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/trace.py +5 -4
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/watch.py +2 -2
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/webapp.py +1 -2
- {crypttrace-0.2.0 → crypttrace-0.4.0/src/crypttrace.egg-info}/PKG-INFO +31 -8
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
- crypttrace-0.2.0/src/crypttrace/labels/known.json +0 -148
- crypttrace-0.2.0/src/crypttrace/offramp.py +0 -58
- {crypttrace-0.2.0 → crypttrace-0.4.0}/LICENSE +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/pyproject.toml +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/setup.cfg +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/chains.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/fetchers/solana.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/fetchers/tron.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/render.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/store.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/web/index.html +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.2.0 → crypttrace-0.4.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.4.0
|
|
4
4
|
Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron and Solana
|
|
5
5
|
Author: bobslayerX
|
|
6
6
|
License-Expression: MIT
|
|
@@ -216,6 +216,9 @@ crypttrace report 0xADDRESS --depth 3
|
|
|
216
216
|
# Refresh label lists (OFAC sanctions, …)
|
|
217
217
|
crypttrace update-labels
|
|
218
218
|
|
|
219
|
+
# …and add OKX's ~300k signed deposit addresses (downloads ~80 MB once)
|
|
220
|
+
crypttrace update-labels --okx
|
|
221
|
+
|
|
219
222
|
# Launch the web UI / list chains
|
|
220
223
|
crypttrace serve
|
|
221
224
|
crypttrace chains
|
|
@@ -273,7 +276,9 @@ behaviour: an address forwarding most of its outgoing value to a labelled
|
|
|
273
276
|
exchange is almost certainly a deposit address, i.e. the cash-out point where
|
|
274
277
|
that exchange holds the depositor's KYC. `trace` applies the same heuristic
|
|
275
278
|
automatically, turning an anonymous intermediary into "→ Binance deposit (KYC
|
|
276
|
-
point)".
|
|
279
|
+
point)". It works on every supported chain; on Tron and Solana, where deposits
|
|
280
|
+
are usually swept as stablecoins, it checks USDT and USDC as well as the native
|
|
281
|
+
coin.
|
|
277
282
|
|
|
278
283
|
### First-funder (deanonymization)
|
|
279
284
|
|
|
@@ -371,11 +376,22 @@ SDN sanctioned-address list — and merges them into the local DB in
|
|
|
371
376
|
`~/.crypttrace/`. The curated seed wins on conflicts, so richer names survive.
|
|
372
377
|
Add sources in `labels/labels.py` → `SOURCES`.
|
|
373
378
|
|
|
374
|
-
Bitcoin, Tron and Solana
|
|
375
|
-
|
|
376
|
-
proof-of-reserves.
|
|
377
|
-
|
|
378
|
-
Every label records where it came from — see
|
|
379
|
+
On Bitcoin, Tron and Solana the labels cover the reserve wallets of Binance,
|
|
380
|
+
OKX and HTX, plus Bybit's older 2022 list — each taken from the address list the
|
|
381
|
+
exchange publishes itself for proof-of-reserves. That is what lets `offramp`
|
|
382
|
+
flag deposit addresses on those chains. Funds sent to an exchange not on that
|
|
383
|
+
list still show as `unknown`. Every label records where it came from — see
|
|
384
|
+
`crypttrace labels audit`.
|
|
385
|
+
|
|
386
|
+
**Deposit addresses, labelled outright.** OKX signs every address that holds
|
|
387
|
+
customer funds — over 300,000, nearly all of them per-customer deposit addresses
|
|
388
|
+
— and publishes the list with its proof of reserves. `crypttrace update-labels
|
|
389
|
+
--okx` downloads the latest one into `~/.crypttrace/` (about 18 MB once
|
|
390
|
+
imported). With it, funds landing on an OKX deposit address are identified the
|
|
391
|
+
moment they arrive, before OKX sweeps them onward — which is when a freeze
|
|
392
|
+
request still has something to freeze. Without it, `offramp` only recognises a
|
|
393
|
+
deposit address after it has forwarded the money. Rows without OKX's signature
|
|
394
|
+
are ignored, and every address is checked before it is stored.
|
|
379
395
|
|
|
380
396
|
### Reports
|
|
381
397
|
|
|
@@ -478,7 +494,14 @@ cases/ # worked investigations with their data
|
|
|
478
494
|
|
|
479
495
|
## Roadmap
|
|
480
496
|
|
|
481
|
-
-
|
|
497
|
+
- More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
|
|
498
|
+
and refreshing these lists as the exchanges republish them
|
|
499
|
+
- Address-poisoning detection: flag look-alike addresses (same first and last
|
|
500
|
+
characters) in a victim's history before they copy the wrong one
|
|
501
|
+
- Stablecoin freeze check: whether USDT/USDC at an address is already frozen
|
|
502
|
+
by the issuer, and who to ask for a freeze
|
|
503
|
+
- `report --html`: one self-contained file with the interactive graph, to
|
|
504
|
+
send to an exchange or attach to a police report
|
|
482
505
|
- `report --pdf` for exchange and law-enforcement filings
|
|
483
506
|
- Internal transactions (completes `funder` and contract-mediated transfers)
|
|
484
507
|
- Per-mint filtering for Solana SPL tokens
|
|
@@ -182,6 +182,9 @@ crypttrace report 0xADDRESS --depth 3
|
|
|
182
182
|
# Refresh label lists (OFAC sanctions, …)
|
|
183
183
|
crypttrace update-labels
|
|
184
184
|
|
|
185
|
+
# …and add OKX's ~300k signed deposit addresses (downloads ~80 MB once)
|
|
186
|
+
crypttrace update-labels --okx
|
|
187
|
+
|
|
185
188
|
# Launch the web UI / list chains
|
|
186
189
|
crypttrace serve
|
|
187
190
|
crypttrace chains
|
|
@@ -239,7 +242,9 @@ behaviour: an address forwarding most of its outgoing value to a labelled
|
|
|
239
242
|
exchange is almost certainly a deposit address, i.e. the cash-out point where
|
|
240
243
|
that exchange holds the depositor's KYC. `trace` applies the same heuristic
|
|
241
244
|
automatically, turning an anonymous intermediary into "→ Binance deposit (KYC
|
|
242
|
-
point)".
|
|
245
|
+
point)". It works on every supported chain; on Tron and Solana, where deposits
|
|
246
|
+
are usually swept as stablecoins, it checks USDT and USDC as well as the native
|
|
247
|
+
coin.
|
|
243
248
|
|
|
244
249
|
### First-funder (deanonymization)
|
|
245
250
|
|
|
@@ -337,11 +342,22 @@ SDN sanctioned-address list — and merges them into the local DB in
|
|
|
337
342
|
`~/.crypttrace/`. The curated seed wins on conflicts, so richer names survive.
|
|
338
343
|
Add sources in `labels/labels.py` → `SOURCES`.
|
|
339
344
|
|
|
340
|
-
Bitcoin, Tron and Solana
|
|
341
|
-
|
|
342
|
-
proof-of-reserves.
|
|
343
|
-
|
|
344
|
-
Every label records where it came from — see
|
|
345
|
+
On Bitcoin, Tron and Solana the labels cover the reserve wallets of Binance,
|
|
346
|
+
OKX and HTX, plus Bybit's older 2022 list — each taken from the address list the
|
|
347
|
+
exchange publishes itself for proof-of-reserves. That is what lets `offramp`
|
|
348
|
+
flag deposit addresses on those chains. Funds sent to an exchange not on that
|
|
349
|
+
list still show as `unknown`. Every label records where it came from — see
|
|
350
|
+
`crypttrace labels audit`.
|
|
351
|
+
|
|
352
|
+
**Deposit addresses, labelled outright.** OKX signs every address that holds
|
|
353
|
+
customer funds — over 300,000, nearly all of them per-customer deposit addresses
|
|
354
|
+
— and publishes the list with its proof of reserves. `crypttrace update-labels
|
|
355
|
+
--okx` downloads the latest one into `~/.crypttrace/` (about 18 MB once
|
|
356
|
+
imported). With it, funds landing on an OKX deposit address are identified the
|
|
357
|
+
moment they arrive, before OKX sweeps them onward — which is when a freeze
|
|
358
|
+
request still has something to freeze. Without it, `offramp` only recognises a
|
|
359
|
+
deposit address after it has forwarded the money. Rows without OKX's signature
|
|
360
|
+
are ignored, and every address is checked before it is stored.
|
|
345
361
|
|
|
346
362
|
### Reports
|
|
347
363
|
|
|
@@ -444,7 +460,14 @@ cases/ # worked investigations with their data
|
|
|
444
460
|
|
|
445
461
|
## Roadmap
|
|
446
462
|
|
|
447
|
-
-
|
|
463
|
+
- More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
|
|
464
|
+
and refreshing these lists as the exchanges republish them
|
|
465
|
+
- Address-poisoning detection: flag look-alike addresses (same first and last
|
|
466
|
+
characters) in a victim's history before they copy the wrong one
|
|
467
|
+
- Stablecoin freeze check: whether USDT/USDC at an address is already frozen
|
|
468
|
+
by the issuer, and who to ask for a freeze
|
|
469
|
+
- `report --html`: one self-contained file with the interactive graph, to
|
|
470
|
+
send to an exchange or attach to a police report
|
|
448
471
|
- `report --pdf` for exchange and law-enforcement filings
|
|
449
472
|
- Internal transactions (completes `funder` and contract-mediated transfers)
|
|
450
473
|
- Per-mint filtering for Solana SPL tokens
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.4.0"
|
|
@@ -5,10 +5,12 @@ known entities (exchanges, mixers, sanctioned wallets), and traces where the
|
|
|
5
5
|
funds went.
|
|
6
6
|
"""
|
|
7
7
|
import sys
|
|
8
|
+
import zipfile
|
|
8
9
|
from datetime import datetime
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import Optional
|
|
11
12
|
|
|
13
|
+
import requests
|
|
12
14
|
import typer
|
|
13
15
|
from rich.console import Console
|
|
14
16
|
|
|
@@ -262,21 +264,21 @@ def crosschain(
|
|
|
262
264
|
|
|
263
265
|
@app.command()
|
|
264
266
|
def offramp(
|
|
265
|
-
address: str = typer.Argument(..., help="Address to check
|
|
267
|
+
address: str = typer.Argument(..., help="Address to check"),
|
|
266
268
|
chain: str = CHAIN_OPT,
|
|
267
269
|
):
|
|
268
270
|
"""Check whether an address is an exchange deposit address (cash-out / off-ramp)."""
|
|
269
271
|
try:
|
|
270
272
|
hit = offramp_mod.detect(address, chain)
|
|
271
|
-
except etherscan.EtherscanError as e:
|
|
273
|
+
except (chains_mod.ChainError, etherscan.EtherscanError) as e:
|
|
272
274
|
console.print(f"[red]Error:[/red] {e}")
|
|
273
275
|
raise typer.Exit(1)
|
|
274
276
|
if hit:
|
|
275
277
|
pct = int(hit["fraction"] * 100)
|
|
276
278
|
console.print(
|
|
277
279
|
f"[green]➜ Likely off-ramp:[/green] this address forwarded ~{pct}% of outgoing "
|
|
278
|
-
f"funds ({hit['forwarded']:.4f}) to [bold]{hit['exchange']}[/bold].\n"
|
|
279
|
-
f" It is probably a {hit['
|
|
280
|
+
f"funds ({hit['forwarded']:.4f} {hit['symbol']}) to [bold]{hit['exchange']}[/bold].\n"
|
|
281
|
+
f" It is probably a deposit address at {hit['company']} — a KYC identification point."
|
|
280
282
|
)
|
|
281
283
|
else:
|
|
282
284
|
console.print("[dim]No exchange-forwarding pattern detected. "
|
|
@@ -352,6 +354,17 @@ def labels_audit():
|
|
|
352
354
|
for u in a["unsourced"][:8]:
|
|
353
355
|
console.print(f" [dim]{u['name']} ({u['type']})[/dim]")
|
|
354
356
|
|
|
357
|
+
from crypttrace.labels import bulk
|
|
358
|
+
downloaded = bulk.sets()
|
|
359
|
+
if downloaded:
|
|
360
|
+
console.print("\n [bold]downloaded exchange address sets[/bold] (self-published, "
|
|
361
|
+
"checked on import)")
|
|
362
|
+
for s in downloaded.values():
|
|
363
|
+
console.print(f" {s['name']:<34} {s['count']:>8} snapshot {s['snapshot']}")
|
|
364
|
+
else:
|
|
365
|
+
console.print("\n [dim]No exchange address sets downloaded. "
|
|
366
|
+
"`crypttrace update-labels --okx` adds OKX's ~300k deposit addresses.[/dim]")
|
|
367
|
+
|
|
355
368
|
|
|
356
369
|
@labels_app.command("why")
|
|
357
370
|
def labels_why(
|
|
@@ -393,15 +406,33 @@ def labels_check(
|
|
|
393
406
|
|
|
394
407
|
|
|
395
408
|
@app.command(name="update-labels")
|
|
396
|
-
def update_labels(
|
|
409
|
+
def update_labels(
|
|
410
|
+
okx: bool = typer.Option(False, "--okx",
|
|
411
|
+
help="Also download OKX's signed address list (~80 MB): "
|
|
412
|
+
"labels its ~300k deposit addresses directly"),
|
|
413
|
+
okx_file: Optional[Path] = typer.Option(None, "--okx-file",
|
|
414
|
+
help="Import an OKX proof-of-reserves zip you "
|
|
415
|
+
"already downloaded, instead of fetching it"),
|
|
416
|
+
):
|
|
397
417
|
"""Download the latest label lists (OFAC sanctions, etc.) into the local DB."""
|
|
418
|
+
from crypttrace.labels import bulk
|
|
398
419
|
console.print("Updating label database…")
|
|
399
420
|
for name, cnt, err in labels.update():
|
|
400
421
|
if err:
|
|
401
422
|
console.print(f" [red]✗[/red] {name}: {err}")
|
|
402
423
|
else:
|
|
403
424
|
console.print(f" [green]✓[/green] {name}: [bold]{cnt}[/bold] addresses")
|
|
404
|
-
|
|
425
|
+
if okx or okx_file:
|
|
426
|
+
try:
|
|
427
|
+
n, snap = bulk.import_okx(str(okx_file) if okx_file else None,
|
|
428
|
+
progress=lambda m: console.print(f" [dim]OKX: {m}[/dim]"))
|
|
429
|
+
console.print(f" [green]✓[/green] OKX signed addresses (snapshot {snap}): "
|
|
430
|
+
f"[bold]{n}[/bold] addresses")
|
|
431
|
+
except (requests.RequestException, ValueError, OSError, zipfile.BadZipFile) as e:
|
|
432
|
+
console.print(f" [red]✗[/red] OKX signed addresses: {e}")
|
|
433
|
+
console.print(f"[green]Done.[/green] {labels.count()} curated and imported labels loaded"
|
|
434
|
+
+ (f", plus {sum(s['count'] for s in bulk.sets().values())} exchange addresses"
|
|
435
|
+
if bulk.sets() else "") + ".")
|
|
405
436
|
console.print(f"[dim]Cache: {config.DATA_DIR / 'imported_labels.json'}[/dim]")
|
|
406
437
|
|
|
407
438
|
|
|
@@ -74,7 +74,7 @@ def analyse(address: str, chain: str = "eth", asset: Optional[dict] = None,
|
|
|
74
74
|
result["funding"] = []
|
|
75
75
|
|
|
76
76
|
try:
|
|
77
|
-
result["offramp"] = offramp_mod.detect(address, chain)
|
|
77
|
+
result["offramp"] = offramp_mod.detect(address, chain)
|
|
78
78
|
except Exception:
|
|
79
79
|
result["offramp"] = None
|
|
80
80
|
|
|
@@ -82,20 +82,11 @@ def analyse(address: str, chain: str = "eth", asset: Optional[dict] = None,
|
|
|
82
82
|
return result
|
|
83
83
|
|
|
84
84
|
|
|
85
|
-
def _service_name(label: str) -> str:
|
|
86
|
-
"""'Binance 14 (hot wallet)' -> 'Binance' — victims need the company, not our label."""
|
|
87
|
-
import re
|
|
88
|
-
name = re.sub(r"\(.*?\)", "", label) # drop parentheticals
|
|
89
|
-
name = name.split(" deposit")[0].split(":")[0]
|
|
90
|
-
name = re.sub(r"\s+\d+\s*$", "", name.strip()) # drop trailing wallet numbers
|
|
91
|
-
return name.strip() or label
|
|
92
|
-
|
|
93
|
-
|
|
94
85
|
def build_guidance(r: dict) -> dict:
|
|
95
86
|
"""Turn findings into plain-language next steps."""
|
|
96
87
|
steps: List[dict] = []
|
|
97
88
|
exchanges = r["exchanges"]
|
|
98
|
-
ex_names = sorted({
|
|
89
|
+
ex_names = sorted({labels.company(f["label"]) for f in exchanges}) if exchanges else []
|
|
99
90
|
|
|
100
91
|
if exchanges:
|
|
101
92
|
headline = ("Good news, relatively speaking: the trail reaches a "
|
|
@@ -90,6 +90,9 @@ def evidence(address: str) -> Dict:
|
|
|
90
90
|
# Tron/Solana keys keep their case (it carries the checksum), but callers
|
|
91
91
|
# often pass addresses already lower-cased by the label layer.
|
|
92
92
|
meta = raw.get(address) or {k.lower(): v for k, v in raw.items()}.get(address.lower())
|
|
93
|
+
if not meta:
|
|
94
|
+
from crypttrace.labels import bulk # large downloaded sets (OKX, …)
|
|
95
|
+
meta = bulk.lookup(address)
|
|
93
96
|
if not meta:
|
|
94
97
|
return {"address": address, "known": False}
|
|
95
98
|
return {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Large address sets published by exchanges themselves, kept in SQLite.
|
|
2
|
+
|
|
3
|
+
OKX signs every address that holds customer funds for its proof of reserves —
|
|
4
|
+
over 300,000 of them, most of them per-customer *deposit* addresses. A deposit
|
|
5
|
+
address is the cash-out point: funds that land there are with an exchange that
|
|
6
|
+
knows the depositor. Labelling them outright catches a deposit the moment funds
|
|
7
|
+
arrive, before the exchange sweeps them onward — the window in which a freeze
|
|
8
|
+
request can still work. The behavioural off-ramp heuristic only sees the sweep.
|
|
9
|
+
|
|
10
|
+
That is far too many to ship in known.json or to load on every run, so the set
|
|
11
|
+
is downloaded on request (`crypttrace update-labels --okx`) into a SQLite file
|
|
12
|
+
and looked up one address at a time. Curated labels in known.json always win.
|
|
13
|
+
"""
|
|
14
|
+
import csv
|
|
15
|
+
import io
|
|
16
|
+
import re
|
|
17
|
+
import sqlite3
|
|
18
|
+
import time
|
|
19
|
+
import zipfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Callable, Dict, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
from crypttrace import addresses, config
|
|
26
|
+
|
|
27
|
+
DB_PATH: Path = config.DATA_DIR / "bulk_labels.sqlite"
|
|
28
|
+
|
|
29
|
+
OKX_PAGE = "https://www.okx.com/proof-of-reserves/download"
|
|
30
|
+
_OKX_FILE = re.compile(r"https://static\.okx\.com/cdn/okx/por/chain/por_csv_(\d{10})_V(\d+)\.zip")
|
|
31
|
+
|
|
32
|
+
# OKX network name -> crypttrace chain (only the chains crypttrace can trace)
|
|
33
|
+
OKX_NETWORKS = {"BTC": "btc", "TRON": "tron", "SOL": "sol", "ETH": "eth", "BSC": "bsc",
|
|
34
|
+
"POLYGON": "polygon", "ARBITRUM": "arbitrum", "OPTIMISM": "optimism",
|
|
35
|
+
"BASE": "base"}
|
|
36
|
+
|
|
37
|
+
_conn: Optional[sqlite3.Connection] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _db() -> Optional[sqlite3.Connection]:
|
|
41
|
+
"""Open the store lazily; None when no set has been downloaded."""
|
|
42
|
+
global _conn
|
|
43
|
+
if _conn is None:
|
|
44
|
+
if not DB_PATH.exists():
|
|
45
|
+
return None
|
|
46
|
+
_conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
|
47
|
+
return _conn
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _create(c: sqlite3.Connection) -> None:
|
|
51
|
+
c.execute("CREATE TABLE IF NOT EXISTS labels (key TEXT PRIMARY KEY, chain TEXT, "
|
|
52
|
+
"set_name TEXT) WITHOUT ROWID")
|
|
53
|
+
c.execute("CREATE TABLE IF NOT EXISTS sets (set_name TEXT PRIMARY KEY, name TEXT, "
|
|
54
|
+
"type TEXT, role TEXT, source TEXT, source_kind TEXT, snapshot TEXT, "
|
|
55
|
+
"url TEXT, count INTEGER, imported_at INTEGER)")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def lookup(address: str) -> Optional[dict]:
|
|
59
|
+
c = _db()
|
|
60
|
+
if c is None:
|
|
61
|
+
return None
|
|
62
|
+
row = c.execute("SELECT s.name, s.type, s.role, s.source, s.source_kind, s.snapshot, l.chain "
|
|
63
|
+
"FROM labels l JOIN sets s USING (set_name) WHERE l.key = ?",
|
|
64
|
+
(address.strip().lower(),)).fetchone()
|
|
65
|
+
if not row:
|
|
66
|
+
return None
|
|
67
|
+
name, typ, role, source, kind, snapshot, chain = row
|
|
68
|
+
return {"name": name, "type": typ, "role": role, "source": source,
|
|
69
|
+
"source_kind": kind, "added": snapshot, "chain": chain}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sets() -> Dict[str, dict]:
|
|
73
|
+
"""What has been downloaded, for `labels audit`."""
|
|
74
|
+
c = _db()
|
|
75
|
+
if c is None:
|
|
76
|
+
return {}
|
|
77
|
+
cols = ["set_name", "name", "snapshot", "url", "count", "imported_at"]
|
|
78
|
+
return {r[0]: dict(zip(cols, r)) for r in
|
|
79
|
+
c.execute(f"SELECT {', '.join(cols)} FROM sets").fetchall()}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def latest_okx_url(timeout: int = 30) -> str:
|
|
83
|
+
"""The newest address file linked from OKX's proof-of-reserves download page."""
|
|
84
|
+
html = requests.get(OKX_PAGE, timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}).text
|
|
85
|
+
found = {(m.group(1), int(m.group(2))): m.group(0) for m in _OKX_FILE.finditer(html)}
|
|
86
|
+
if not found:
|
|
87
|
+
raise ValueError("no address file found on OKX's proof-of-reserves page")
|
|
88
|
+
return found[max(found)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _okx_csv_name(zip_path: Path) -> str:
|
|
92
|
+
with zipfile.ZipFile(zip_path) as z:
|
|
93
|
+
return next(n for n in z.namelist() if n.lower().endswith(".csv"))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _okx_rows(zip_path: Path):
|
|
97
|
+
"""Yield (network, address) from OKX's file: a totals table, a blank line,
|
|
98
|
+
then one row per address with its network and ownership signature."""
|
|
99
|
+
with zipfile.ZipFile(zip_path) as z:
|
|
100
|
+
with z.open(_okx_csv_name(zip_path)) as raw:
|
|
101
|
+
f = io.TextIOWrapper(raw, encoding="utf-8-sig", newline="")
|
|
102
|
+
for line in f:
|
|
103
|
+
if line.lower().startswith("coin,type,network"):
|
|
104
|
+
header = next(csv.reader([line]))
|
|
105
|
+
break
|
|
106
|
+
else:
|
|
107
|
+
raise ValueError("unexpected OKX file layout: no address table")
|
|
108
|
+
idx = {h.strip().lower(): i for i, h in enumerate(header)}
|
|
109
|
+
n_i, a_i, s_i = idx["network"], idx["address"], idx["signature1"]
|
|
110
|
+
for row in csv.reader(f):
|
|
111
|
+
if len(row) > max(n_i, a_i, s_i) and row[s_i].strip(): # signed only
|
|
112
|
+
yield row[n_i].strip().upper(), row[a_i].strip()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def import_okx(source: Optional[str] = None,
|
|
116
|
+
progress: Optional[Callable[[str], None]] = None) -> Tuple[int, str]:
|
|
117
|
+
"""Download (or read a local copy of) OKX's signed address file and store it.
|
|
118
|
+
|
|
119
|
+
Returns (addresses stored, snapshot). The previous OKX set is replaced in
|
|
120
|
+
one transaction, so a failed import leaves the old one in place.
|
|
121
|
+
"""
|
|
122
|
+
say = progress or (lambda m: None)
|
|
123
|
+
url = source or latest_okx_url()
|
|
124
|
+
config.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
|
|
126
|
+
if Path(url).exists():
|
|
127
|
+
zip_path, downloaded = Path(url), False
|
|
128
|
+
# the source text can end up in a report; a local path would leak the user's name
|
|
129
|
+
url = f"local copy {zip_path.name}"
|
|
130
|
+
else:
|
|
131
|
+
zip_path, downloaded = config.DATA_DIR / "okx_por.zip.part", True
|
|
132
|
+
say(f"downloading {url}")
|
|
133
|
+
with requests.get(url, stream=True, timeout=60,
|
|
134
|
+
headers={"User-Agent": "Mozilla/5.0"}) as r:
|
|
135
|
+
r.raise_for_status()
|
|
136
|
+
with open(zip_path, "wb") as fh:
|
|
137
|
+
for chunk in r.iter_content(1 << 20):
|
|
138
|
+
fh.write(chunk)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
# the snapshot time is in the file name: okx_por_2026090800_V1.csv
|
|
142
|
+
stamp = re.search(r"(\d{10})", _okx_csv_name(zip_path) + " " + url)
|
|
143
|
+
snapshot = (f"{stamp[1][:4]}-{stamp[1][4:6]}-{stamp[1][6:8]}" if stamp
|
|
144
|
+
else "unknown date")
|
|
145
|
+
say("reading addresses")
|
|
146
|
+
rows, validators, bad = {}, 0, 0
|
|
147
|
+
for network, addr in _okx_rows(zip_path):
|
|
148
|
+
chain = OKX_NETWORKS.get(network)
|
|
149
|
+
if not chain:
|
|
150
|
+
continue
|
|
151
|
+
key = addr.lower()
|
|
152
|
+
if key in rows:
|
|
153
|
+
continue
|
|
154
|
+
if chain == "eth" and len(addr) == 98:
|
|
155
|
+
validators += 1 # a staking validator's public key, not an address
|
|
156
|
+
continue
|
|
157
|
+
if not addresses.validate(addr, chain)[0]:
|
|
158
|
+
bad += 1
|
|
159
|
+
continue
|
|
160
|
+
rows[key] = (key, chain, "okx")
|
|
161
|
+
|
|
162
|
+
c = sqlite3.connect(DB_PATH)
|
|
163
|
+
try:
|
|
164
|
+
_create(c)
|
|
165
|
+
with c:
|
|
166
|
+
c.execute("DELETE FROM labels WHERE set_name = 'okx'")
|
|
167
|
+
c.executemany("INSERT OR REPLACE INTO labels VALUES (?, ?, ?)", rows.values())
|
|
168
|
+
c.execute("INSERT OR REPLACE INTO sets VALUES (?,?,?,?,?,?,?,?,?,?)", (
|
|
169
|
+
"okx", "OKX deposit address", "exchange", "deposit",
|
|
170
|
+
f"one of the ~{len(rows) // 1000}k addresses OKX signed ('I am an OKX "
|
|
171
|
+
f"address') in its proof-of-reserves snapshot {snapshot} ({url}); most are "
|
|
172
|
+
"customer deposit addresses. Signature not verified by crypttrace.",
|
|
173
|
+
"self-published", snapshot, url, len(rows), int(time.time())))
|
|
174
|
+
finally:
|
|
175
|
+
c.close()
|
|
176
|
+
finally:
|
|
177
|
+
if downloaded:
|
|
178
|
+
zip_path.unlink(missing_ok=True)
|
|
179
|
+
|
|
180
|
+
global _conn
|
|
181
|
+
if _conn is not None:
|
|
182
|
+
_conn.close()
|
|
183
|
+
_conn = None
|
|
184
|
+
say(f"stored {len(rows)} addresses"
|
|
185
|
+
+ (f", skipped {validators} ETH staking validator keys" if validators else "")
|
|
186
|
+
+ (f", skipped {bad} malformed" if bad else ""))
|
|
187
|
+
return len(rows), snapshot
|