crypttrace 0.2.0__py3-none-any.whl
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/__init__.py +2 -0
- crypttrace/addresses.py +132 -0
- crypttrace/analysis.py +222 -0
- crypttrace/assess.py +273 -0
- crypttrace/assets.py +68 -0
- crypttrace/bridges.py +90 -0
- crypttrace/chains.py +254 -0
- crypttrace/cli.py +795 -0
- crypttrace/config.py +27 -0
- crypttrace/fetchers/__init__.py +0 -0
- crypttrace/fetchers/bitcoin.py +186 -0
- crypttrace/fetchers/etherscan.py +114 -0
- crypttrace/fetchers/http.py +139 -0
- crypttrace/fetchers/solana.py +97 -0
- crypttrace/fetchers/tron.py +128 -0
- crypttrace/funder.py +76 -0
- crypttrace/investigate.py +204 -0
- crypttrace/labels/__init__.py +0 -0
- crypttrace/labels/audit.py +102 -0
- crypttrace/labels/known.json +148 -0
- crypttrace/labels/labels.py +199 -0
- crypttrace/labels/partial.json +15 -0
- crypttrace/offramp.py +58 -0
- crypttrace/prices.py +90 -0
- crypttrace/render.py +241 -0
- crypttrace/report.py +175 -0
- crypttrace/store.py +215 -0
- crypttrace/trace.py +165 -0
- crypttrace/verify.py +153 -0
- crypttrace/watch.py +138 -0
- crypttrace/web/index.html +660 -0
- crypttrace/webapp.py +292 -0
- crypttrace-0.2.0.dist-info/METADATA +503 -0
- crypttrace-0.2.0.dist-info/RECORD +38 -0
- crypttrace-0.2.0.dist-info/WHEEL +5 -0
- crypttrace-0.2.0.dist-info/entry_points.txt +2 -0
- crypttrace-0.2.0.dist-info/licenses/LICENSE +21 -0
- crypttrace-0.2.0.dist-info/top_level.txt +1 -0
crypttrace/config.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Configuration and shared constants."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# Data dir for cache + labels (created on first run)
|
|
6
|
+
DATA_DIR = Path(os.environ.get("CRYPTTRACE_HOME", Path.home() / ".crypttrace"))
|
|
7
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
8
|
+
|
|
9
|
+
CACHE_DB = DATA_DIR / "cache.sqlite"
|
|
10
|
+
|
|
11
|
+
# Etherscan v2 API. One key works across all EVM chains via chainid param.
|
|
12
|
+
# Get a free key at https://etherscan.io/myapikey and export it:
|
|
13
|
+
# export ETHERSCAN_API_KEY=xxxx
|
|
14
|
+
ETHERSCAN_API_KEY = os.environ.get("ETHERSCAN_API_KEY", "")
|
|
15
|
+
ETHERSCAN_BASE = "https://api.etherscan.io/v2/api"
|
|
16
|
+
|
|
17
|
+
# Supported EVM chains: name -> chainid
|
|
18
|
+
CHAINS = {
|
|
19
|
+
"eth": 1,
|
|
20
|
+
"bsc": 56,
|
|
21
|
+
"polygon": 137,
|
|
22
|
+
"arbitrum": 42161,
|
|
23
|
+
"optimism": 10,
|
|
24
|
+
"base": 8453,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
WEI = 10 ** 18
|
|
File without changes
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Bitcoin fetcher via mempool.space — no API key required.
|
|
2
|
+
|
|
3
|
+
Bitcoin uses the UTXO model, not accounts: a transaction consumes previous
|
|
4
|
+
outputs and creates new ones, so there is no single "from" or "to". To fit the
|
|
5
|
+
same tracing engine used for EVM chains, transactions are normalized into
|
|
6
|
+
directional transfer rows:
|
|
7
|
+
|
|
8
|
+
* if our address signed an input, each output that isn't ours is an outflow
|
|
9
|
+
(outputs back to ourselves are change and are skipped);
|
|
10
|
+
* otherwise, outputs paying us are inflows, attributed to the first input.
|
|
11
|
+
|
|
12
|
+
Bitcoin also enables the strongest clustering heuristic in blockchain forensics:
|
|
13
|
+
common-input-ownership. If several addresses sign inputs of the same
|
|
14
|
+
transaction, one party almost certainly controls all of them.
|
|
15
|
+
"""
|
|
16
|
+
from typing import List, Dict, Optional
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
BASE = "https://mempool.space/api"
|
|
21
|
+
SATS = 100_000_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BitcoinError(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get(path: str, timeout: int = 30):
|
|
29
|
+
"""Cached, throttled GET against mempool.space."""
|
|
30
|
+
from crypttrace.fetchers import http
|
|
31
|
+
try:
|
|
32
|
+
data = http.request_json(f"{BASE}{path}", timeout=timeout)
|
|
33
|
+
except http.RateLimited as e:
|
|
34
|
+
raise BitcoinError(str(e))
|
|
35
|
+
except requests.RequestException as e:
|
|
36
|
+
raise BitcoinError(f"mempool.space request failed: {e}")
|
|
37
|
+
except ValueError as e:
|
|
38
|
+
raise BitcoinError(f"bad response from mempool.space: {e}")
|
|
39
|
+
if isinstance(data, dict) and "__status__" in data:
|
|
40
|
+
if data["__status__"] == 400:
|
|
41
|
+
raise BitcoinError("Bitcoin address rejected by mempool.space — check it is "
|
|
42
|
+
"exact (BTC addresses are case-sensitive).")
|
|
43
|
+
raise BitcoinError("Address not found on the Bitcoin chain.")
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def stats(address: str) -> Dict:
|
|
48
|
+
"""Authoritative totals straight from the chain index.
|
|
49
|
+
|
|
50
|
+
These are the numbers our own parsing must agree with — the reference for
|
|
51
|
+
checking that a trace didn't miscount or silently miss history.
|
|
52
|
+
"""
|
|
53
|
+
d = _get(f"/address/{address}")
|
|
54
|
+
cs = d.get("chain_stats", {}) or {}
|
|
55
|
+
ms = d.get("mempool_stats", {}) or {}
|
|
56
|
+
recv = (cs.get("funded_txo_sum", 0) + ms.get("funded_txo_sum", 0)) / SATS
|
|
57
|
+
sent = (cs.get("spent_txo_sum", 0) + ms.get("spent_txo_sum", 0)) / SATS
|
|
58
|
+
return {
|
|
59
|
+
"received": recv,
|
|
60
|
+
"sent": sent,
|
|
61
|
+
"balance": recv - sent,
|
|
62
|
+
"tx_count": (cs.get("tx_count", 0) + ms.get("tx_count", 0)),
|
|
63
|
+
"funded_outputs": (cs.get("funded_txo_count", 0) + ms.get("funded_txo_count", 0)),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def balance(address: str) -> float:
|
|
68
|
+
return stats(address)["balance"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def raw_txs(address: str, max_txs: int = 500) -> List[dict]:
|
|
72
|
+
"""Transactions touching this address, paging back through history.
|
|
73
|
+
|
|
74
|
+
mempool.space returns ~50 per call. A single page is not enough for
|
|
75
|
+
investigations: famous addresses get spammed with dust, which pushes the
|
|
76
|
+
transactions that actually matter out of the recent window. So we follow
|
|
77
|
+
the /txs/chain/:last_txid cursor until we have enough history.
|
|
78
|
+
"""
|
|
79
|
+
out: List[dict] = []
|
|
80
|
+
last: Optional[str] = None
|
|
81
|
+
while len(out) < max_txs:
|
|
82
|
+
path = f"/address/{address}/txs" if last is None \
|
|
83
|
+
else f"/address/{address}/txs/chain/{last}"
|
|
84
|
+
batch = _get(path)
|
|
85
|
+
if not isinstance(batch, list) or not batch:
|
|
86
|
+
break
|
|
87
|
+
out.extend(batch)
|
|
88
|
+
if len(batch) < 25: # last page
|
|
89
|
+
break
|
|
90
|
+
nxt = batch[-1].get("txid")
|
|
91
|
+
if not nxt or nxt == last:
|
|
92
|
+
break
|
|
93
|
+
last = nxt
|
|
94
|
+
return out[:max_txs]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _in_addrs(tx: dict) -> List[str]:
|
|
98
|
+
out = []
|
|
99
|
+
for v in tx.get("vin", []) or []:
|
|
100
|
+
a = (v.get("prevout") or {}).get("scriptpubkey_address")
|
|
101
|
+
if a:
|
|
102
|
+
out.append(a)
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _in_pairs(tx: dict):
|
|
107
|
+
"""(address, value) for every input — a UTXO tx can be funded by many parties."""
|
|
108
|
+
for v in tx.get("vin", []) or []:
|
|
109
|
+
p = v.get("prevout") or {}
|
|
110
|
+
a = p.get("scriptpubkey_address")
|
|
111
|
+
if a:
|
|
112
|
+
yield a, (p.get("value", 0) or 0) / SATS
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _out_pairs(tx: dict):
|
|
116
|
+
for o in tx.get("vout", []) or []:
|
|
117
|
+
a = o.get("scriptpubkey_address")
|
|
118
|
+
if a:
|
|
119
|
+
yield a, o.get("value", 0) / SATS
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def transfers(address: str, limit: int = 1000) -> List[Dict]:
|
|
123
|
+
"""Normalized {from,to,value,timestamp,hash,symbol} rows.
|
|
124
|
+
|
|
125
|
+
A Bitcoin transaction has no single sender: it can be funded by many inputs
|
|
126
|
+
from different owners and pay many outputs. Attributing a whole transfer to
|
|
127
|
+
the first input address — the naive shortcut — badly misreports
|
|
128
|
+
consolidations, e.g. crediting one wallet with funds a hundred others
|
|
129
|
+
supplied. So value is split in proportion to what each side actually
|
|
130
|
+
contributed or received.
|
|
131
|
+
"""
|
|
132
|
+
me = address
|
|
133
|
+
rows: List[Dict] = []
|
|
134
|
+
for tx in raw_txs(address, max_txs=max(limit, 200)):
|
|
135
|
+
ts = int((tx.get("status") or {}).get("block_time") or 0)
|
|
136
|
+
h = tx.get("txid", "")
|
|
137
|
+
ins = list(_in_pairs(tx))
|
|
138
|
+
outs = list(_out_pairs(tx))
|
|
139
|
+
total_in = sum(v for _, v in ins)
|
|
140
|
+
if total_in <= 0:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
if me in (a for a, _ in ins):
|
|
144
|
+
# outgoing: our share of the inputs funds our share of each output
|
|
145
|
+
mine_in = sum(v for a, v in ins if a == me)
|
|
146
|
+
share = mine_in / total_in
|
|
147
|
+
# The chain counts a spent output at full value; the recipient gets
|
|
148
|
+
# less, because the miner fee comes out in between. Carry our share
|
|
149
|
+
# of that fee so totals can be reconciled exactly rather than
|
|
150
|
+
# hidden inside a tolerance.
|
|
151
|
+
fee_share = (total_in - sum(v for _, v in outs)) * share
|
|
152
|
+
for a, v in outs:
|
|
153
|
+
if a == me or v <= 0:
|
|
154
|
+
continue # change back to self
|
|
155
|
+
rows.append({"from": me, "to": a, "value": v * share,
|
|
156
|
+
"timestamp": ts, "hash": h, "symbol": "BTC",
|
|
157
|
+
"fee_share": fee_share})
|
|
158
|
+
else:
|
|
159
|
+
# incoming: credit every funder in proportion to what it put in
|
|
160
|
+
mine_out = sum(v for a, v in outs if a == me)
|
|
161
|
+
if mine_out <= 0:
|
|
162
|
+
continue
|
|
163
|
+
merged: Dict[str, float] = {}
|
|
164
|
+
for a, v in ins:
|
|
165
|
+
merged[a] = merged.get(a, 0.0) + v
|
|
166
|
+
for a, v in merged.items():
|
|
167
|
+
rows.append({"from": a, "to": me, "value": mine_out * (v / total_in),
|
|
168
|
+
"timestamp": ts, "hash": h, "symbol": "BTC"})
|
|
169
|
+
if len(rows) >= limit:
|
|
170
|
+
break
|
|
171
|
+
return rows
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cluster(address: str) -> List[tuple]:
|
|
175
|
+
"""Common-input-ownership: addresses that co-signed inputs with `address`.
|
|
176
|
+
|
|
177
|
+
Returns [(address, times_seen_together)] — likely the same owner's wallets.
|
|
178
|
+
"""
|
|
179
|
+
peers: Dict[str, int] = {}
|
|
180
|
+
for tx in raw_txs(address):
|
|
181
|
+
ins = _in_addrs(tx)
|
|
182
|
+
if address in ins and len(set(ins)) > 1:
|
|
183
|
+
for a in set(ins):
|
|
184
|
+
if a != address:
|
|
185
|
+
peers[a] = peers.get(a, 0) + 1
|
|
186
|
+
return sorted(peers.items(), key=lambda kv: kv[1], reverse=True)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Etherscan (v2 multichain) fetcher with a small SQLite cache.
|
|
2
|
+
|
|
3
|
+
The blockchain is public: every transaction is queryable. This module wraps the
|
|
4
|
+
Etherscan API and caches responses so repeated traces don't re-hit the API.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
import time
|
|
9
|
+
from typing import List, Dict, Optional
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from crypttrace import config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _db() -> sqlite3.Connection:
|
|
17
|
+
conn = sqlite3.connect(config.CACHE_DB)
|
|
18
|
+
conn.execute(
|
|
19
|
+
"CREATE TABLE IF NOT EXISTS cache ("
|
|
20
|
+
" key TEXT PRIMARY KEY,"
|
|
21
|
+
" ts INTEGER,"
|
|
22
|
+
" payload TEXT)"
|
|
23
|
+
)
|
|
24
|
+
return conn
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cache_get(key: str, max_age: int = 3600) -> Optional[dict]:
|
|
28
|
+
conn = _db()
|
|
29
|
+
row = conn.execute("SELECT ts, payload FROM cache WHERE key=?", (key,)).fetchone()
|
|
30
|
+
conn.close()
|
|
31
|
+
if not row:
|
|
32
|
+
return None
|
|
33
|
+
ts, payload = row
|
|
34
|
+
if time.time() - ts > max_age:
|
|
35
|
+
return None
|
|
36
|
+
return json.loads(payload)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cache_put(key: str, payload: dict) -> None:
|
|
40
|
+
conn = _db()
|
|
41
|
+
conn.execute(
|
|
42
|
+
"INSERT OR REPLACE INTO cache(key, ts, payload) VALUES (?,?,?)",
|
|
43
|
+
(key, int(time.time()), json.dumps(payload)),
|
|
44
|
+
)
|
|
45
|
+
conn.commit()
|
|
46
|
+
conn.close()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class EtherscanError(RuntimeError):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _call(chain: str, params: Dict[str, str], cache_age: int = 3600) -> dict:
|
|
54
|
+
if chain not in config.CHAINS:
|
|
55
|
+
raise EtherscanError(f"unsupported chain '{chain}'. Options: {list(config.CHAINS)}")
|
|
56
|
+
if not config.ETHERSCAN_API_KEY:
|
|
57
|
+
raise EtherscanError(
|
|
58
|
+
"No API key. Get a free one at https://etherscan.io/myapikey "
|
|
59
|
+
"then run: export ETHERSCAN_API_KEY=xxxx"
|
|
60
|
+
)
|
|
61
|
+
q = {
|
|
62
|
+
"chainid": config.CHAINS[chain],
|
|
63
|
+
"apikey": config.ETHERSCAN_API_KEY,
|
|
64
|
+
**params,
|
|
65
|
+
}
|
|
66
|
+
key = f"{chain}:" + "&".join(f"{k}={v}" for k, v in sorted(q.items()) if k != "apikey")
|
|
67
|
+
cached = _cache_get(key, cache_age)
|
|
68
|
+
if cached is not None:
|
|
69
|
+
return cached
|
|
70
|
+
|
|
71
|
+
resp = requests.get(config.ETHERSCAN_BASE, params=q, timeout=30)
|
|
72
|
+
resp.raise_for_status()
|
|
73
|
+
data = resp.json()
|
|
74
|
+
# status "0" with "No transactions found" is a valid empty result, not an error
|
|
75
|
+
if data.get("status") == "0" and "No transactions" not in str(data.get("message", "")):
|
|
76
|
+
# rate-limit or bad key etc.
|
|
77
|
+
if "rate limit" in str(data.get("result", "")).lower():
|
|
78
|
+
time.sleep(1)
|
|
79
|
+
return _call(chain, params, cache_age)
|
|
80
|
+
# otherwise return as-is; caller handles empty
|
|
81
|
+
_cache_put(key, data)
|
|
82
|
+
return data
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_balance(address: str, chain: str = "eth") -> float:
|
|
86
|
+
data = _call(chain, {"module": "account", "action": "balance",
|
|
87
|
+
"address": address, "tag": "latest"})
|
|
88
|
+
try:
|
|
89
|
+
return int(data["result"]) / config.WEI
|
|
90
|
+
except (KeyError, ValueError):
|
|
91
|
+
return 0.0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_txs(address: str, chain: str = "eth", limit: int = 1000,
|
|
95
|
+
sort: str = "desc") -> List[dict]:
|
|
96
|
+
"""Normal (native-coin) transactions. sort='desc' (newest first) or 'asc' (oldest first)."""
|
|
97
|
+
data = _call(chain, {
|
|
98
|
+
"module": "account", "action": "txlist", "address": address,
|
|
99
|
+
"startblock": "0", "endblock": "99999999",
|
|
100
|
+
"page": "1", "offset": str(limit), "sort": sort,
|
|
101
|
+
})
|
|
102
|
+
result = data.get("result")
|
|
103
|
+
return result if isinstance(result, list) else []
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_token_txs(address: str, chain: str = "eth", limit: int = 1000) -> List[dict]:
|
|
107
|
+
"""ERC-20 token transfers, newest first."""
|
|
108
|
+
data = _call(chain, {
|
|
109
|
+
"module": "account", "action": "tokentx", "address": address,
|
|
110
|
+
"startblock": "0", "endblock": "99999999",
|
|
111
|
+
"page": "1", "offset": str(limit), "sort": "desc",
|
|
112
|
+
})
|
|
113
|
+
result = data.get("result")
|
|
114
|
+
return result if isinstance(result, list) else []
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Shared HTTP layer for the non-EVM fetchers: caching, throttling, 429 backoff.
|
|
2
|
+
|
|
3
|
+
Tracing walks many addresses, and each hop is an API call. Free endpoints
|
|
4
|
+
(TronGrid, mempool.space, public Solana RPC) rate-limit aggressively, so this
|
|
5
|
+
module:
|
|
6
|
+
|
|
7
|
+
* caches responses in the same SQLite file the Etherscan fetcher uses, so a
|
|
8
|
+
re-run — or an address seen twice in one trace — costs nothing;
|
|
9
|
+
* spaces requests per host so we don't trip limits in the first place;
|
|
10
|
+
* retries with exponential backoff when a 429 happens anyway.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import sqlite3
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from typing import Optional
|
|
17
|
+
from urllib.parse import urlencode, urlparse
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
from crypttrace import config
|
|
22
|
+
|
|
23
|
+
# minimum seconds between calls to the same host
|
|
24
|
+
MIN_INTERVAL = {
|
|
25
|
+
"api.trongrid.io": 0.4,
|
|
26
|
+
"mempool.space": 0.25,
|
|
27
|
+
"api.mainnet-beta.solana.com": 0.3,
|
|
28
|
+
}
|
|
29
|
+
DEFAULT_INTERVAL = 0.25
|
|
30
|
+
|
|
31
|
+
_last_call = {}
|
|
32
|
+
_lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RateLimited(RuntimeError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _db() -> sqlite3.Connection:
|
|
40
|
+
conn = sqlite3.connect(config.CACHE_DB)
|
|
41
|
+
conn.execute("CREATE TABLE IF NOT EXISTS cache "
|
|
42
|
+
"(key TEXT PRIMARY KEY, ts INTEGER, payload TEXT)")
|
|
43
|
+
return conn
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cache_get(key: str, max_age: int) -> Optional[dict]:
|
|
47
|
+
try:
|
|
48
|
+
conn = _db()
|
|
49
|
+
row = conn.execute("SELECT ts, payload FROM cache WHERE key=?", (key,)).fetchone()
|
|
50
|
+
conn.close()
|
|
51
|
+
except sqlite3.Error:
|
|
52
|
+
return None
|
|
53
|
+
if not row:
|
|
54
|
+
return None
|
|
55
|
+
ts, payload = row
|
|
56
|
+
if time.time() - ts > max_age:
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
return json.loads(payload)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cache_put(key: str, payload) -> None:
|
|
65
|
+
try:
|
|
66
|
+
conn = _db()
|
|
67
|
+
conn.execute("INSERT OR REPLACE INTO cache(key, ts, payload) VALUES (?,?,?)",
|
|
68
|
+
(key, int(time.time()), json.dumps(payload)))
|
|
69
|
+
conn.commit()
|
|
70
|
+
conn.close()
|
|
71
|
+
except (sqlite3.Error, TypeError):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _throttle(host: str) -> None:
|
|
76
|
+
with _lock:
|
|
77
|
+
gap = MIN_INTERVAL.get(host, DEFAULT_INTERVAL)
|
|
78
|
+
wait = gap - (time.time() - _last_call.get(host, 0.0))
|
|
79
|
+
if wait > 0:
|
|
80
|
+
time.sleep(wait)
|
|
81
|
+
_last_call[host] = time.time()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def request_json(url: str, params: dict = None, *, body: dict = None,
|
|
85
|
+
cache_key: str = None, ttl: int = 3600, retries: int = 3,
|
|
86
|
+
timeout: int = 30, headers: dict = None):
|
|
87
|
+
"""GET (or POST when `body` is given) returning JSON, cached and rate-limited."""
|
|
88
|
+
key = cache_key or (url + ("?" + urlencode(sorted(params.items())) if params else ""))
|
|
89
|
+
hit = cache_get(key, ttl)
|
|
90
|
+
if hit is not None:
|
|
91
|
+
return hit
|
|
92
|
+
|
|
93
|
+
host = urlparse(url).netloc
|
|
94
|
+
delay = 1.0
|
|
95
|
+
last_err = None
|
|
96
|
+
for attempt in range(retries + 1):
|
|
97
|
+
_throttle(host)
|
|
98
|
+
try:
|
|
99
|
+
if body is not None:
|
|
100
|
+
r = requests.post(url, json=body, timeout=timeout, headers=headers)
|
|
101
|
+
else:
|
|
102
|
+
r = requests.get(url, params=params, timeout=timeout, headers=headers)
|
|
103
|
+
except requests.RequestException as e:
|
|
104
|
+
last_err = e
|
|
105
|
+
if attempt < retries:
|
|
106
|
+
time.sleep(delay)
|
|
107
|
+
delay *= 2
|
|
108
|
+
continue
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
if r.status_code == 429:
|
|
112
|
+
if attempt < retries:
|
|
113
|
+
# honour Retry-After when the server sends it
|
|
114
|
+
try:
|
|
115
|
+
delay = max(delay, float(r.headers.get("Retry-After", 0)))
|
|
116
|
+
except ValueError:
|
|
117
|
+
pass
|
|
118
|
+
time.sleep(delay)
|
|
119
|
+
delay *= 2
|
|
120
|
+
continue
|
|
121
|
+
raise RateLimited(
|
|
122
|
+
f"{host} rate limit reached. Wait a few seconds and retry, lower --depth/--branching, "
|
|
123
|
+
"or set an API key for a higher quota.")
|
|
124
|
+
|
|
125
|
+
if r.status_code in (400, 404):
|
|
126
|
+
return {"__status__": r.status_code}
|
|
127
|
+
|
|
128
|
+
r.raise_for_status()
|
|
129
|
+
try:
|
|
130
|
+
data = r.json()
|
|
131
|
+
except ValueError as e:
|
|
132
|
+
last_err = e
|
|
133
|
+
raise
|
|
134
|
+
cache_put(key, data)
|
|
135
|
+
return data
|
|
136
|
+
|
|
137
|
+
if last_err:
|
|
138
|
+
raise last_err
|
|
139
|
+
return None
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Solana fetcher via public JSON-RPC — no API key required.
|
|
2
|
+
|
|
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.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
from typing import List, Dict
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
RPC = os.environ.get("CRYPTTRACE_SOLANA_RPC", "https://api.mainnet-beta.solana.com")
|
|
14
|
+
LAMPORTS = 1_000_000_000
|
|
15
|
+
MAX_TXS = 25 # keep the number of RPC round-trips sane
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SolanaError(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _rpc(method: str, params: list, timeout: int = 30):
|
|
23
|
+
"""Cached, throttled JSON-RPC call."""
|
|
24
|
+
import json as _json
|
|
25
|
+
from crypttrace.fetchers import http
|
|
26
|
+
body = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
|
|
27
|
+
key = f"sol:{method}:{_json.dumps(params, sort_keys=True, default=str)}"
|
|
28
|
+
try:
|
|
29
|
+
j = http.request_json(RPC, body=body, cache_key=key, timeout=timeout)
|
|
30
|
+
except http.RateLimited as e:
|
|
31
|
+
raise SolanaError(str(e) + " (tip: set CRYPTTRACE_SOLANA_RPC to your own endpoint)")
|
|
32
|
+
except requests.RequestException as e:
|
|
33
|
+
raise SolanaError(f"Solana RPC request failed: {e}")
|
|
34
|
+
except ValueError as e:
|
|
35
|
+
raise SolanaError(f"bad response from Solana RPC: {e}")
|
|
36
|
+
if not isinstance(j, dict):
|
|
37
|
+
return None
|
|
38
|
+
if "error" in j:
|
|
39
|
+
raise SolanaError(f"Solana RPC error: {j['error'].get('message')}")
|
|
40
|
+
return j.get("result")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def balance(address: str) -> float:
|
|
44
|
+
res = _rpc("getBalance", [address])
|
|
45
|
+
if isinstance(res, dict):
|
|
46
|
+
return (res.get("value") or 0) / LAMPORTS
|
|
47
|
+
return 0.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _signatures(address: str, limit: int) -> List[str]:
|
|
51
|
+
res = _rpc("getSignaturesForAddress", [address, {"limit": min(limit, MAX_TXS)}]) or []
|
|
52
|
+
return [s.get("signature") for s in res if s.get("signature")]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_tx(sig: str) -> List[Dict]:
|
|
56
|
+
tx = _rpc("getTransaction", [sig, {"encoding": "jsonParsed",
|
|
57
|
+
"maxSupportedTransactionVersion": 0}])
|
|
58
|
+
if not tx:
|
|
59
|
+
return []
|
|
60
|
+
ts = int(tx.get("blockTime") or 0)
|
|
61
|
+
rows = []
|
|
62
|
+
msg = (tx.get("transaction") or {}).get("message") or {}
|
|
63
|
+
instrs = list(msg.get("instructions") or [])
|
|
64
|
+
for inner in (tx.get("meta") or {}).get("innerInstructions") or []:
|
|
65
|
+
instrs.extend(inner.get("instructions") or [])
|
|
66
|
+
for ins in instrs:
|
|
67
|
+
parsed = ins.get("parsed")
|
|
68
|
+
if not isinstance(parsed, dict):
|
|
69
|
+
continue
|
|
70
|
+
info = parsed.get("info") or {}
|
|
71
|
+
ptype = parsed.get("type")
|
|
72
|
+
prog = ins.get("program")
|
|
73
|
+
if prog == "system" and ptype in ("transfer", "transferWithSeed"):
|
|
74
|
+
rows.append({"from": info.get("source", ""), "to": info.get("destination", ""),
|
|
75
|
+
"value": (info.get("lamports") or 0) / LAMPORTS,
|
|
76
|
+
"timestamp": ts, "hash": sig, "symbol": "SOL"})
|
|
77
|
+
elif prog == "spl-token" and ptype in ("transfer", "transferChecked"):
|
|
78
|
+
amt = info.get("tokenAmount") or {}
|
|
79
|
+
try:
|
|
80
|
+
val = float(amt.get("uiAmountString") or amt.get("uiAmount") or info.get("amount") or 0)
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
val = 0.0
|
|
83
|
+
rows.append({"from": info.get("source", "") or info.get("authority", ""),
|
|
84
|
+
"to": info.get("destination", ""), "value": val,
|
|
85
|
+
"timestamp": ts, "hash": sig, "symbol": "SPL"})
|
|
86
|
+
return rows
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def transfers(address: str, limit: int = MAX_TXS) -> List[Dict]:
|
|
90
|
+
"""Normalized transfer rows. Note: capped at MAX_TXS transactions."""
|
|
91
|
+
rows: List[Dict] = []
|
|
92
|
+
for sig in _signatures(address, limit):
|
|
93
|
+
try:
|
|
94
|
+
rows.extend(_parse_tx(sig))
|
|
95
|
+
except SolanaError:
|
|
96
|
+
continue
|
|
97
|
+
return rows
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Tron fetcher via TronGrid — no API key required for basic use.
|
|
2
|
+
|
|
3
|
+
Tron matters for victim cases: a very large share of everyday scams (romance
|
|
4
|
+
scams, fake investment platforms, "pig butchering") move USDT-TRC20 on Tron
|
|
5
|
+
because fees are near zero.
|
|
6
|
+
|
|
7
|
+
Tron uses an account model like EVM, but the API returns addresses in hex
|
|
8
|
+
(41-prefixed) for native transfers, so they're converted to the familiar base58
|
|
9
|
+
"T..." form here.
|
|
10
|
+
"""
|
|
11
|
+
import hashlib
|
|
12
|
+
from typing import List, Dict
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
BASE = "https://api.trongrid.io"
|
|
17
|
+
SUN = 1_000_000 # 1 TRX
|
|
18
|
+
_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TronError(RuntimeError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def hex_to_base58(h: str) -> str:
|
|
26
|
+
"""Convert a Tron hex address (41…) to base58check (T…)."""
|
|
27
|
+
if not h:
|
|
28
|
+
return ""
|
|
29
|
+
if h.startswith("T"): # already base58
|
|
30
|
+
return h
|
|
31
|
+
if h.startswith("0x"):
|
|
32
|
+
h = h[2:]
|
|
33
|
+
try:
|
|
34
|
+
b = bytes.fromhex(h)
|
|
35
|
+
except ValueError:
|
|
36
|
+
return h
|
|
37
|
+
if len(b) == 20:
|
|
38
|
+
b = b"\x41" + b
|
|
39
|
+
chk = hashlib.sha256(hashlib.sha256(b).digest()).digest()[:4]
|
|
40
|
+
b = b + chk
|
|
41
|
+
n = int.from_bytes(b, "big")
|
|
42
|
+
s = ""
|
|
43
|
+
while n > 0:
|
|
44
|
+
n, r = divmod(n, 58)
|
|
45
|
+
s = _B58[r] + s
|
|
46
|
+
pad = 0
|
|
47
|
+
for c in b:
|
|
48
|
+
if c == 0:
|
|
49
|
+
pad += 1
|
|
50
|
+
else:
|
|
51
|
+
break
|
|
52
|
+
return "1" * pad + s
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _get(path: str, params: dict = None, timeout: int = 30):
|
|
56
|
+
"""Cached, throttled GET. Set TRONGRID_API_KEY for a higher rate limit."""
|
|
57
|
+
import os
|
|
58
|
+
from crypttrace.fetchers import http
|
|
59
|
+
headers = {}
|
|
60
|
+
key = os.environ.get("TRONGRID_API_KEY")
|
|
61
|
+
if key:
|
|
62
|
+
headers["TRON-PRO-API-KEY"] = key
|
|
63
|
+
try:
|
|
64
|
+
data = http.request_json(f"{BASE}{path}", params or {},
|
|
65
|
+
timeout=timeout, headers=headers or None)
|
|
66
|
+
except http.RateLimited as e:
|
|
67
|
+
raise TronError(str(e))
|
|
68
|
+
except requests.RequestException as e:
|
|
69
|
+
raise TronError(f"TronGrid request failed: {e}")
|
|
70
|
+
except ValueError as e:
|
|
71
|
+
raise TronError(f"bad response from TronGrid: {e}")
|
|
72
|
+
if isinstance(data, dict) and "__status__" in data:
|
|
73
|
+
return {"data": []}
|
|
74
|
+
return data
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def balance(address: str) -> float:
|
|
78
|
+
d = _get(f"/v1/accounts/{address}")
|
|
79
|
+
data = d.get("data") or []
|
|
80
|
+
if not data:
|
|
81
|
+
return 0.0
|
|
82
|
+
return (data[0].get("balance") or 0) / SUN
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _native(address: str, limit: int) -> List[Dict]:
|
|
86
|
+
d = _get(f"/v1/accounts/{address}/transactions", {"limit": min(limit, 200)})
|
|
87
|
+
rows = []
|
|
88
|
+
for tx in d.get("data", []) or []:
|
|
89
|
+
try:
|
|
90
|
+
c = (tx.get("raw_data", {}).get("contract") or [])[0]
|
|
91
|
+
if c.get("type") != "TransferContract":
|
|
92
|
+
continue
|
|
93
|
+
v = c["parameter"]["value"]
|
|
94
|
+
frm = hex_to_base58(v.get("owner_address", ""))
|
|
95
|
+
to = hex_to_base58(v.get("to_address", ""))
|
|
96
|
+
amt = (v.get("amount") or 0) / SUN
|
|
97
|
+
except (KeyError, IndexError, TypeError):
|
|
98
|
+
continue
|
|
99
|
+
if amt <= 0:
|
|
100
|
+
continue
|
|
101
|
+
rows.append({"from": frm, "to": to, "value": amt,
|
|
102
|
+
"timestamp": int((tx.get("block_timestamp") or 0) / 1000),
|
|
103
|
+
"hash": tx.get("txID", ""), "symbol": "TRX"})
|
|
104
|
+
return rows
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def token_transfers(address: str, limit: int = 200) -> List[Dict]:
|
|
108
|
+
"""TRC20 transfers (USDT and friends) — already base58 in the API."""
|
|
109
|
+
d = _get(f"/v1/accounts/{address}/transactions/trc20", {"limit": min(limit, 200)})
|
|
110
|
+
rows = []
|
|
111
|
+
for t in d.get("data", []) or []:
|
|
112
|
+
info = t.get("token_info") or {}
|
|
113
|
+
dec = int(info.get("decimals") or 6)
|
|
114
|
+
try:
|
|
115
|
+
val = int(t.get("value") or 0) / (10 ** dec)
|
|
116
|
+
except (ValueError, TypeError):
|
|
117
|
+
continue
|
|
118
|
+
rows.append({"from": t.get("from", ""), "to": t.get("to", ""), "value": val,
|
|
119
|
+
"timestamp": int((t.get("block_timestamp") or 0) / 1000),
|
|
120
|
+
"hash": t.get("transaction_id", ""),
|
|
121
|
+
"symbol": info.get("symbol", "TRC20"),
|
|
122
|
+
"contract": (info.get("address") or "").lower()})
|
|
123
|
+
return rows
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def transfers(address: str, limit: int = 200) -> List[Dict]:
|
|
127
|
+
"""Native TRX transfers (use token_transfers for USDT-TRC20)."""
|
|
128
|
+
return _native(address, limit)
|