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.
Files changed (54) hide show
  1. {crypttrace-0.20.0/src/crypttrace.egg-info → crypttrace-0.22.0}/PKG-INFO +14 -4
  2. {crypttrace-0.20.0 → crypttrace-0.22.0}/README.md +13 -3
  3. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/chains.py +39 -4
  5. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/solana.py +57 -18
  6. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/ton.py +8 -5
  7. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/tron.py +11 -7
  8. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/funder.py +5 -3
  9. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/store.py +17 -0
  10. {crypttrace-0.20.0 → crypttrace-0.22.0/src/crypttrace.egg-info}/PKG-INFO +14 -4
  11. {crypttrace-0.20.0 → crypttrace-0.22.0}/LICENSE +0 -0
  12. {crypttrace-0.20.0 → crypttrace-0.22.0}/pyproject.toml +0 -0
  13. {crypttrace-0.20.0 → crypttrace-0.22.0}/setup.cfg +0 -0
  14. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/addresses.py +0 -0
  15. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/analysis.py +0 -0
  16. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/assess.py +0 -0
  17. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/assets.py +0 -0
  18. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/bridges.py +0 -0
  19. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/cli.py +0 -0
  20. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/config.py +0 -0
  21. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/drain.py +0 -0
  22. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/evidence.py +0 -0
  23. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/__init__.py +0 -0
  24. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  25. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  26. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/fetchers/http.py +0 -0
  27. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/freeze.py +0 -0
  28. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/gascluster.py +0 -0
  29. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/investigate.py +0 -0
  30. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/__init__.py +0 -0
  31. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/audit.py +0 -0
  32. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/bulk.py +0 -0
  33. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/known.json +0 -0
  34. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/labels.py +0 -0
  35. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/names.py +0 -0
  36. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/partial.json +0 -0
  37. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/tonapi.py +0 -0
  38. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/labels/tronscan.py +0 -0
  39. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/offramp.py +0 -0
  40. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/poisoning.py +0 -0
  41. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/prices.py +0 -0
  42. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/render.py +0 -0
  43. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/report.py +0 -0
  44. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/spam.py +0 -0
  45. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/trace.py +0 -0
  46. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/verify.py +0 -0
  47. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/watch.py +0 -0
  48. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/web/index.html +0 -0
  49. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace/webapp.py +0 -0
  50. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
  51. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  52. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  53. {crypttrace-0.20.0 → crypttrace-0.22.0}/src/crypttrace.egg-info/requires.txt +0 -0
  54. {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.20.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 (faster) Solana RPC
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. (Uses external transactions; wallets first funded by an internal
419
- contract call need internal-tx data — a planned extension.)
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 (faster) Solana RPC
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. (Uses external transactions; wallets first funded by an internal
385
- contract call need internal-tx data — a planned extension.)
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.20.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
- calls = [(tx, False) for tx in etherscan.get_txs(address, chain, limit=limit)]
109
- calls += [(tx, True) for tx in etherscan.get_internal_txs(address, chain, limit=limit)]
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
- recent signatures, then each transaction, and read the parsed instructions. That
5
- means one RPC call per transaction, so this fetcher is slower and intentionally
6
- capped. Set CRYPTTRACE_SOLANA_RPC to use your own (faster) endpoint.
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
- RPC = os.environ.get("CRYPTTRACE_SOLANA_RPC", "https://api.mainnet-beta.solana.com")
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 = 25 # keep the number of RPC round-trips sane
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
- res = _rpc("getSignaturesForAddress", [address, {"limit": min(limit, MAX_TXS)}]) or []
57
- return [s.get("signature") for s in res if s.get("signature")]
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 = _rpc("getTransaction", [sig, {"encoding": "jsonParsed",
62
- "maxSupportedTransactionVersion": 0}])
63
- if not tx:
64
- return []
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. Note: capped at MAX_TXS transactions."""
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
- try:
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) -> List[dict]:
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="desc")).get(key) or []
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
- return _native(address, limit)
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
- Limitation: this uses normal (external) transactions. Wallets first funded by an
11
- internal contract call (some exchanges, disperse tools) need internal-tx data,
12
- a natural next extension.
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.20.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 (faster) Solana RPC
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. (Uses external transactions; wallets first funded by an internal
419
- contract call need internal-tx data — a planned extension.)
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