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.
- {crypttrace-0.10.0/src/crypttrace.egg-info → crypttrace-0.12.0}/PKG-INFO +20 -12
- {crypttrace-0.10.0 → crypttrace-0.12.0}/README.md +17 -9
- {crypttrace-0.10.0 → crypttrace-0.12.0}/pyproject.toml +2 -2
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/addresses.py +58 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/analysis.py +1 -1
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/assets.py +11 -2
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/chains.py +27 -12
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/cli.py +2 -1
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/fetchers/etherscan.py +2 -1
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/fetchers/http.py +5 -1
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/fetchers/solana.py +40 -7
- crypttrace-0.12.0/src/crypttrace/fetchers/ton.py +136 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/funder.py +9 -4
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/labels/audit.py +2 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/labels/bulk.py +3 -1
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/labels/known.json +5 -1
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/labels/labels.py +2 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/offramp.py +5 -1
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/poisoning.py +7 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/prices.py +2 -2
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/render.py +4 -3
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/report.py +2 -1
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/store.py +3 -1
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/trace.py +1 -1
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/watch.py +0 -3
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/web/index.html +5 -3
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/webapp.py +4 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0/src/crypttrace.egg-info}/PKG-INFO +20 -12
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/LICENSE +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/setup.cfg +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/fetchers/tron.py +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/freeze.py +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/investigate.py +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.10.0 → crypttrace-0.12.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {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.
|
|
4
|
-
Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron and
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
rows.append({
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
current =
|
|
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)
|
|
@@ -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
|
-
|
|
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}
|
|
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!=''
|
|
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 =
|
|
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.
|
|
4
|
-
Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron and
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|