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/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)