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.
Files changed (46) hide show
  1. {crypttrace-0.2.0/src/crypttrace.egg-info → crypttrace-0.4.0}/PKG-INFO +31 -8
  2. {crypttrace-0.2.0 → crypttrace-0.4.0}/README.md +30 -7
  3. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/cli.py +37 -6
  5. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/investigate.py +2 -11
  6. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/labels/audit.py +3 -0
  7. crypttrace-0.4.0/src/crypttrace/labels/bulk.py +187 -0
  8. crypttrace-0.4.0/src/crypttrace/labels/known.json +284 -0
  9. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/labels/labels.py +18 -1
  10. crypttrace-0.4.0/src/crypttrace/offramp.py +76 -0
  11. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/trace.py +5 -4
  12. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/watch.py +2 -2
  13. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/webapp.py +1 -2
  14. {crypttrace-0.2.0 → crypttrace-0.4.0/src/crypttrace.egg-info}/PKG-INFO +31 -8
  15. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
  16. crypttrace-0.2.0/src/crypttrace/labels/known.json +0 -148
  17. crypttrace-0.2.0/src/crypttrace/offramp.py +0 -58
  18. {crypttrace-0.2.0 → crypttrace-0.4.0}/LICENSE +0 -0
  19. {crypttrace-0.2.0 → crypttrace-0.4.0}/pyproject.toml +0 -0
  20. {crypttrace-0.2.0 → crypttrace-0.4.0}/setup.cfg +0 -0
  21. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/addresses.py +0 -0
  22. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/analysis.py +0 -0
  23. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/assess.py +0 -0
  24. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/assets.py +0 -0
  25. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/bridges.py +0 -0
  26. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/chains.py +0 -0
  27. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/config.py +0 -0
  28. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/fetchers/__init__.py +0 -0
  29. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  30. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  31. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/fetchers/http.py +0 -0
  32. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/fetchers/solana.py +0 -0
  33. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/fetchers/tron.py +0 -0
  34. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/funder.py +0 -0
  35. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/labels/__init__.py +0 -0
  36. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/labels/partial.json +0 -0
  37. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/prices.py +0 -0
  38. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/render.py +0 -0
  39. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/report.py +0 -0
  40. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/store.py +0 -0
  41. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/verify.py +0 -0
  42. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace/web/index.html +0 -0
  43. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  44. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  45. {crypttrace-0.2.0 → crypttrace-0.4.0}/src/crypttrace.egg-info/requires.txt +0 -0
  46. {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.2.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 coverage is currently Binance only: its reserve
375
- wallets, taken from the address list Binance publishes itself for
376
- proof-of-reserves. So `offramp` can flag Binance deposit addresses on those
377
- chains, but funds sent to other exchanges there will still show as `unknown`.
378
- Every label records where it came from — see `crypttrace labels audit`.
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
- - Exchange/service labels for Bitcoin, Tron and Solana
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 coverage is currently Binance only: its reserve
341
- wallets, taken from the address list Binance publishes itself for
342
- proof-of-reserves. So `offramp` can flag Binance deposit addresses on those
343
- chains, but funds sent to other exchanges there will still show as `unknown`.
344
- Every label records where it came from — see `crypttrace labels audit`.
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
- - Exchange/service labels for Bitcoin, Tron and Solana
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.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 (0x…)"),
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['exchange']} deposit address — a KYC identification point."
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
- console.print(f"[green]Done.[/green] {labels.count()} labelled addresses now loaded.")
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) if chains.is_evm(chain) else None
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({_service_name(f["label"]) for f in exchanges}) if exchanges else []
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