crypttrace 0.20.0__tar.gz → 0.22.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.20.0/src/crypttrace.egg-info → crypttrace-0.22.0}/PKG-INFO +14 -4
- {crypttrace-0.20.0 → crypttrace-0.22.0}/README.md +13 -3
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/chains.py +39 -4
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/solana.py +57 -18
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/ton.py +8 -5
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/tron.py +11 -7
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/funder.py +5 -3
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/store.py +17 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0/src/crypttrace.egg-info}/PKG-INFO +14 -4
- {crypttrace-0.20.0 → crypttrace-0.22.0}/LICENSE +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/pyproject.toml +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/setup.cfg +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/cli.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/drain.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/evidence.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/freeze.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/gascluster.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/investigate.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/known.json +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/labels.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/names.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/tonapi.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/tronscan.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/poisoning.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/render.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/spam.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/watch.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/web/index.html +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/webapp.py +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.22.0
|
|
4
4
|
Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron, Solana and TON
|
|
5
5
|
Author: bobslayerX
|
|
6
6
|
License-Expression: MIT
|
|
@@ -101,7 +101,7 @@ own credentials (all optional):
|
|
|
101
101
|
|
|
102
102
|
```bash
|
|
103
103
|
export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
|
|
104
|
-
export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own
|
|
104
|
+
export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own Solana RPC — 1,000 txs of history instead of 50
|
|
105
105
|
export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
|
|
106
106
|
export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
|
|
107
107
|
export TRONSCAN_API_KEY=xxxx # optional: Tron exchange and scam tags from Tronscan
|
|
@@ -109,6 +109,13 @@ export TRONSCAN_API_KEY=xxxx # optional: Tron exchange and scam tags f
|
|
|
109
109
|
|
|
110
110
|
(In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
|
|
111
111
|
|
|
112
|
+
**Solana history depth.** Solana has no "transfers of an address" call: every
|
|
113
|
+
transaction is a separate request, and the public endpoint sustains about one
|
|
114
|
+
a second. So on it crypttrace reads the latest **50** transactions of each
|
|
115
|
+
address (about a minute); with your own endpoint (a free Helius or QuickNode
|
|
116
|
+
key) it reads up to **1,000**, in parallel. `CRYPTTRACE_SOLANA_MAX_TXS` sets
|
|
117
|
+
the number either way.
|
|
118
|
+
|
|
112
119
|
## Supported chains
|
|
113
120
|
|
|
114
121
|
| Chain | `--chain` | Data source | Model |
|
|
@@ -415,8 +422,11 @@ coin.
|
|
|
415
422
|
then whoever funded that funder. A fresh laundering wallet must be bootstrapped
|
|
416
423
|
from somewhere, and the chain frequently terminates at an exchange withdrawal —
|
|
417
424
|
an identification point. A core primitive for tying "unrelated" wallets to one
|
|
418
|
-
controller.
|
|
419
|
-
|
|
425
|
+
controller. On EVM chains, Tron and TON the history is read from its start, so
|
|
426
|
+
the first funding of a years-old, busy wallet is found too; Bitcoin and Solana
|
|
427
|
+
explorers list newest first, so there it is the earliest of the latest 200
|
|
428
|
+
transfers. On EVM chains a first funding sent by a contract (a mixer or bridge
|
|
429
|
+
withdrawal, an internal transaction) counts and is flagged.
|
|
420
430
|
|
|
421
431
|
### Victim lists and timing analysis
|
|
422
432
|
|
|
@@ -67,7 +67,7 @@ own credentials (all optional):
|
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
69
|
export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
|
|
70
|
-
export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own
|
|
70
|
+
export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own Solana RPC — 1,000 txs of history instead of 50
|
|
71
71
|
export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
|
|
72
72
|
export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
|
|
73
73
|
export TRONSCAN_API_KEY=xxxx # optional: Tron exchange and scam tags from Tronscan
|
|
@@ -75,6 +75,13 @@ export TRONSCAN_API_KEY=xxxx # optional: Tron exchange and scam tags f
|
|
|
75
75
|
|
|
76
76
|
(In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
|
|
77
77
|
|
|
78
|
+
**Solana history depth.** Solana has no "transfers of an address" call: every
|
|
79
|
+
transaction is a separate request, and the public endpoint sustains about one
|
|
80
|
+
a second. So on it crypttrace reads the latest **50** transactions of each
|
|
81
|
+
address (about a minute); with your own endpoint (a free Helius or QuickNode
|
|
82
|
+
key) it reads up to **1,000**, in parallel. `CRYPTTRACE_SOLANA_MAX_TXS` sets
|
|
83
|
+
the number either way.
|
|
84
|
+
|
|
78
85
|
## Supported chains
|
|
79
86
|
|
|
80
87
|
| Chain | `--chain` | Data source | Model |
|
|
@@ -381,8 +388,11 @@ coin.
|
|
|
381
388
|
then whoever funded that funder. A fresh laundering wallet must be bootstrapped
|
|
382
389
|
from somewhere, and the chain frequently terminates at an exchange withdrawal —
|
|
383
390
|
an identification point. A core primitive for tying "unrelated" wallets to one
|
|
384
|
-
controller.
|
|
385
|
-
|
|
391
|
+
controller. On EVM chains, Tron and TON the history is read from its start, so
|
|
392
|
+
the first funding of a years-old, busy wallet is found too; Bitcoin and Solana
|
|
393
|
+
explorers list newest first, so there it is the earliest of the latest 200
|
|
394
|
+
transfers. On EVM chains a first funding sent by a contract (a mixer or bridge
|
|
395
|
+
withdrawal, an internal transaction) counts and is flagged.
|
|
386
396
|
|
|
387
397
|
### Victim lists and timing analysis
|
|
388
398
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.22.0"
|
|
@@ -101,12 +101,14 @@ def balance(address: str, chain: str = "eth") -> float:
|
|
|
101
101
|
|
|
102
102
|
# ---------- normalized row builders ----------
|
|
103
103
|
|
|
104
|
-
def _evm_native(address: str, chain: str, limit: int) -> List[Dict]:
|
|
104
|
+
def _evm_native(address: str, chain: str, limit: int, oldest: bool = False) -> List[Dict]:
|
|
105
105
|
"""The native coin moved: normal transactions plus internal ones (sent by contracts —
|
|
106
|
-
withdrawals from mixers and exchanges, ETH back from swaps). Failed calls moved nothing.
|
|
106
|
+
withdrawals from mixers and exchanges, ETH back from swaps). Failed calls moved nothing.
|
|
107
|
+
`oldest` reads the start of the history instead of its latest part."""
|
|
107
108
|
rows = []
|
|
108
|
-
|
|
109
|
-
calls
|
|
109
|
+
sort = "asc" if oldest else "desc"
|
|
110
|
+
calls = [(tx, False) for tx in etherscan.get_txs(address, chain, limit=limit, sort=sort)]
|
|
111
|
+
calls += [(tx, True) for tx in etherscan.get_internal_txs(address, chain, limit=limit, sort=sort)]
|
|
110
112
|
for tx, internal in calls:
|
|
111
113
|
if str(tx.get("isError", "0")) == "1":
|
|
112
114
|
continue
|
|
@@ -175,6 +177,12 @@ def transfers(address: str, chain: str = "eth", limit: int = 1000,
|
|
|
175
177
|
|
|
176
178
|
Served from the local store when it holds recent data for this address, so
|
|
177
179
|
repeat analysis costs nothing and works offline.
|
|
180
|
+
|
|
181
|
+
`oldest_first` returns the *first* `limit` transfers of the address, oldest
|
|
182
|
+
first — what "who funded this wallet" needs. On EVM chains, Tron and TON the
|
|
183
|
+
explorers can list a history from its start, so a busy wallet's first
|
|
184
|
+
funding is found however long ago it was; on Bitcoin and Solana (newest
|
|
185
|
+
first only) it is the oldest of the latest `limit`.
|
|
178
186
|
"""
|
|
179
187
|
check(chain)
|
|
180
188
|
if chain == "ton":
|
|
@@ -189,6 +197,11 @@ def transfers(address: str, chain: str = "eth", limit: int = 1000,
|
|
|
189
197
|
else:
|
|
190
198
|
asset_key = (contract or "*").lower()
|
|
191
199
|
skip_read = fresh or FORCE_FRESH
|
|
200
|
+
if oldest_first and not asset and not OFFLINE and (is_evm(chain) or chain in ("tron", "ton")):
|
|
201
|
+
# the store keeps the latest part of a history; only a complete one holds its start
|
|
202
|
+
if not (USE_STORE and not skip_read and store.is_fresh(chain, address, asset_key)
|
|
203
|
+
and store.is_complete(chain, address, asset_key)):
|
|
204
|
+
return _oldest(address, chain, limit, fresh or FORCE_FRESH)
|
|
192
205
|
if USE_STORE and not skip_read:
|
|
193
206
|
if OFFLINE or store.is_fresh(chain, address, asset_key):
|
|
194
207
|
rows = store.load(address=address, chain=chain, contract=asset_key,
|
|
@@ -239,6 +252,28 @@ def transfers(address: str, chain: str = "eth", limit: int = 1000,
|
|
|
239
252
|
return rows
|
|
240
253
|
|
|
241
254
|
|
|
255
|
+
def _oldest(address: str, chain: str, limit: int, fresh: bool) -> List[Dict]:
|
|
256
|
+
"""The first `limit` native transfers of an address, oldest first.
|
|
257
|
+
|
|
258
|
+
Not written to the store: it keeps the latest part of each history, and a
|
|
259
|
+
start-of-history slice filed there would pass for it."""
|
|
260
|
+
was_fresh = _http.FRESH
|
|
261
|
+
_http.FRESH = was_fresh or fresh
|
|
262
|
+
try:
|
|
263
|
+
if is_evm(chain):
|
|
264
|
+
rows = _evm_native(address, chain, limit, oldest=True)
|
|
265
|
+
elif chain == "tron":
|
|
266
|
+
rows = tron.transfers(address, limit, oldest=True)
|
|
267
|
+
else:
|
|
268
|
+
rows = ton.transfers(address, limit, oldest=True)
|
|
269
|
+
except _UPSTREAM_ERRORS as e:
|
|
270
|
+
raise ChainError(str(e))
|
|
271
|
+
finally:
|
|
272
|
+
_http.FRESH = was_fresh
|
|
273
|
+
rows.sort(key=lambda r: r.get("timestamp", 0))
|
|
274
|
+
return rows
|
|
275
|
+
|
|
276
|
+
|
|
242
277
|
def flows(address: str, chain: str, top: int, direction: str = "out",
|
|
243
278
|
limit: int = 1000, asset: Optional[dict] = None):
|
|
244
279
|
"""Aggregated value per counterparty: [(other, total, tx_count)].
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
"""Solana fetcher via public JSON-RPC — no API key required.
|
|
2
2
|
|
|
3
3
|
Solana has no "list transfers for an address" endpoint: you fetch the address's
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
signatures (page after page), then the transactions, and read the parsed
|
|
5
|
+
instructions; failed transactions are skipped — they moved nothing.
|
|
6
|
+
|
|
7
|
+
The public endpoint sustains about one getTransaction call a second — faster,
|
|
8
|
+
and most calls come back 429 with a 10-second wait; JSON-RPC batches fare worse.
|
|
9
|
+
So on it transactions are fetched one at a time, paced to that rate, and history
|
|
10
|
+
is capped at MAX_TXS = 50 transactions per address. With your own endpoint in
|
|
11
|
+
CRYPTTRACE_SOLANA_RPC (e.g. a free Helius or QuickNode key) calls run in parallel
|
|
12
|
+
and the cap is 1,000. CRYPTTRACE_SOLANA_MAX_TXS overrides either.
|
|
7
13
|
"""
|
|
8
14
|
import os
|
|
9
15
|
from typing import List, Dict
|
|
@@ -12,9 +18,15 @@ import requests
|
|
|
12
18
|
|
|
13
19
|
from crypttrace import assets
|
|
14
20
|
|
|
15
|
-
|
|
21
|
+
PUBLIC_RPC = "https://api.mainnet-beta.solana.com"
|
|
22
|
+
RPC = os.environ.get("CRYPTTRACE_SOLANA_RPC", PUBLIC_RPC)
|
|
16
23
|
LAMPORTS = 1_000_000_000
|
|
17
|
-
MAX_TXS =
|
|
24
|
+
MAX_TXS = int(os.environ.get("CRYPTTRACE_SOLANA_MAX_TXS") or (50 if RPC == PUBLIC_RPC else 1000))
|
|
25
|
+
WORKERS = 1 if RPC == PUBLIC_RPC else 8 # calls in flight; the HTTP layer spaces their starts
|
|
26
|
+
if RPC == PUBLIC_RPC:
|
|
27
|
+
from crypttrace.fetchers import http as _http
|
|
28
|
+
_http.MIN_INTERVAL["api.mainnet-beta.solana.com"] = 1.1
|
|
29
|
+
SIG_PAGE = 1000 # the most getSignaturesForAddress returns at once
|
|
18
30
|
|
|
19
31
|
# mint -> symbol for the tokens crypttrace knows; others show as "SPL"
|
|
20
32
|
_KNOWN_MINTS = {t["contract"]: t["symbol"] for t in assets.tokens_for("sol").values()}
|
|
@@ -53,15 +65,44 @@ def balance(address: str) -> float:
|
|
|
53
65
|
|
|
54
66
|
|
|
55
67
|
def _signatures(address: str, limit: int) -> List[str]:
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
"""Signatures of successful transactions, newest first, page after page."""
|
|
69
|
+
want = min(limit, MAX_TXS)
|
|
70
|
+
out: List[str] = []
|
|
71
|
+
before = None
|
|
72
|
+
while len(out) < want:
|
|
73
|
+
opts = {"limit": min(SIG_PAGE, want - len(out) + 50)}
|
|
74
|
+
if before:
|
|
75
|
+
opts["before"] = before
|
|
76
|
+
page = _rpc("getSignaturesForAddress", [address, opts]) or []
|
|
77
|
+
if not page:
|
|
78
|
+
break
|
|
79
|
+
out += [s["signature"] for s in page if s.get("signature") and s.get("err") is None]
|
|
80
|
+
before = page[-1].get("signature")
|
|
81
|
+
if len(page) < opts["limit"]:
|
|
82
|
+
break
|
|
83
|
+
return out[:want]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _transactions(sigs: List[str]) -> List[tuple]:
|
|
87
|
+
"""(signature, transaction) for each signature — one cached call each, a few at a time."""
|
|
88
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
89
|
+
|
|
90
|
+
def one(sig):
|
|
91
|
+
try:
|
|
92
|
+
return sig, _rpc("getTransaction", [sig, {"encoding": "jsonParsed",
|
|
93
|
+
"maxSupportedTransactionVersion": 1}])
|
|
94
|
+
except SolanaError:
|
|
95
|
+
return sig, None
|
|
96
|
+
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
|
|
97
|
+
return list(pool.map(one, sigs))
|
|
58
98
|
|
|
59
99
|
|
|
60
|
-
def _parse_tx(sig: str) -> List[Dict]:
|
|
61
|
-
tx
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
100
|
+
def _parse_tx(sig: str, tx=None) -> List[Dict]:
|
|
101
|
+
if tx is None:
|
|
102
|
+
tx = _rpc("getTransaction", [sig, {"encoding": "jsonParsed",
|
|
103
|
+
"maxSupportedTransactionVersion": 1}])
|
|
104
|
+
if not tx or (tx.get("meta") or {}).get("err") is not None:
|
|
105
|
+
return [] # a failed transaction moved nothing
|
|
65
106
|
ts = int(tx.get("blockTime") or 0)
|
|
66
107
|
rows = []
|
|
67
108
|
meta = tx.get("meta") or {}
|
|
@@ -121,11 +162,9 @@ def _parse_tx(sig: str) -> List[Dict]:
|
|
|
121
162
|
|
|
122
163
|
|
|
123
164
|
def transfers(address: str, limit: int = MAX_TXS) -> List[Dict]:
|
|
124
|
-
"""Normalized transfer rows
|
|
165
|
+
"""Normalized transfer rows from the latest `limit` (at most MAX_TXS) successful transactions."""
|
|
125
166
|
rows: List[Dict] = []
|
|
126
|
-
for sig in _signatures(address, limit):
|
|
127
|
-
|
|
128
|
-
rows.extend(_parse_tx(sig))
|
|
129
|
-
except SolanaError:
|
|
130
|
-
continue
|
|
167
|
+
for sig, tx in _transactions(_signatures(address, limit)):
|
|
168
|
+
if tx: # unreadable ones are skipped, not refetched
|
|
169
|
+
rows.extend(_parse_tx(sig, tx))
|
|
131
170
|
return rows
|
|
@@ -53,12 +53,14 @@ def _get(path: str, params: Optional[dict] = None, timeout: int = 30) -> dict:
|
|
|
53
53
|
return data if isinstance(data, dict) else {}
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def _pages(path: str, key: str, params: dict, limit: int, page: int
|
|
56
|
+
def _pages(path: str, key: str, params: dict, limit: int, page: int,
|
|
57
|
+
oldest: bool = False) -> List[dict]:
|
|
57
58
|
items: List[dict] = []
|
|
58
59
|
offset = 0
|
|
60
|
+
sort = "asc" if oldest else "desc"
|
|
59
61
|
while len(items) < limit:
|
|
60
62
|
n = min(page, limit - len(items))
|
|
61
|
-
batch = _get(path, dict(params, limit=n, offset=offset, sort=
|
|
63
|
+
batch = _get(path, dict(params, limit=n, offset=offset, sort=sort)).get(key) or []
|
|
62
64
|
items += batch
|
|
63
65
|
if len(batch) < n:
|
|
64
66
|
break
|
|
@@ -103,11 +105,12 @@ def balance(address: str) -> float:
|
|
|
103
105
|
return int(d.get("balance") or 0) / NANO
|
|
104
106
|
|
|
105
107
|
|
|
106
|
-
def transfers(address: str, limit: int = 1000) -> List[Dict]:
|
|
107
|
-
"""TON moved in and out of `address`: its incoming message and outgoing ones
|
|
108
|
+
def transfers(address: str, limit: int = 1000, oldest: bool = False) -> List[Dict]:
|
|
109
|
+
"""TON moved in and out of `address`: its incoming message and outgoing ones;
|
|
110
|
+
from the latest transactions, or with `oldest` from the first."""
|
|
108
111
|
me = _addr(address)
|
|
109
112
|
rows = []
|
|
110
|
-
for tx in _pages("/transactions", "transactions", {"account": address}, limit, TX_PAGE):
|
|
113
|
+
for tx in _pages("/transactions", "transactions", {"account": address}, limit, TX_PAGE, oldest):
|
|
111
114
|
ts, h = int(tx.get("now") or 0), _hex(tx.get("hash", ""))
|
|
112
115
|
msgs = [tx.get("in_msg") or {}] + list(tx.get("out_msgs") or [])
|
|
113
116
|
for m in msgs:
|
|
@@ -85,13 +85,16 @@ def balance(address: str) -> float:
|
|
|
85
85
|
PAGE = 200 # TronGrid's page size
|
|
86
86
|
|
|
87
87
|
|
|
88
|
-
def _pages(path: str, limit: int) -> List[Dict]:
|
|
89
|
-
"""Up to `limit` items, following TronGrid's fingerprint cursor page by page
|
|
88
|
+
def _pages(path: str, limit: int, oldest: bool = False) -> List[Dict]:
|
|
89
|
+
"""Up to `limit` items, following TronGrid's fingerprint cursor page by page;
|
|
90
|
+
the latest ones, or with `oldest` the first ones.
|
|
90
91
|
|
|
91
92
|
One page (200 items) used to be all we read, so a busy wallet's history
|
|
92
93
|
ended a few days back and older transfers were simply invisible."""
|
|
93
94
|
items: List[Dict] = []
|
|
94
95
|
params = {"limit": min(limit, PAGE)}
|
|
96
|
+
if oldest:
|
|
97
|
+
params["order_by"] = "block_timestamp,asc"
|
|
95
98
|
while len(items) < limit:
|
|
96
99
|
d = _get(path, dict(params))
|
|
97
100
|
page = d.get("data", []) or []
|
|
@@ -103,10 +106,10 @@ def _pages(path: str, limit: int) -> List[Dict]:
|
|
|
103
106
|
return items[:limit]
|
|
104
107
|
|
|
105
108
|
|
|
106
|
-
def _native(address: str, limit: int) -> List[Dict]:
|
|
109
|
+
def _native(address: str, limit: int, oldest: bool = False) -> List[Dict]:
|
|
107
110
|
# the endpoint lists every transaction type; TRX transfers are a subset of it
|
|
108
111
|
rows = []
|
|
109
|
-
for tx in _pages(f"/v1/accounts/{address}/transactions", limit):
|
|
112
|
+
for tx in _pages(f"/v1/accounts/{address}/transactions", limit, oldest):
|
|
110
113
|
try:
|
|
111
114
|
c = (tx.get("raw_data", {}).get("contract") or [])[0]
|
|
112
115
|
if c.get("type") != "TransferContract":
|
|
@@ -147,6 +150,7 @@ def token_transfers(address: str, limit: int = 200) -> List[Dict]:
|
|
|
147
150
|
return rows
|
|
148
151
|
|
|
149
152
|
|
|
150
|
-
def transfers(address: str, limit: int = 200) -> List[Dict]:
|
|
151
|
-
"""Native TRX transfers (use token_transfers for USDT-TRC20)
|
|
152
|
-
|
|
153
|
+
def transfers(address: str, limit: int = 200, oldest: bool = False) -> List[Dict]:
|
|
154
|
+
"""Native TRX transfers (use token_transfers for USDT-TRC20); the latest
|
|
155
|
+
`limit`, or with `oldest` the first."""
|
|
156
|
+
return _native(address, limit, oldest)
|
|
@@ -7,9 +7,11 @@ funding hub or — the prize — a centralised-exchange withdrawal, which is a K
|
|
|
7
7
|
identification point. Investigators like ZachXBT lean on this constantly to tie
|
|
8
8
|
"unrelated" laundering wallets back to a single controller.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
The history is read from its start on EVM chains, Tron and TON, so a busy
|
|
11
|
+
wallet's first funding is found however long ago it came. Bitcoin and Solana
|
|
12
|
+
explorers list newest first, so there it is the earliest of the latest 200
|
|
13
|
+
transfers. On EVM chains a first funding sent by a contract (an internal
|
|
14
|
+
transaction — a mixer or bridge withdrawal) counts, and is flagged.
|
|
13
15
|
"""
|
|
14
16
|
from typing import Optional, List, Dict
|
|
15
17
|
|
|
@@ -106,6 +106,12 @@ def _conn() -> sqlite3.Connection:
|
|
|
106
106
|
c.execute(f"DELETE FROM fetched WHERE chain IN ({marks}) AND asset=''", evm)
|
|
107
107
|
c.execute("PRAGMA user_version=5")
|
|
108
108
|
c.commit()
|
|
109
|
+
if c.execute("PRAGMA user_version").fetchone()[0] < 6:
|
|
110
|
+
# 0.21: Solana history skipped every version-1 transaction — read it again
|
|
111
|
+
c.execute("DELETE FROM transfers WHERE chain='sol'")
|
|
112
|
+
c.execute("DELETE FROM fetched WHERE chain='sol'")
|
|
113
|
+
c.execute("PRAGMA user_version=6")
|
|
114
|
+
c.commit()
|
|
109
115
|
return c
|
|
110
116
|
|
|
111
117
|
|
|
@@ -162,6 +168,17 @@ def is_fresh(chain: str, address: str, asset_key: str = "",
|
|
|
162
168
|
return a is not None and a <= max_age
|
|
163
169
|
|
|
164
170
|
|
|
171
|
+
def is_complete(chain: str, address: str, asset_key: str = "") -> bool:
|
|
172
|
+
"""Whether the last fetch of this address read its whole history."""
|
|
173
|
+
c = _conn()
|
|
174
|
+
try:
|
|
175
|
+
row = c.execute("SELECT complete FROM fetched WHERE chain=? AND address=? AND asset=?",
|
|
176
|
+
(chain, address, asset_key)).fetchone()
|
|
177
|
+
finally:
|
|
178
|
+
c.close()
|
|
179
|
+
return bool(row and row[0])
|
|
180
|
+
|
|
181
|
+
|
|
165
182
|
def load(chain: str, address: str, contract: str = "", native_symbol: str = "") -> List[dict]:
|
|
166
183
|
"""Every stored transfer touching this address, newest first."""
|
|
167
184
|
c = _conn()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.22.0
|
|
4
4
|
Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron, Solana and TON
|
|
5
5
|
Author: bobslayerX
|
|
6
6
|
License-Expression: MIT
|
|
@@ -101,7 +101,7 @@ own credentials (all optional):
|
|
|
101
101
|
|
|
102
102
|
```bash
|
|
103
103
|
export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
|
|
104
|
-
export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own
|
|
104
|
+
export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own Solana RPC — 1,000 txs of history instead of 50
|
|
105
105
|
export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
|
|
106
106
|
export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
|
|
107
107
|
export TRONSCAN_API_KEY=xxxx # optional: Tron exchange and scam tags from Tronscan
|
|
@@ -109,6 +109,13 @@ export TRONSCAN_API_KEY=xxxx # optional: Tron exchange and scam tags f
|
|
|
109
109
|
|
|
110
110
|
(In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
|
|
111
111
|
|
|
112
|
+
**Solana history depth.** Solana has no "transfers of an address" call: every
|
|
113
|
+
transaction is a separate request, and the public endpoint sustains about one
|
|
114
|
+
a second. So on it crypttrace reads the latest **50** transactions of each
|
|
115
|
+
address (about a minute); with your own endpoint (a free Helius or QuickNode
|
|
116
|
+
key) it reads up to **1,000**, in parallel. `CRYPTTRACE_SOLANA_MAX_TXS` sets
|
|
117
|
+
the number either way.
|
|
118
|
+
|
|
112
119
|
## Supported chains
|
|
113
120
|
|
|
114
121
|
| Chain | `--chain` | Data source | Model |
|
|
@@ -415,8 +422,11 @@ coin.
|
|
|
415
422
|
then whoever funded that funder. A fresh laundering wallet must be bootstrapped
|
|
416
423
|
from somewhere, and the chain frequently terminates at an exchange withdrawal —
|
|
417
424
|
an identification point. A core primitive for tying "unrelated" wallets to one
|
|
418
|
-
controller.
|
|
419
|
-
|
|
425
|
+
controller. On EVM chains, Tron and TON the history is read from its start, so
|
|
426
|
+
the first funding of a years-old, busy wallet is found too; Bitcoin and Solana
|
|
427
|
+
explorers list newest first, so there it is the earliest of the latest 200
|
|
428
|
+
transfers. On EVM chains a first funding sent by a contract (a mixer or bridge
|
|
429
|
+
withdrawal, an internal transaction) counts and is flagged.
|
|
420
430
|
|
|
421
431
|
### Victim lists and timing analysis
|
|
422
432
|
|
|
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
|
|
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
|
|
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
|