crypttrace 0.10.0__tar.gz → 0.12.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 (47) hide show
  1. {crypttrace-0.10.0/src/crypttrace.egg-info → crypttrace-0.12.0}/PKG-INFO +20 -12
  2. {crypttrace-0.10.0 → crypttrace-0.12.0}/README.md +17 -9
  3. {crypttrace-0.10.0 → crypttrace-0.12.0}/pyproject.toml +2 -2
  4. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/__init__.py +1 -1
  5. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/addresses.py +58 -0
  6. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/analysis.py +1 -1
  7. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/assets.py +11 -2
  8. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/chains.py +27 -12
  9. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/cli.py +2 -1
  10. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/fetchers/etherscan.py +2 -1
  11. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/fetchers/http.py +5 -1
  12. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/fetchers/solana.py +40 -7
  13. crypttrace-0.12.0/src/crypttrace/fetchers/ton.py +136 -0
  14. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/funder.py +9 -4
  15. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/labels/audit.py +2 -0
  16. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/labels/bulk.py +3 -1
  17. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/labels/known.json +5 -1
  18. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/labels/labels.py +2 -0
  19. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/offramp.py +5 -1
  20. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/poisoning.py +7 -0
  21. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/prices.py +2 -2
  22. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/render.py +4 -3
  23. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/report.py +2 -1
  24. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/store.py +3 -1
  25. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/trace.py +1 -1
  26. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/watch.py +0 -3
  27. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/web/index.html +5 -3
  28. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/webapp.py +4 -0
  29. {crypttrace-0.10.0 → crypttrace-0.12.0/src/crypttrace.egg-info}/PKG-INFO +20 -12
  30. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
  31. {crypttrace-0.10.0 → crypttrace-0.12.0}/LICENSE +0 -0
  32. {crypttrace-0.10.0 → crypttrace-0.12.0}/setup.cfg +0 -0
  33. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/assess.py +0 -0
  34. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/bridges.py +0 -0
  35. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/config.py +0 -0
  36. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/fetchers/__init__.py +0 -0
  37. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  38. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/fetchers/tron.py +0 -0
  39. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/freeze.py +0 -0
  40. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/investigate.py +0 -0
  41. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/labels/__init__.py +0 -0
  42. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/labels/partial.json +0 -0
  43. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/verify.py +0 -0
  44. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  45. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  46. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace.egg-info/requires.txt +0 -0
  47. {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace.egg-info/top_level.txt +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.10.0
4
- Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron and Solana
3
+ Version: 0.12.0
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
7
7
  Project-URL: Homepage, https://github.com/bobslayerX/crypttrace
8
8
  Project-URL: Issues, https://github.com/bobslayerX/crypttrace/issues
9
9
  Project-URL: Changelog, https://github.com/bobslayerX/crypttrace/blob/main/CHANGELOG.md
10
- Keywords: osint,blockchain,forensics,cryptocurrency,bitcoin,ethereum,tron,solana,investigation
10
+ Keywords: osint,blockchain,forensics,cryptocurrency,bitcoin,ethereum,tron,solana,ton,investigation
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Environment :: Console
13
13
  Classifier: Environment :: Web Environment
@@ -42,7 +42,7 @@ Dynamic: license-file
42
42
  Open-source OSINT toolkit for crypto investigations. Give it a suspicious
43
43
  address; it pulls the public on-chain history, labels known entities (exchanges,
44
44
  mixers, sanctioned wallets), scores risk, and maps where the funds moved —
45
- across Ethereum, Bitcoin, Tron, Solana and more.
45
+ across Ethereum, Bitcoin, Tron, Solana, TON and more.
46
46
 
47
47
  Built for the investigation that actually happens: *someone's crypto was stolen,
48
48
  here's the wallet — where did the money go, and where can it still be stopped?*
@@ -80,7 +80,7 @@ python selftest.py # offline checks, no API keys needed
80
80
  | | Needed? | How to get it |
81
81
  |---|---|---|
82
82
  | **EVM chains** (Ethereum, BSC, Polygon, Arbitrum, Optimism, Base) | **Required** | Free key at [etherscan.io/myapikey](https://etherscan.io/myapikey). One v2 key covers all EVM chains. |
83
- | **Bitcoin, Tron, Solana** | **Not required** | Work out of the box via public endpoints. |
83
+ | **Bitcoin, Tron, Solana, TON** | **Not required** | Work out of the box via public endpoints. |
84
84
 
85
85
  ```bash
86
86
  export ETHERSCAN_API_KEY=xxxx # required for EVM chains only
@@ -93,7 +93,7 @@ $env:ETHERSCAN_API_KEY = "xxxx" # this window only
93
93
  [Environment]::SetEnvironmentVariable("ETHERSCAN_API_KEY", "xxxx", "User") # permanently
94
94
  ```
95
95
 
96
- **About the keyless chains:** Bitcoin, Tron and Solana work with no signup at
96
+ **About the keyless chains:** Bitcoin, Tron, Solana and TON work with no signup at
97
97
  all, but their free public endpoints are **rate-limited**. crypttrace handles
98
98
  this for you — it caches every response, spaces requests out, and retries with
99
99
  backoff when a limit is hit. For deep traces you can raise the ceiling with your
@@ -102,6 +102,7 @@ own credentials (all optional):
102
102
  ```bash
103
103
  export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
104
104
  export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana RPC
105
+ export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
105
106
  ```
106
107
 
107
108
  (In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
@@ -114,11 +115,16 @@ export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana R
114
115
  | Bitcoin | `btc` | mempool.space | UTXO |
115
116
  | Tron (TRX + USDT-TRC20) | `tron` | TronGrid | account |
116
117
  | Solana (SOL + SPL) | `sol` | public JSON-RPC | account |
118
+ | TON (TON + jettons, e.g. USDT) | `ton` | toncenter v3 | account |
117
119
 
118
120
  Every network is normalized to the same transfer shape, so `profile`, `trace`,
119
121
  `funder`, `report` and the web graph behave identically everywhere. Bitcoin
120
122
  additionally unlocks `cluster`. Tron matters for everyday victim cases — most
121
123
  romance / "pig butchering" scams move USDT-TRC20 because fees are near zero.
124
+ TON matters for the same reason: USDT on TON is built into Telegram's wallet,
125
+ so scams that start in a Telegram chat often end there. TON addresses are
126
+ accepted in any form (`UQ…`, `EQ…` or raw `0:…`) and shown as wallets show
127
+ them (`UQ…`); jetton transfers are reported between the owners' wallets.
122
128
 
123
129
  ---
124
130
 
@@ -433,13 +439,14 @@ want:
433
439
  ```bash
434
440
  crypttrace trace 0xADDRESS --asset usdt # USDT on Ethereum
435
441
  crypttrace trace TADDRESS --chain tron --asset usdt # USDT-TRC20 on Tron
442
+ crypttrace trace UQADDRESS --chain ton --asset usdt # USDT on TON
436
443
  crypttrace tokens TADDRESS --chain tron
437
444
  ```
438
445
 
439
446
  Token contracts differ per chain, so the registry is chain-aware: `usdt` on Tron
440
447
  resolves to `TR7NHqje…jLj6t`, on Ethereum to `0xdac17f95…31ec7`. Known symbols
441
- are `usdt`, `usdc`, `dai`, `weth`, `wbtc` on EVM and `usdt`, `usdc` on
442
- Tron/Solana; you can also pass any contract address directly.
448
+ are `usdt`, `usdc`, `dai`, `weth`, `wbtc` on EVM, `usdt`, `usdc` on
449
+ Tron/Solana and `usdt` on TON; you can also pass any contract address directly.
443
450
 
444
451
  Matching is by **exact contract**, which matters: attackers routinely airdrop
445
452
  fake tokens named "USDT" to poison wallets, and those are ignored rather than
@@ -459,8 +466,8 @@ Add sources in `labels/labels.py` → `SOURCES`.
459
466
 
460
467
  On Bitcoin, Tron and Solana the labels cover the reserve wallets of Binance,
461
468
  OKX and HTX, plus Bybit's older 2022 list — each taken from the address list the
462
- exchange publishes itself for proof-of-reserves. That is what lets `offramp`
463
- flag deposit addresses on those chains. Funds sent to an exchange not on that
469
+ exchange publishes itself for proof-of-reserves; on TON, Binance's reserve
470
+ wallets. That is what lets `offramp` flag deposit addresses on those chains. Funds sent to an exchange not on that
464
471
  list still show as `unknown`. Every label records where it came from — see
465
472
  `crypttrace labels audit`.
466
473
 
@@ -561,6 +568,7 @@ src/crypttrace/
561
568
  bitcoin.py # Bitcoin UTXO (mempool.space) + clustering
562
569
  tron.py # Tron / TRC20 (TronGrid) + base58 conversion
563
570
  solana.py # Solana JSON-RPC
571
+ ton.py # TON + jettons (toncenter v3)
564
572
  http.py # shared cache, throttling, 429 backoff
565
573
  labels/
566
574
  known.json # curated label DB, one source per claim
@@ -589,10 +597,10 @@ cases/ # worked investigations with their data
589
597
 
590
598
  ## Roadmap
591
599
 
592
- - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
600
+ - More exchanges on Bitcoin, Tron, Solana (now: Binance, OKX, HTX, Bybit) and
601
+ TON (now: Binance),
593
602
  and refreshing these lists as the exchanges republish them
594
603
  - Internal transactions (completes `funder` and contract-mediated transfers)
595
- - Per-mint filtering for Solana SPL tokens
596
604
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
597
605
  - Spam/dust token filtering in `tokens`
598
606
  - Entity clustering on EVM via the gas-funding heuristic
@@ -8,7 +8,7 @@
8
8
  Open-source OSINT toolkit for crypto investigations. Give it a suspicious
9
9
  address; it pulls the public on-chain history, labels known entities (exchanges,
10
10
  mixers, sanctioned wallets), scores risk, and maps where the funds moved —
11
- across Ethereum, Bitcoin, Tron, Solana and more.
11
+ across Ethereum, Bitcoin, Tron, Solana, TON and more.
12
12
 
13
13
  Built for the investigation that actually happens: *someone's crypto was stolen,
14
14
  here's the wallet — where did the money go, and where can it still be stopped?*
@@ -46,7 +46,7 @@ python selftest.py # offline checks, no API keys needed
46
46
  | | Needed? | How to get it |
47
47
  |---|---|---|
48
48
  | **EVM chains** (Ethereum, BSC, Polygon, Arbitrum, Optimism, Base) | **Required** | Free key at [etherscan.io/myapikey](https://etherscan.io/myapikey). One v2 key covers all EVM chains. |
49
- | **Bitcoin, Tron, Solana** | **Not required** | Work out of the box via public endpoints. |
49
+ | **Bitcoin, Tron, Solana, TON** | **Not required** | Work out of the box via public endpoints. |
50
50
 
51
51
  ```bash
52
52
  export ETHERSCAN_API_KEY=xxxx # required for EVM chains only
@@ -59,7 +59,7 @@ $env:ETHERSCAN_API_KEY = "xxxx" # this window only
59
59
  [Environment]::SetEnvironmentVariable("ETHERSCAN_API_KEY", "xxxx", "User") # permanently
60
60
  ```
61
61
 
62
- **About the keyless chains:** Bitcoin, Tron and Solana work with no signup at
62
+ **About the keyless chains:** Bitcoin, Tron, Solana and TON work with no signup at
63
63
  all, but their free public endpoints are **rate-limited**. crypttrace handles
64
64
  this for you — it caches every response, spaces requests out, and retries with
65
65
  backoff when a limit is hit. For deep traces you can raise the ceiling with your
@@ -68,6 +68,7 @@ own credentials (all optional):
68
68
  ```bash
69
69
  export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
70
70
  export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana RPC
71
+ export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
71
72
  ```
72
73
 
73
74
  (In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
@@ -80,11 +81,16 @@ export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana R
80
81
  | Bitcoin | `btc` | mempool.space | UTXO |
81
82
  | Tron (TRX + USDT-TRC20) | `tron` | TronGrid | account |
82
83
  | Solana (SOL + SPL) | `sol` | public JSON-RPC | account |
84
+ | TON (TON + jettons, e.g. USDT) | `ton` | toncenter v3 | account |
83
85
 
84
86
  Every network is normalized to the same transfer shape, so `profile`, `trace`,
85
87
  `funder`, `report` and the web graph behave identically everywhere. Bitcoin
86
88
  additionally unlocks `cluster`. Tron matters for everyday victim cases — most
87
89
  romance / "pig butchering" scams move USDT-TRC20 because fees are near zero.
90
+ TON matters for the same reason: USDT on TON is built into Telegram's wallet,
91
+ so scams that start in a Telegram chat often end there. TON addresses are
92
+ accepted in any form (`UQ…`, `EQ…` or raw `0:…`) and shown as wallets show
93
+ them (`UQ…`); jetton transfers are reported between the owners' wallets.
88
94
 
89
95
  ---
90
96
 
@@ -399,13 +405,14 @@ want:
399
405
  ```bash
400
406
  crypttrace trace 0xADDRESS --asset usdt # USDT on Ethereum
401
407
  crypttrace trace TADDRESS --chain tron --asset usdt # USDT-TRC20 on Tron
408
+ crypttrace trace UQADDRESS --chain ton --asset usdt # USDT on TON
402
409
  crypttrace tokens TADDRESS --chain tron
403
410
  ```
404
411
 
405
412
  Token contracts differ per chain, so the registry is chain-aware: `usdt` on Tron
406
413
  resolves to `TR7NHqje…jLj6t`, on Ethereum to `0xdac17f95…31ec7`. Known symbols
407
- are `usdt`, `usdc`, `dai`, `weth`, `wbtc` on EVM and `usdt`, `usdc` on
408
- Tron/Solana; you can also pass any contract address directly.
414
+ are `usdt`, `usdc`, `dai`, `weth`, `wbtc` on EVM, `usdt`, `usdc` on
415
+ Tron/Solana and `usdt` on TON; you can also pass any contract address directly.
409
416
 
410
417
  Matching is by **exact contract**, which matters: attackers routinely airdrop
411
418
  fake tokens named "USDT" to poison wallets, and those are ignored rather than
@@ -425,8 +432,8 @@ Add sources in `labels/labels.py` → `SOURCES`.
425
432
 
426
433
  On Bitcoin, Tron and Solana the labels cover the reserve wallets of Binance,
427
434
  OKX and HTX, plus Bybit's older 2022 list — each taken from the address list the
428
- exchange publishes itself for proof-of-reserves. That is what lets `offramp`
429
- flag deposit addresses on those chains. Funds sent to an exchange not on that
435
+ exchange publishes itself for proof-of-reserves; on TON, Binance's reserve
436
+ wallets. That is what lets `offramp` flag deposit addresses on those chains. Funds sent to an exchange not on that
430
437
  list still show as `unknown`. Every label records where it came from — see
431
438
  `crypttrace labels audit`.
432
439
 
@@ -527,6 +534,7 @@ src/crypttrace/
527
534
  bitcoin.py # Bitcoin UTXO (mempool.space) + clustering
528
535
  tron.py # Tron / TRC20 (TronGrid) + base58 conversion
529
536
  solana.py # Solana JSON-RPC
537
+ ton.py # TON + jettons (toncenter v3)
530
538
  http.py # shared cache, throttling, 429 backoff
531
539
  labels/
532
540
  known.json # curated label DB, one source per claim
@@ -555,10 +563,10 @@ cases/ # worked investigations with their data
555
563
 
556
564
  ## Roadmap
557
565
 
558
- - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
566
+ - More exchanges on Bitcoin, Tron, Solana (now: Binance, OKX, HTX, Bybit) and
567
+ TON (now: Binance),
559
568
  and refreshing these lists as the exchanges republish them
560
569
  - Internal transactions (completes `funder` and contract-mediated transfers)
561
- - Per-mint filtering for Solana SPL tokens
562
570
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
563
571
  - Spam/dust token filtering in `tokens`
564
572
  - Entity clustering on EVM via the gas-funding heuristic
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "crypttrace"
3
3
  dynamic = ["version"]
4
- description = "OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron and Solana"
4
+ description = "OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron, Solana and TON"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
8
8
  authors = [{ name = "bobslayerX" }]
9
9
  requires-python = ">=3.9"
10
- keywords = ["osint", "blockchain", "forensics", "cryptocurrency", "bitcoin", "ethereum", "tron", "solana", "investigation"]
10
+ keywords = ["osint", "blockchain", "forensics", "cryptocurrency", "bitcoin", "ethereum", "tron", "solana", "ton", "investigation"]
11
11
  classifiers = [
12
12
  "Development Status :: 4 - Beta",
13
13
  "Environment :: Console",
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.10.0"
2
+ __version__ = "0.12.0"
@@ -123,9 +123,67 @@ def validate(address: str, chain: str) -> Tuple[bool, str]:
123
123
  return False, "does not decode to a 32-byte public key"
124
124
  return True, "valid 32-byte key (Solana addresses carry no checksum)"
125
125
 
126
+ if chain == "ton":
127
+ if ":" in a:
128
+ return (True, "valid raw TON address (the raw form carries no checksum)") \
129
+ if ton_parse(a) else (False, "raw TON addresses are workchain:64 hex characters")
130
+ if len(a) != 48:
131
+ return False, "TON addresses are 48 characters (EQ…/UQ…) or workchain:hex"
132
+ return (True, "TON checksum valid") if ton_parse(a) else \
133
+ (False, "checksum does not match — the address is mistyped or invented")
134
+
126
135
  return False, f"unknown chain '{chain}'"
127
136
 
128
137
 
138
+ # ---------------------------------------------------------------- TON
139
+ # One account, several spellings: raw "0:<64 hex>", and 48-character base64 forms
140
+ # that are bounceable ("EQ…") or not ("UQ…"), each ending in a CRC16 of the rest.
141
+ # Everything is compared in one canonical spelling: non-bounceable, url-safe —
142
+ # the form wallets show, and so the one a poisoner imitates.
143
+
144
+ def _crc16_xmodem(data: bytes) -> int:
145
+ crc = 0
146
+ for byte in data:
147
+ crc ^= byte << 8
148
+ for _ in range(8):
149
+ crc = ((crc << 1) ^ 0x1021) if crc & 0x8000 else crc << 1
150
+ crc &= 0xFFFF
151
+ return crc
152
+
153
+
154
+ def ton_parse(address: str) -> Optional[Tuple[int, bytes]]:
155
+ """(workchain, 32-byte account id) from any TON spelling, or None."""
156
+ import base64
157
+ import re
158
+ a = (address or "").strip()
159
+ m = re.fullmatch(r"(-?\d+):([0-9a-fA-F]{64})", a)
160
+ if m:
161
+ return int(m.group(1)), bytes.fromhex(m.group(2))
162
+ if len(a) != 48:
163
+ return None
164
+ try:
165
+ raw = base64.b64decode(a.replace("-", "+").replace("_", "/"), validate=True)
166
+ except (ValueError, TypeError):
167
+ return None
168
+ if len(raw) != 36 or (raw[0] & 0x7F) not in (0x11, 0x51):
169
+ return None
170
+ if _crc16_xmodem(raw[:34]) != int.from_bytes(raw[34:], "big"):
171
+ return None
172
+ wc = raw[1] - 256 if raw[1] > 127 else raw[1]
173
+ return wc, raw[2:34]
174
+
175
+
176
+ def ton_friendly(address: str, bounceable: bool = False) -> Optional[str]:
177
+ """The canonical 48-character spelling (non-bounceable "UQ…" by default)."""
178
+ import base64
179
+ p = ton_parse(address)
180
+ if not p:
181
+ return None
182
+ wc, account = p
183
+ body = bytes([0x11 if bounceable else 0x51, wc & 0xFF]) + account
184
+ return base64.urlsafe_b64encode(body + _crc16_xmodem(body).to_bytes(2, "big")).decode()
185
+
186
+
129
187
  def looks_like_txid(value: str) -> bool:
130
188
  """64 hex characters — a transaction id, which people paste in by mistake."""
131
189
  v = (value or "").strip()
@@ -24,7 +24,7 @@ from crypttrace.labels import labels
24
24
  # drowns out the transfers an investigation is actually about.
25
25
  DUST = {"btc": 0.0005, "eth": 0.002, "bsc": 0.005, "polygon": 5.0,
26
26
  "arbitrum": 0.002, "optimism": 0.002, "base": 0.002,
27
- "tron": 5.0, "sol": 0.01}
27
+ "tron": 5.0, "sol": 0.01, "ton": 0.01}
28
28
 
29
29
 
30
30
  def dust_threshold(chain: str, asset: Optional[dict] = None) -> float:
@@ -23,6 +23,10 @@ TOKENS: Dict[str, Dict[str, dict]] = {
23
23
  "usdt": {"contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "symbol": "USDT", "stable": True},
24
24
  "usdc": {"contract": "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8", "symbol": "USDC", "stable": True},
25
25
  },
26
+ "ton": {
27
+ "usdt": {"contract": "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe",
28
+ "symbol": "USDT", "stable": True, "decimals": 6},
29
+ },
26
30
  "sol": {
27
31
  "usdc": {"contract": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "symbol": "USDC", "stable": True},
28
32
  "usdt": {"contract": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", "symbol": "USDT", "stable": True},
@@ -45,7 +49,7 @@ def resolve_asset(asset: Optional[str], chain: str = "eth") -> Optional[dict]:
45
49
  Accepts 'eth'/'native'/None, a known symbol on that chain, or a raw contract
46
50
  address (0x… on EVM, base58 on Tron/Solana).
47
51
  """
48
- if asset is None or asset.lower() in ("eth", "native", "btc", "trx", "sol", "bnb", "matic"):
52
+ if asset is None or asset.lower() in ("eth", "native", "btc", "trx", "sol", "bnb", "matic", "ton"):
49
53
  return None
50
54
 
51
55
  a = asset.lower()
@@ -54,7 +58,12 @@ def resolve_asset(asset: Optional[str], chain: str = "eth") -> Optional[dict]:
54
58
  return dict(known[a])
55
59
 
56
60
  # raw contract address
57
- if chain in ("tron", "sol"):
61
+ if chain == "ton": # a jetton master, in any spelling -> raw
62
+ from crypttrace import addresses
63
+ p = addresses.ton_parse(asset)
64
+ if p:
65
+ return {"contract": f"{p[0]}:{p[1].hex()}", "symbol": "JETTON", "stable": False}
66
+ elif chain in ("tron", "sol"):
58
67
  if len(asset) >= 32 and not asset.startswith("0x"):
59
68
  return {"contract": asset, "symbol": asset[:6].upper(), "stable": False}
60
69
  elif a.startswith("0x") and len(a) == 42:
@@ -13,21 +13,22 @@ would be meaningless, so a caller always asks for one asset at a time.
13
13
  from typing import List, Dict, Optional
14
14
 
15
15
  from crypttrace import config
16
- from crypttrace.fetchers import etherscan, bitcoin, tron, solana
16
+ from crypttrace import addresses as _addresses
17
+ from crypttrace.fetchers import etherscan, bitcoin, http as _http, tron, solana, ton
17
18
 
18
19
  EVM_CHAINS = set(config.CHAINS)
19
- NON_EVM = {"btc", "tron", "sol"}
20
+ NON_EVM = {"btc", "tron", "sol", "ton"}
20
21
  ALL_CHAINS = sorted(EVM_CHAINS | NON_EVM)
21
22
 
22
23
  SYMBOL = {"eth": "ETH", "bsc": "BNB", "polygon": "MATIC", "arbitrum": "ETH",
23
- "optimism": "ETH", "base": "ETH", "btc": "BTC", "tron": "TRX", "sol": "SOL"}
24
+ "optimism": "ETH", "base": "ETH", "btc": "BTC", "tron": "TRX", "sol": "SOL", "ton": "TON"}
24
25
 
25
26
  EXPLORER = {
26
27
  "eth": "https://etherscan.io/address/{}", "bsc": "https://bscscan.com/address/{}",
27
28
  "polygon": "https://polygonscan.com/address/{}", "arbitrum": "https://arbiscan.io/address/{}",
28
29
  "optimism": "https://optimistic.etherscan.io/address/{}", "base": "https://basescan.org/address/{}",
29
30
  "btc": "https://mempool.space/address/{}", "tron": "https://tronscan.org/#/address/{}",
30
- "sol": "https://solscan.io/account/{}",
31
+ "sol": "https://solscan.io/account/{}", "ton": "https://tonviewer.com/{}",
31
32
  }
32
33
 
33
34
  TX_EXPLORER = {
@@ -35,11 +36,11 @@ TX_EXPLORER = {
35
36
  "polygon": "https://polygonscan.com/tx/{}", "arbitrum": "https://arbiscan.io/tx/{}",
36
37
  "optimism": "https://optimistic.etherscan.io/tx/{}", "base": "https://basescan.org/tx/{}",
37
38
  "btc": "https://mempool.space/tx/{}", "tron": "https://tronscan.org/#/transaction/{}",
38
- "sol": "https://solscan.io/tx/{}",
39
+ "sol": "https://solscan.io/tx/{}", "ton": "https://tonviewer.com/transaction/{}",
39
40
  }
40
41
 
41
42
  _UPSTREAM_ERRORS = (etherscan.EtherscanError, bitcoin.BitcoinError,
42
- tron.TronError, solana.SolanaError)
43
+ tron.TronError, solana.SolanaError, ton.TonError)
43
44
 
44
45
 
45
46
  class ChainError(RuntimeError):
@@ -56,6 +57,8 @@ def case_sensitive(chain: str) -> bool:
56
57
 
57
58
 
58
59
  def norm_addr(address: str, chain: str) -> str:
60
+ if chain == "ton": # one account, several spellings: compare the one wallets show
61
+ return _addresses.ton_friendly(address) or address
59
62
  return address if case_sensitive(chain) else address.lower()
60
63
 
61
64
 
@@ -87,6 +90,8 @@ def balance(address: str, chain: str = "eth") -> float:
87
90
  return tron.balance(address)
88
91
  if chain == "sol":
89
92
  return solana.balance(address)
93
+ if chain == "ton":
94
+ return ton.balance(address)
90
95
  except _UPSTREAM_ERRORS as e:
91
96
  raise ChainError(str(e))
92
97
  return 0.0
@@ -138,9 +143,8 @@ def _sol_rows(address: str, limit: int, token: bool, contract: Optional[str]) ->
138
143
  rows = solana.transfers(address, limit)
139
144
  if token:
140
145
  rows = [r for r in rows if r.get("symbol") != "SOL"]
141
- if contract:
142
- rows = [r for r in rows if r.get("mint") in (None, contract)
143
- or (r.get("contract") or "") == contract]
146
+ if contract: # SPL rows carry their mint as the contract
147
+ rows = [r for r in rows if (r.get("contract") or "") == contract]
144
148
  else:
145
149
  rows = [r for r in rows if r.get("symbol") == "SOL"]
146
150
  return rows
@@ -160,15 +164,15 @@ def transfers(address: str, chain: str = "eth", limit: int = 1000,
160
164
  repeat analysis costs nothing and works offline.
161
165
  """
162
166
  check(chain)
167
+ if chain == "ton":
168
+ address = norm_addr(address, chain) # one spelling for store keys and matching
163
169
  contract = asset.get("contract") if asset else None
164
170
 
165
171
  from crypttrace import store
166
172
  # What the stored rows are filed under: "" native, a contract, "*" every
167
- # token, or "spl" on Solana, whose token rows carry no mint to tell apart.
173
+ # token (Solana token rows carry their mint as the contract).
168
174
  if not asset:
169
175
  asset_key = ""
170
- elif chain == "sol":
171
- asset_key = "spl"
172
176
  else:
173
177
  asset_key = (contract or "*").lower()
174
178
  skip_read = fresh or FORCE_FRESH
@@ -184,6 +188,9 @@ def transfers(address: str, chain: str = "eth", limit: int = 1000,
184
188
  # nothing stored for this address and we're not allowed to fetch
185
189
  return []
186
190
 
191
+ # asked for fresh data: skip the HTTP-level caches too, not just the store
192
+ was_fresh = _http.FRESH
193
+ _http.FRESH = was_fresh or fresh or FORCE_FRESH
187
194
  try:
188
195
  if is_evm(chain):
189
196
  rows = _evm_token(address, chain, contract, limit) if asset \
@@ -198,10 +205,16 @@ def transfers(address: str, chain: str = "eth", limit: int = 1000,
198
205
  else tron.transfers(address, limit)
199
206
  elif chain == "sol":
200
207
  rows = _sol_rows(address, limit, bool(asset), contract)
208
+ elif chain == "ton":
209
+ rows = ton.transfers(address, limit) if not asset else [
210
+ r for r in ton.jetton_transfers(address, limit)
211
+ if not contract or r["contract"] == contract.lower()]
201
212
  else:
202
213
  rows = []
203
214
  except _UPSTREAM_ERRORS as e:
204
215
  raise ChainError(str(e))
216
+ finally:
217
+ _http.FRESH = was_fresh
205
218
 
206
219
  if USE_STORE:
207
220
  try:
@@ -255,6 +268,8 @@ def token_holdings(address: str, chain: str = "eth", limit: int = 1000) -> List[
255
268
  rows = _evm_token(address, chain, None, limit)
256
269
  elif chain == "tron":
257
270
  rows = _tron_token(address, None, limit)
271
+ elif chain == "ton":
272
+ rows = ton.jetton_transfers(address, limit)
258
273
  else:
259
274
  rows = _sol_rows(address, limit, True, None)
260
275
  except _UPSTREAM_ERRORS as e:
@@ -390,7 +390,7 @@ def funder(
390
390
  except etherscan.EtherscanError as e:
391
391
  console.print(f"[red]Error:[/red] {e}")
392
392
  raise typer.Exit(1)
393
- console.print(render.funding_tree(address, chain_hops))
393
+ console.print(render.funding_tree(address, chain_hops, chains_mod.symbol(chain)))
394
394
  if chain_hops and chain_hops[-1]["terminal"] and chain_hops[-1]["funder_type"] == "exchange":
395
395
  console.print("\n[green]➜ Funding chain reaches an exchange — a KYC identification "
396
396
  "point. A legal request to that exchange can reveal the owner.[/green]")
@@ -804,6 +804,7 @@ def chains():
804
804
  console.print(" btc Bitcoin — mempool.space (UTXO)")
805
805
  console.print(" tron Tron — TronGrid (TRX + USDT-TRC20)")
806
806
  console.print(" sol Solana — public JSON-RPC")
807
+ console.print(" ton TON — toncenter (TON + jettons such as USDT)")
807
808
 
808
809
 
809
810
  @app.command()
@@ -64,7 +64,8 @@ def _call(chain: str, params: Dict[str, str], cache_age: int = 3600) -> dict:
64
64
  **params,
65
65
  }
66
66
  key = f"{chain}:" + "&".join(f"{k}={v}" for k, v in sorted(q.items()) if k != "apikey")
67
- cached = _cache_get(key, cache_age)
67
+ from crypttrace.fetchers import http
68
+ cached = None if http.FRESH else _cache_get(key, cache_age)
68
69
  if cached is not None:
69
70
  return cached
70
71
 
@@ -31,6 +31,10 @@ DEFAULT_INTERVAL = 0.25
31
31
  _last_call = {}
32
32
  _lock = threading.Lock()
33
33
 
34
+ # Set while a caller needs what the chain says *now* (watch, --fresh): cached
35
+ # responses are skipped, though fresh ones are still stored.
36
+ FRESH = False
37
+
34
38
 
35
39
  class RateLimited(RuntimeError):
36
40
  pass
@@ -86,7 +90,7 @@ def request_json(url: str, params: dict = None, *, body: dict = None,
86
90
  timeout: int = 30, headers: dict = None):
87
91
  """GET (or POST when `body` is given) returning JSON, cached and rate-limited."""
88
92
  key = cache_key or (url + ("?" + urlencode(sorted(params.items())) if params else ""))
89
- hit = cache_get(key, ttl)
93
+ hit = None if FRESH else cache_get(key, ttl)
90
94
  if hit is not None:
91
95
  return hit
92
96
 
@@ -10,10 +10,15 @@ from typing import List, Dict
10
10
 
11
11
  import requests
12
12
 
13
+ from crypttrace import assets
14
+
13
15
  RPC = os.environ.get("CRYPTTRACE_SOLANA_RPC", "https://api.mainnet-beta.solana.com")
14
16
  LAMPORTS = 1_000_000_000
15
17
  MAX_TXS = 25 # keep the number of RPC round-trips sane
16
18
 
19
+ # mint -> symbol for the tokens crypttrace knows; others show as "SPL"
20
+ _KNOWN_MINTS = {t["contract"]: t["symbol"] for t in assets.tokens_for("sol").values()}
21
+
17
22
 
18
23
  class SolanaError(RuntimeError):
19
24
  pass
@@ -59,10 +64,26 @@ def _parse_tx(sig: str) -> List[Dict]:
59
64
  return []
60
65
  ts = int(tx.get("blockTime") or 0)
61
66
  rows = []
67
+ meta = tx.get("meta") or {}
62
68
  msg = (tx.get("transaction") or {}).get("message") or {}
63
69
  instrs = list(msg.get("instructions") or [])
64
- for inner in (tx.get("meta") or {}).get("innerInstructions") or []:
70
+ for inner in meta.get("innerInstructions") or []:
65
71
  instrs.extend(inner.get("instructions") or [])
72
+
73
+ # An SPL transfer names token *accounts*, not wallets. The token balances in
74
+ # the transaction's metadata say which wallet owns each account, which mint
75
+ # it holds and how many decimals that mint has.
76
+ keys = [k.get("pubkey") if isinstance(k, dict) else k for k in msg.get("accountKeys") or []]
77
+ loaded = meta.get("loadedAddresses") or {}
78
+ if not any(isinstance(k, dict) and k.get("source") == "lookupTable"
79
+ for k in msg.get("accountKeys") or []):
80
+ keys += list(loaded.get("writable") or []) + list(loaded.get("readonly") or [])
81
+ accounts = {}
82
+ for b in (meta.get("preTokenBalances") or []) + (meta.get("postTokenBalances") or []):
83
+ i = b.get("accountIndex")
84
+ if isinstance(i, int) and i < len(keys):
85
+ accounts[keys[i]] = {"owner": b.get("owner") or "", "mint": b.get("mint") or "",
86
+ "decimals": (b.get("uiTokenAmount") or {}).get("decimals")}
66
87
  for ins in instrs:
67
88
  parsed = ins.get("parsed")
68
89
  if not isinstance(parsed, dict):
@@ -75,15 +96,27 @@ def _parse_tx(sig: str) -> List[Dict]:
75
96
  "value": (info.get("lamports") or 0) / LAMPORTS,
76
97
  "timestamp": ts, "hash": sig, "symbol": "SOL"})
77
98
  elif prog == "spl-token" and ptype in ("transfer", "transferChecked"):
99
+ src = accounts.get(info.get("source", ""), {})
100
+ dst = accounts.get(info.get("destination", ""), {})
101
+ mint = info.get("mint") or src.get("mint") or dst.get("mint") or ""
102
+ decimals = src.get("decimals") if src.get("decimals") is not None else dst.get("decimals")
78
103
  amt = info.get("tokenAmount") or {}
79
104
  try:
80
- val = float(amt.get("uiAmountString") or amt.get("uiAmount") or info.get("amount") or 0)
105
+ if amt.get("uiAmountString") or amt.get("uiAmount") is not None:
106
+ val = float(amt.get("uiAmountString") or amt.get("uiAmount") or 0)
107
+ elif decimals is not None: # plain "transfer" gives raw base units
108
+ val = int(info.get("amount") or 0) / (10 ** int(decimals))
109
+ else:
110
+ continue # no way to know the unit: don't guess
81
111
  except (TypeError, ValueError):
82
- val = 0.0
83
- rows.append({"from": info.get("source", "") or info.get("authority", ""),
84
- "to": info.get("destination", ""), "value": val,
85
- "timestamp": ts, "hash": sig, "symbol": "SPL",
86
- "contract": "spl"}) # keeps token rows apart from SOL in the store
112
+ continue
113
+ rows.append({
114
+ # the wallets behind the token accounts; the account itself if unknown
115
+ "from": src.get("owner") or info.get("authority") or info.get("source", ""),
116
+ "to": dst.get("owner") or info.get("destination", ""),
117
+ "value": val, "timestamp": ts, "hash": sig,
118
+ "symbol": _KNOWN_MINTS.get(mint, "SPL"),
119
+ "contract": mint or "spl"}) # the mint; keeps token rows apart from SOL
87
120
  return rows
88
121
 
89
122
 
@@ -0,0 +1,136 @@
1
+ """TON fetcher via toncenter's v3 indexer — no API key required for basic use.
2
+
3
+ TON matters for the same reason Tron does: USDT on TON is cheap to move and is
4
+ built into Telegram's wallet, so scams that start in a Telegram chat end here.
5
+
6
+ Every address comes back from the indexer in the raw "0:<hex>" form and is
7
+ rewritten into the canonical "UQ…" spelling (see addresses.ton_friendly), so it
8
+ matches what wallets show, the labels, and what a victim pastes. Jetton
9
+ transfers (USDT and friends) are reported between the owners' wallets, not the
10
+ per-owner jetton-wallet contracts that actually hold the tokens.
11
+
12
+ The keyless endpoint allows about one request per second; set TONCENTER_API_KEY
13
+ for more.
14
+ """
15
+ import base64
16
+ import os
17
+ from typing import Dict, List, Optional
18
+
19
+ import requests
20
+
21
+ from crypttrace import addresses, assets
22
+ from crypttrace.fetchers import http
23
+
24
+ BASE = "https://toncenter.com/api/v3"
25
+ NANO = 1_000_000_000
26
+ # page sizes toncenter accepts (checked live): fewer pages matter at ~1 request/second
27
+ TX_PAGE, JETTON_PAGE = 500, 1000
28
+ _KEY = os.environ.get("TONCENTER_API_KEY")
29
+ http.MIN_INTERVAL["toncenter.com"] = 0.12 if _KEY else 1.1
30
+
31
+ # jetton master (raw, lower-case) -> decimals, starting with the ones we know
32
+ _DECIMALS: Dict[str, int] = {t["contract"]: t.get("decimals", 9)
33
+ for t in assets.tokens_for("ton").values()}
34
+ _SYMBOLS: Dict[str, str] = {t["contract"]: t["symbol"] for t in assets.tokens_for("ton").values()}
35
+
36
+
37
+ class TonError(RuntimeError):
38
+ pass
39
+
40
+
41
+ def _get(path: str, params: Optional[dict] = None, timeout: int = 30) -> dict:
42
+ headers = {"X-API-Key": _KEY} if _KEY else None
43
+ try:
44
+ data = http.request_json(f"{BASE}{path}", params or {}, timeout=timeout, headers=headers)
45
+ except http.RateLimited as e:
46
+ raise TonError(str(e) + " (tip: set TONCENTER_API_KEY for a higher limit)")
47
+ except requests.RequestException as e:
48
+ raise TonError(f"toncenter request failed: {e}")
49
+ except ValueError as e:
50
+ raise TonError(f"bad response from toncenter: {e}")
51
+ if isinstance(data, dict) and "__status__" in data:
52
+ raise TonError(f"toncenter answered HTTP {data['__status__']}")
53
+ return data if isinstance(data, dict) else {}
54
+
55
+
56
+ def _pages(path: str, key: str, params: dict, limit: int, page: int) -> List[dict]:
57
+ items: List[dict] = []
58
+ offset = 0
59
+ while len(items) < limit:
60
+ n = min(page, limit - len(items))
61
+ batch = _get(path, dict(params, limit=n, offset=offset, sort="desc")).get(key) or []
62
+ items += batch
63
+ if len(batch) < n:
64
+ break
65
+ offset += n
66
+ return items
67
+
68
+
69
+ def _addr(raw: Optional[str]) -> str:
70
+ return addresses.ton_friendly(raw or "") or (raw or "")
71
+
72
+
73
+ def _hex(tx_hash: str) -> str:
74
+ """toncenter gives base64 hashes; explorers link by hex."""
75
+ try:
76
+ return base64.b64decode(tx_hash).hex()
77
+ except (ValueError, TypeError):
78
+ return tx_hash or ""
79
+
80
+
81
+ def balance(address: str) -> float:
82
+ d = _get("/account", {"address": address})
83
+ return int(d.get("balance") or 0) / NANO
84
+
85
+
86
+ def transfers(address: str, limit: int = 1000) -> List[Dict]:
87
+ """TON moved in and out of `address`: its incoming message and outgoing ones."""
88
+ me = _addr(address)
89
+ rows = []
90
+ for tx in _pages("/transactions", "transactions", {"account": address}, limit, TX_PAGE):
91
+ ts, h = int(tx.get("now") or 0), _hex(tx.get("hash", ""))
92
+ msgs = [tx.get("in_msg") or {}] + list(tx.get("out_msgs") or [])
93
+ for m in msgs:
94
+ src, dst = m.get("source"), m.get("destination")
95
+ value = int(m.get("value") or 0) / NANO
96
+ if not src or not dst or value <= 0: # external messages carry no value
97
+ continue
98
+ src, dst = _addr(src), _addr(dst)
99
+ if me not in (src, dst):
100
+ continue
101
+ rows.append({"from": src, "to": dst, "value": value, "timestamp": ts,
102
+ "hash": h, "symbol": "TON"})
103
+ return rows
104
+
105
+
106
+ def _decimals(master: str) -> Optional[int]:
107
+ if master not in _DECIMALS:
108
+ try:
109
+ m = (_get("/jetton/masters", {"address": master, "limit": 1}).get("jetton_masters")
110
+ or [{}])[0]
111
+ content = m.get("jetton_content") or {}
112
+ _DECIMALS[master] = int(content.get("decimals") or 9) # TEP-64 default is 9
113
+ except (TonError, ValueError, TypeError):
114
+ return None
115
+ return _DECIMALS[master]
116
+
117
+
118
+ def jetton_transfers(address: str, limit: int = 1000) -> List[Dict]:
119
+ """Jetton (token) transfers to and from `address`, between owner wallets."""
120
+ rows = []
121
+ for t in _pages("/jetton/transfers", "jetton_transfers", {"owner_address": address}, limit, JETTON_PAGE):
122
+ if t.get("aborted"):
123
+ continue
124
+ master = (t.get("jetton_master") or "").lower()
125
+ dec = _decimals(master)
126
+ if dec is None:
127
+ continue # unknown unit: don't guess
128
+ try:
129
+ value = int(t.get("amount") or 0) / (10 ** dec)
130
+ except (ValueError, TypeError):
131
+ continue
132
+ rows.append({"from": _addr(t.get("source")), "to": _addr(t.get("destination")),
133
+ "value": value, "timestamp": int(t.get("transaction_now") or 0),
134
+ "hash": _hex(t.get("transaction_hash", "")),
135
+ "symbol": _SYMBOLS.get(master, "JETTON"), "contract": master})
136
+ return rows
@@ -24,10 +24,13 @@ def first_funder(address: str, chain: str = "eth") -> Optional[dict]:
24
24
  Works on every supported chain. {funder, value, timestamp, hash} or None.
25
25
  """
26
26
  from crypttrace import chains
27
- me = address if chain in ("btc", "tron", "sol") else address.lower()
27
+ me = chains.norm_addr(address, chain)
28
28
  rows = chains.transfers(address, chain, limit=200, oldest_first=True)
29
+ from crypttrace import analysis
30
+ dust = analysis.dust_threshold(chain)
29
31
  for r in rows: # ascending => first match is the earliest funding
30
- if r.get("to") != me or r.get("value", 0) <= 0:
32
+ # dust is not funding: it is what poisoners and spammers send everyone
33
+ if r.get("to") != me or r.get("value", 0) < max(dust, 1e-18):
31
34
  continue
32
35
  return {
33
36
  "funder": r.get("from", ""),
@@ -49,9 +52,11 @@ def funding_chain(address: str, chain: str = "eth", max_hops: int = 6) -> List[D
49
52
  Returns a list of hops, each {address, funder, value, timestamp, funder_type,
50
53
  funder_label, terminal}. Stops at a labelled entity, a dead end, or a cycle.
51
54
  """
55
+ from crypttrace import chains
52
56
  chain_hops: List[Dict] = []
53
- seen = {address.lower()}
54
- current = address.lower()
57
+ # lower-casing here broke every Bitcoin, Tron and Solana address
58
+ current = chains.norm_addr(address, chain)
59
+ seen = {current}
55
60
 
56
61
  for _ in range(max_hops):
57
62
  info = first_funder(current, chain)
@@ -39,6 +39,8 @@ def _chain_of(address: str) -> str:
39
39
  return "btc"
40
40
  if a.startswith("T") and len(a) == 34:
41
41
  return "tron"
42
+ if len(a) == 48 or ":" in a:
43
+ return "ton"
42
44
  return "sol"
43
45
 
44
46
 
@@ -32,7 +32,7 @@ _OKX_FILE = re.compile(r"https://static\.okx\.com/cdn/okx/por/chain/por_csv_(\d{
32
32
  # OKX network name -> crypttrace chain (only the chains crypttrace can trace)
33
33
  OKX_NETWORKS = {"BTC": "btc", "TRON": "tron", "SOL": "sol", "ETH": "eth", "BSC": "bsc",
34
34
  "POLYGON": "polygon", "ARBITRUM": "arbitrum", "OPTIMISM": "optimism",
35
- "BASE": "base"}
35
+ "BASE": "base", "TON": "ton"}
36
36
 
37
37
  _conn: Optional[sqlite3.Connection] = None
38
38
 
@@ -148,6 +148,8 @@ def import_okx(source: Optional[str] = None,
148
148
  chain = OKX_NETWORKS.get(network)
149
149
  if not chain:
150
150
  continue
151
+ if chain == "ton": # one spelling per account, the one wallets show
152
+ addr = addresses.ton_friendly(addr) or addr
151
153
  key = addr.lower()
152
154
  if key in rows:
153
155
  continue
@@ -280,5 +280,9 @@
280
280
  "bc1q9w4g79ndel72lygvwtqzem67z6uqv4yncvqjz3yn8my9swnwflxsutg4cx": {"name": "Bybit (2022 reserve list)", "type": "exchange", "chain": "btc", "source": "Bybit proof-of-reserves address list, Dec 2022 (static.bycustody.com/download/app/bybit_por_202212.csv, linked from Bybit's help center); may since be retired", "source_kind": "self-published", "added": "2026-09-23"},
281
281
  "bc1qjysjfd9t9aspttpjqzv68k0ydpe7pvyd5vlyn37868473lell5tqkz456m": {"name": "Bybit (2022 reserve list)", "type": "exchange", "chain": "btc", "source": "Bybit proof-of-reserves address list, Dec 2022 (static.bycustody.com/download/app/bybit_por_202212.csv, linked from Bybit's help center); may since be retired", "source_kind": "self-published", "added": "2026-09-23"},
282
282
  "1GrwDkr33gT6LuumniYjKEGjTLhsL5kmqC": {"name": "Bybit (2022 reserve list)", "type": "exchange", "chain": "btc", "source": "Bybit proof-of-reserves address list, Dec 2022 (static.bycustody.com/download/app/bybit_por_202212.csv, linked from Bybit's help center); may since be retired", "source_kind": "self-published", "added": "2026-09-23"},
283
- "bc1q2qqqt87kh33s0er58akh7v9cwjgd83z5smh9rp": {"name": "Bybit (2022 reserve list)", "type": "exchange", "chain": "btc", "source": "Bybit proof-of-reserves address list, Dec 2022 (static.bycustody.com/download/app/bybit_por_202212.csv, linked from Bybit's help center); may since be retired", "source_kind": "self-published", "added": "2026-09-23"}
283
+ "bc1q2qqqt87kh33s0er58akh7v9cwjgd83z5smh9rp": {"name": "Bybit (2022 reserve list)", "type": "exchange", "chain": "btc", "source": "Bybit proof-of-reserves address list, Dec 2022 (static.bycustody.com/download/app/bybit_por_202212.csv, linked from Bybit's help center); may since be retired", "source_kind": "self-published", "added": "2026-09-23"},
284
+
285
+ "UQAdCl0uTEHMLzvmsD6o69FXjxbNavwaP7pPAGMLCRf9aEbe": {"name": "Binance reserve wallet (custodied by Ceffu)", "type": "exchange", "chain": "ton", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-09-23; listed since 2026-08-24", "source_kind": "self-published", "added": "2026-09-29"},
286
+ "UQD4uGNdB4a3f52mYOZf0x1nCmdd1DAvrLppL0a1cetTYCQx": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ton", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-09-23; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-09-29"},
287
+ "UQDKHZ7e70CzqdvZCC83Z4WVR8POC_ZB0J1Y4zo88G-zCSRH": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ton", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-09-23; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-09-29"}
284
288
  }
@@ -177,6 +177,8 @@ TYPE_ICON = {
177
177
 
178
178
  def lookup(address: str) -> Optional[dict]:
179
179
  _load()
180
+ if len(address) == 48 or ":" in address: # TON, in whatever spelling
181
+ address = addresses.ton_friendly(address) or address
180
182
  hit = _KNOWN.get(address.lower())
181
183
  if hit:
182
184
  return hit
@@ -18,7 +18,7 @@ from crypttrace.labels import labels
18
18
 
19
19
  # Where thefts mostly move as stablecoins, a deposit address sweeps those rather
20
20
  # than the native coin (on Tron it may never send TRX at all).
21
- STABLECOIN_CHAINS = {"tron": ("usdt", "usdc"), "sol": ("usdc", "usdt")}
21
+ STABLECOIN_CHAINS = {"tron": ("usdt", "usdc"), "sol": ("usdc", "usdt"), "ton": ("usdt",)}
22
22
 
23
23
 
24
24
  def _one(address: str, chain: str, threshold: float, asset: Optional[dict]) -> Optional[dict]:
@@ -66,6 +66,10 @@ def detect(address: str, chain: str = "eth", threshold: float = 0.6,
66
66
  exchange wallets, otherwise None. Checks `asset` if given; otherwise the
67
67
  native coin and, on Tron and Solana, their stablecoins.
68
68
  """
69
+ # an exchange's own labelled wallet moving funds between its wallets is not
70
+ # a customer's deposit address
71
+ if labels.type_of(address) == "exchange" and not labels.is_deposit(address):
72
+ return None
69
73
  tries: List[Optional[dict]] = [asset]
70
74
  if asset is None and stablecoins:
71
75
  tries += [assets.resolve_asset(s, chain) for s in STABLECOIN_CHAINS.get(chain, ())]
@@ -24,6 +24,7 @@ from collections import defaultdict
24
24
  from typing import Dict, List, Optional, Tuple
25
25
 
26
26
  from crypttrace import analysis, assets, chains
27
+ from crypttrace.labels import labels
27
28
 
28
29
  # How closely two addresses must match at the ends (after "0x", "T", "bc1q"…).
29
30
  # Strong: rare by chance, flagged on resemblance alone. Weak: the cheap
@@ -40,6 +41,8 @@ SUFFIX_BITS = 32
40
41
  def _suffix_chars(address: str, chain: str) -> int:
41
42
  if chains.is_evm(chain):
42
43
  bits = 4.0 # hex
44
+ elif chain == "ton":
45
+ bits = 6.0 # base64
43
46
  elif address.lower().startswith(("bc1", "tb1")):
44
47
  bits = 5.0 # bech32
45
48
  else:
@@ -53,6 +56,8 @@ _ALL_TOKENS = {"contract": None, "symbol": "*"}
53
56
 
54
57
  def _body(address: str, chain: str) -> str:
55
58
  """The part of an address that varies — what a shortened display shows."""
59
+ if chain == "ton":
60
+ return chains.norm_addr(address, chain)[2:] # drop the "UQ" flag bytes
56
61
  a = address if chains.case_sensitive(chain) else address.lower()
57
62
  for p in ("0x", "bc1q", "bc1p", "bc1", "tb1q", "T"):
58
63
  if a.startswith(p) and (p != "T" or chain == "tron"):
@@ -216,6 +221,8 @@ def baited_payments(address: str, chain: str, limit: int = 1000,
216
221
  pattern into a specific claim.
217
222
  """
218
223
  me = chains.norm_addr(address, chain)
224
+ if labels.type_of(me) == "exchange" and not labels.is_deposit(me):
225
+ return [] # an exchange paying its own wallets lures no one
219
226
  rows = _history(address, chain, limit)
220
227
  first_active = min((int(r.get("timestamp") or 0) for r in rows if r.get("timestamp")), default=0)
221
228
  lure: Dict[str, dict] = {} # counterparty -> earliest lure
@@ -14,10 +14,10 @@ _STABLE = {"usdt", "usdc", "dai", "busd", "tusd", "usdp", "gusd", "frax", "lusd"
14
14
  # CoinGecko platform id per chain
15
15
  _PLATFORM = {"eth": "ethereum", "bsc": "binance-smart-chain", "polygon": "polygon-pos",
16
16
  "arbitrum": "arbitrum-one", "optimism": "optimistic-ethereum", "base": "base",
17
- "tron": "tron", "sol": "solana"}
17
+ "tron": "tron", "sol": "solana", "ton": "the-open-network"}
18
18
  _COINGECKO_NATIVE = {"eth": "ethereum", "bsc": "binancecoin", "polygon": "matic-network",
19
19
  "arbitrum": "ethereum", "optimism": "ethereum", "base": "ethereum",
20
- "btc": "bitcoin", "tron": "tron", "sol": "solana"}
20
+ "btc": "bitcoin", "tron": "tron", "sol": "solana", "ton": "the-open-network"}
21
21
 
22
22
  _cache: Dict[str, Optional[float]] = {}
23
23
 
@@ -105,7 +105,8 @@ def profile_rows_table(address: str, chain: str, balance: float, rows: list,
105
105
 
106
106
  def counterparties_rows_table(address: str, chain: str, rows: list, top: int = 10) -> Table:
107
107
  """Counterparties from normalized rows (works on every chain)."""
108
- me = address if chain in ("btc", "tron", "sol") else address.lower()
108
+ from crypttrace import chains
109
+ me = chains.norm_addr(address, chain)
109
110
  agg = {}
110
111
  for r in rows:
111
112
  frm, to = r.get("from", ""), r.get("to", "")
@@ -200,7 +201,7 @@ def crosschain_tree(address: str, chain: str, results: list) -> Tree:
200
201
  return root
201
202
 
202
203
 
203
- def funding_tree(address: str, hops: list) -> Tree:
204
+ def funding_tree(address: str, hops: list, symbol: str = "ETH") -> Tree:
204
205
  """Render a backward funding chain: target ← funder ← funder …"""
205
206
  root = Tree(addr_label(address))
206
207
  if not hops:
@@ -210,7 +211,7 @@ def funding_tree(address: str, hops: list) -> Tree:
210
211
  node = root
211
212
  for h in hops:
212
213
  when = _ts(h["timestamp"])
213
- edge = Text(f"◀── funded by {h['value']:.4f} ETH ({when}) ")
214
+ edge = Text(f"◀── funded by {h['value']:.4f} {symbol} ({when}) ")
214
215
  node = node.add(Text.assemble(edge, addr_label(h["funder"])))
215
216
  if h["terminal"]:
216
217
  kind = h["funder_type"]
@@ -29,7 +29,8 @@ from crypttrace import freeze as freeze_mod
29
29
  from crypttrace import trace as trace_mod
30
30
  from crypttrace.labels import labels
31
31
 
32
- SOURCES = {"btc": "mempool.space", "tron": "TronGrid", "sol": "Solana JSON-RPC"}
32
+ SOURCES = {"btc": "mempool.space", "tron": "TronGrid", "sol": "Solana JSON-RPC",
33
+ "ton": "toncenter"}
33
34
 
34
35
 
35
36
  def _now() -> str:
@@ -118,8 +118,10 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
118
118
  try:
119
119
  if contract == "*":
120
120
  # every token, whatever its contract (address-poisoning checks need fakes too)
121
+ # (Solana rows filed as "spl" by 0.5-0.10 name token accounts, not wallets)
121
122
  q = (f"{cols} WHERE chain=? AND (from_addr=? OR to_addr=?)"
122
- " AND contract IS NOT NULL AND contract!='' ORDER BY ts DESC")
123
+ " AND contract IS NOT NULL AND contract!='' AND contract!='spl'"
124
+ " ORDER BY ts DESC")
123
125
  args = (chain, address, address)
124
126
  elif contract:
125
127
  q = (f"{cols} WHERE chain=? AND (from_addr=? OR to_addr=?)"
@@ -119,7 +119,7 @@ def build_graph(address, chain, depth, branching, asset=None, direction="out"):
119
119
  # Bitcoin / Tron / Solana addresses are case-sensitive (base58); only EVM
120
120
  # addresses may be normalized to lowercase.
121
121
  from crypttrace import chains as _c
122
- _norm = (lambda a: a.lower()) if _c.is_evm(chain) else (lambda a: a)
122
+ _norm = lambda a: _c.norm_addr(a, chain)
123
123
 
124
124
  nodes = {}
125
125
  edges = []
@@ -43,7 +43,6 @@ def _save(d: Dict[str, dict]) -> None:
43
43
 
44
44
 
45
45
  _ALL_TOKENS = {"contract": None, "symbol": "*"}
46
- _SPL = {"contract": "spl", "symbol": "SPL"}
47
46
 
48
47
 
49
48
  def _key(row: dict) -> str:
@@ -62,8 +61,6 @@ def _rows(address: str, chain: str, limit: int) -> List[dict]:
62
61
  tokens = chains.transfers(address, chain, limit, asset=_ALL_TOKENS, fresh=True)
63
62
  except chains.ChainError:
64
63
  return rows # the native history alone still gets watched
65
- if chain == "sol": # Solana token rows carry no mint to tell tokens apart
66
- return rows + [{**r, "_asset": _SPL} for r in tokens]
67
64
  stables = {t["contract"].lower(): dict(t) for t in assets.tokens_for(chain).values()
68
65
  if t.get("stable")}
69
66
  return rows + [{**r, "_asset": stables[(r.get("contract") or "").lower()]}
@@ -216,7 +216,7 @@
216
216
  <option value="eth">eth</option><option value="bsc">bsc</option>
217
217
  <option value="polygon">polygon</option><option value="arbitrum">arbitrum</option>
218
218
  <option value="optimism">optimism</option><option value="base">base</option>
219
- <option value="btc">btc</option><option value="tron">tron</option><option value="sol">sol</option>
219
+ <option value="btc">btc</option><option value="tron">tron</option><option value="sol">sol</option><option value="ton">ton</option>
220
220
  </select>
221
221
  <select id="asset" title="Asset to trace">
222
222
  <option value="native">ETH (native)</option>
@@ -281,7 +281,8 @@ const EXPLORER={eth:'https://etherscan.io',bsc:'https://bscscan.com',polygon:'ht
281
281
  arbitrum:'https://arbiscan.io',optimism:'https://optimistic.etherscan.io',base:'https://basescan.org'};
282
282
  const NON_EVM={btc:a=>`https://mempool.space/address/${a}`,
283
283
  tron:a=>`https://tronscan.org/#/address/${a}`,
284
- sol:a=>`https://solscan.io/account/${a}`};
284
+ sol:a=>`https://solscan.io/account/${a}`,
285
+ ton:a=>`https://tonviewer.com/${a}`};
285
286
  const $=s=>document.querySelector(s);
286
287
  let network=null, lastGraph=null, rootId=null;
287
288
  const MIN_SCALE=0.42; // below this, nodes/labels become unreadable dots
@@ -483,7 +484,7 @@ async function run(){
483
484
  const g=await api(`/api/trace?address=${q(a)}&chain=${chain()}&depth=${$('#depth').value}&asset=${q($('#asset').value)}&direction=${dir}`);
484
485
  loader(80); renderGraph(g);
485
486
  setStatus(`${g.nodes.length} addresses · ${g.edges.length} transfers`);
486
- // BTC/Tron/Solana addresses are case-sensitive — never lowercase them
487
+ // BTC/Tron/Solana/TON addresses are case-sensitive — never lowercase them
487
488
  inspect(NON_EVM[chain()] ? a : a.toLowerCase()); loader(100);
488
489
  if(!g.edges.length){ // explain an empty graph instead of showing nothing
489
490
  const other=dir==='out'?'← where funds came from':'→ where funds went';
@@ -504,6 +505,7 @@ async function syncAssets(){
504
505
  $('#addr').placeholder = c==='btc' ? 'bc1… / 1… / 3… Bitcoin address'
505
506
  : c==='tron' ? 'T… Tron address'
506
507
  : c==='sol' ? 'Solana account address'
508
+ : c==='ton' ? 'UQ… / EQ… TON address'
507
509
  : '0x… paste an address to investigate';
508
510
  try{
509
511
  const r=await api(`/api/assets?chain=${c}`);
@@ -40,6 +40,10 @@ def validate(address: str, chain: str):
40
40
  elif chain == "tron":
41
41
  if not (a.startswith("T") and len(a) == 34):
42
42
  return f"'{a[:14]}…' is not a Tron address (expected T…, 34 chars)."
43
+ elif chain == "ton":
44
+ from crypttrace import addresses as _a
45
+ if not _a.validate(a, "ton")[0]:
46
+ return f"'{a[:14]}…' is not a TON address (expected UQ…/EQ…, 48 chars)."
43
47
  return None
44
48
 
45
49
 
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.10.0
4
- Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron and Solana
3
+ Version: 0.12.0
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
7
7
  Project-URL: Homepage, https://github.com/bobslayerX/crypttrace
8
8
  Project-URL: Issues, https://github.com/bobslayerX/crypttrace/issues
9
9
  Project-URL: Changelog, https://github.com/bobslayerX/crypttrace/blob/main/CHANGELOG.md
10
- Keywords: osint,blockchain,forensics,cryptocurrency,bitcoin,ethereum,tron,solana,investigation
10
+ Keywords: osint,blockchain,forensics,cryptocurrency,bitcoin,ethereum,tron,solana,ton,investigation
11
11
  Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Environment :: Console
13
13
  Classifier: Environment :: Web Environment
@@ -42,7 +42,7 @@ Dynamic: license-file
42
42
  Open-source OSINT toolkit for crypto investigations. Give it a suspicious
43
43
  address; it pulls the public on-chain history, labels known entities (exchanges,
44
44
  mixers, sanctioned wallets), scores risk, and maps where the funds moved —
45
- across Ethereum, Bitcoin, Tron, Solana and more.
45
+ across Ethereum, Bitcoin, Tron, Solana, TON and more.
46
46
 
47
47
  Built for the investigation that actually happens: *someone's crypto was stolen,
48
48
  here's the wallet — where did the money go, and where can it still be stopped?*
@@ -80,7 +80,7 @@ python selftest.py # offline checks, no API keys needed
80
80
  | | Needed? | How to get it |
81
81
  |---|---|---|
82
82
  | **EVM chains** (Ethereum, BSC, Polygon, Arbitrum, Optimism, Base) | **Required** | Free key at [etherscan.io/myapikey](https://etherscan.io/myapikey). One v2 key covers all EVM chains. |
83
- | **Bitcoin, Tron, Solana** | **Not required** | Work out of the box via public endpoints. |
83
+ | **Bitcoin, Tron, Solana, TON** | **Not required** | Work out of the box via public endpoints. |
84
84
 
85
85
  ```bash
86
86
  export ETHERSCAN_API_KEY=xxxx # required for EVM chains only
@@ -93,7 +93,7 @@ $env:ETHERSCAN_API_KEY = "xxxx" # this window only
93
93
  [Environment]::SetEnvironmentVariable("ETHERSCAN_API_KEY", "xxxx", "User") # permanently
94
94
  ```
95
95
 
96
- **About the keyless chains:** Bitcoin, Tron and Solana work with no signup at
96
+ **About the keyless chains:** Bitcoin, Tron, Solana and TON work with no signup at
97
97
  all, but their free public endpoints are **rate-limited**. crypttrace handles
98
98
  this for you — it caches every response, spaces requests out, and retries with
99
99
  backoff when a limit is hit. For deep traces you can raise the ceiling with your
@@ -102,6 +102,7 @@ own credentials (all optional):
102
102
  ```bash
103
103
  export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
104
104
  export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana RPC
105
+ export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
105
106
  ```
106
107
 
107
108
  (In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
@@ -114,11 +115,16 @@ export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana R
114
115
  | Bitcoin | `btc` | mempool.space | UTXO |
115
116
  | Tron (TRX + USDT-TRC20) | `tron` | TronGrid | account |
116
117
  | Solana (SOL + SPL) | `sol` | public JSON-RPC | account |
118
+ | TON (TON + jettons, e.g. USDT) | `ton` | toncenter v3 | account |
117
119
 
118
120
  Every network is normalized to the same transfer shape, so `profile`, `trace`,
119
121
  `funder`, `report` and the web graph behave identically everywhere. Bitcoin
120
122
  additionally unlocks `cluster`. Tron matters for everyday victim cases — most
121
123
  romance / "pig butchering" scams move USDT-TRC20 because fees are near zero.
124
+ TON matters for the same reason: USDT on TON is built into Telegram's wallet,
125
+ so scams that start in a Telegram chat often end there. TON addresses are
126
+ accepted in any form (`UQ…`, `EQ…` or raw `0:…`) and shown as wallets show
127
+ them (`UQ…`); jetton transfers are reported between the owners' wallets.
122
128
 
123
129
  ---
124
130
 
@@ -433,13 +439,14 @@ want:
433
439
  ```bash
434
440
  crypttrace trace 0xADDRESS --asset usdt # USDT on Ethereum
435
441
  crypttrace trace TADDRESS --chain tron --asset usdt # USDT-TRC20 on Tron
442
+ crypttrace trace UQADDRESS --chain ton --asset usdt # USDT on TON
436
443
  crypttrace tokens TADDRESS --chain tron
437
444
  ```
438
445
 
439
446
  Token contracts differ per chain, so the registry is chain-aware: `usdt` on Tron
440
447
  resolves to `TR7NHqje…jLj6t`, on Ethereum to `0xdac17f95…31ec7`. Known symbols
441
- are `usdt`, `usdc`, `dai`, `weth`, `wbtc` on EVM and `usdt`, `usdc` on
442
- Tron/Solana; you can also pass any contract address directly.
448
+ are `usdt`, `usdc`, `dai`, `weth`, `wbtc` on EVM, `usdt`, `usdc` on
449
+ Tron/Solana and `usdt` on TON; you can also pass any contract address directly.
443
450
 
444
451
  Matching is by **exact contract**, which matters: attackers routinely airdrop
445
452
  fake tokens named "USDT" to poison wallets, and those are ignored rather than
@@ -459,8 +466,8 @@ Add sources in `labels/labels.py` → `SOURCES`.
459
466
 
460
467
  On Bitcoin, Tron and Solana the labels cover the reserve wallets of Binance,
461
468
  OKX and HTX, plus Bybit's older 2022 list — each taken from the address list the
462
- exchange publishes itself for proof-of-reserves. That is what lets `offramp`
463
- flag deposit addresses on those chains. Funds sent to an exchange not on that
469
+ exchange publishes itself for proof-of-reserves; on TON, Binance's reserve
470
+ wallets. That is what lets `offramp` flag deposit addresses on those chains. Funds sent to an exchange not on that
464
471
  list still show as `unknown`. Every label records where it came from — see
465
472
  `crypttrace labels audit`.
466
473
 
@@ -561,6 +568,7 @@ src/crypttrace/
561
568
  bitcoin.py # Bitcoin UTXO (mempool.space) + clustering
562
569
  tron.py # Tron / TRC20 (TronGrid) + base58 conversion
563
570
  solana.py # Solana JSON-RPC
571
+ ton.py # TON + jettons (toncenter v3)
564
572
  http.py # shared cache, throttling, 429 backoff
565
573
  labels/
566
574
  known.json # curated label DB, one source per claim
@@ -589,10 +597,10 @@ cases/ # worked investigations with their data
589
597
 
590
598
  ## Roadmap
591
599
 
592
- - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
600
+ - More exchanges on Bitcoin, Tron, Solana (now: Binance, OKX, HTX, Bybit) and
601
+ TON (now: Binance),
593
602
  and refreshing these lists as the exchanges republish them
594
603
  - Internal transactions (completes `funder` and contract-mediated transfers)
595
- - Per-mint filtering for Solana SPL tokens
596
604
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
597
605
  - Spam/dust token filtering in `tokens`
598
606
  - Entity clustering on EVM via the gas-funding heuristic
@@ -34,6 +34,7 @@ src/crypttrace/fetchers/bitcoin.py
34
34
  src/crypttrace/fetchers/etherscan.py
35
35
  src/crypttrace/fetchers/http.py
36
36
  src/crypttrace/fetchers/solana.py
37
+ src/crypttrace/fetchers/ton.py
37
38
  src/crypttrace/fetchers/tron.py
38
39
  src/crypttrace/labels/__init__.py
39
40
  src/crypttrace/labels/audit.py
File without changes
File without changes