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/assets.py ADDED
@@ -0,0 +1,68 @@
1
+ """Asset registry — which token to follow, per chain.
2
+
3
+ Most thefts and scams move stablecoins, not native coins, so tracing has to be
4
+ able to follow a specific token. Contract addresses differ per chain, so the
5
+ registry is keyed by chain. All addresses below are the official ones and were
6
+ verified against block explorers — a wrong contract would silently trace the
7
+ wrong asset (fake "USDT" tokens are routinely airdropped to poison wallets).
8
+ """
9
+ from typing import Optional, Dict
10
+
11
+ # chain -> symbol -> {contract, symbol, stable}
12
+ TOKENS: Dict[str, Dict[str, dict]] = {
13
+ "eth": {
14
+ "usdt": {"contract": "0xdac17f958d2ee523a2206206994597c13d831ec7", "symbol": "USDT", "stable": True},
15
+ "usdc": {"contract": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "symbol": "USDC", "stable": True},
16
+ "dai": {"contract": "0x6b175474e89094c44da98b954eedeac495271d0f", "symbol": "DAI", "stable": True},
17
+ "busd": {"contract": "0x4fabb145d64652a948d72533023f6e7a623c7c53", "symbol": "BUSD", "stable": True},
18
+ "weth": {"contract": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "symbol": "WETH", "stable": False},
19
+ "wbtc": {"contract": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", "symbol": "WBTC", "stable": False},
20
+ },
21
+ # Tron: where most everyday scams (romance / "pig butchering") move money.
22
+ "tron": {
23
+ "usdt": {"contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "symbol": "USDT", "stable": True},
24
+ "usdc": {"contract": "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8", "symbol": "USDC", "stable": True},
25
+ },
26
+ "sol": {
27
+ "usdc": {"contract": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "symbol": "USDC", "stable": True},
28
+ "usdt": {"contract": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", "symbol": "USDT", "stable": True},
29
+ },
30
+ }
31
+
32
+ # chains where token tracing is supported at all
33
+ TOKEN_CHAINS = set(TOKENS) | {"bsc", "polygon", "arbitrum", "optimism", "base"}
34
+
35
+
36
+ def tokens_for(chain: str) -> Dict[str, dict]:
37
+ """Known token symbols for a chain (EVM sidechains reuse the eth registry
38
+ only for symbol names — pass an explicit contract for those)."""
39
+ return TOKENS.get(chain, {})
40
+
41
+
42
+ def resolve_asset(asset: Optional[str], chain: str = "eth") -> Optional[dict]:
43
+ """Turn an --asset value into an asset descriptor, or None for the native coin.
44
+
45
+ Accepts 'eth'/'native'/None, a known symbol on that chain, or a raw contract
46
+ address (0x… on EVM, base58 on Tron/Solana).
47
+ """
48
+ if asset is None or asset.lower() in ("eth", "native", "btc", "trx", "sol", "bnb", "matic"):
49
+ return None
50
+
51
+ a = asset.lower()
52
+ known = TOKENS.get(chain, {})
53
+ if a in known:
54
+ return dict(known[a])
55
+
56
+ # raw contract address
57
+ if chain in ("tron", "sol"):
58
+ if len(asset) >= 32 and not asset.startswith("0x"):
59
+ return {"contract": asset, "symbol": asset[:6].upper(), "stable": False}
60
+ elif a.startswith("0x") and len(a) == 42:
61
+ return {"contract": a, "symbol": asset.upper()[:8], "stable": False}
62
+
63
+ if chain == "btc":
64
+ raise ValueError("Bitcoin has no tokens — omit --asset (or use --asset btc).")
65
+
66
+ opts = ", ".join(known) or "none registered"
67
+ raise ValueError(f"Unknown asset '{asset}' on {chain}. Known symbols: {opts}. "
68
+ f"You can also pass a contract address.")
crypttrace/bridges.py ADDED
@@ -0,0 +1,90 @@
1
+ """Cross-chain tracing across bridges.
2
+
3
+ When funds cross a bridge, the on-chain trail on the source chain ends at the
4
+ bridge contract, and the money reappears on another chain — there is no free,
5
+ deterministic link between the two. This module uses a behavioural heuristic
6
+ that catches a large share of real cases:
7
+
8
+ Launderers very often bridge to the *same address they control* on the
9
+ destination chain. So after spotting a transfer into a bridge, we search every
10
+ other supported chain for an inbound transfer to the same address, of a
11
+ similar amount (bridges take a fee), shortly afterwards.
12
+
13
+ A match is a strong *lead*, not proof: amounts and timing can coincide. Results
14
+ are reported as likely continuations with the delay and amount so the analyst
15
+ can judge. (v1 matches native-coin value; token-bridging and recipient-address
16
+ decoding from bridge calldata are natural extensions.)
17
+ """
18
+ from typing import List, Dict
19
+
20
+ from crypttrace.fetchers import etherscan
21
+ from crypttrace.labels import labels
22
+ from crypttrace import config
23
+
24
+
25
+ def bridge_outflows(address: str, chain: str = "eth") -> List[Dict]:
26
+ """Native transfers from `address` into a labelled bridge contract."""
27
+ me = address.lower()
28
+ outs = []
29
+ for tx in etherscan.get_txs(address, chain, limit=1000):
30
+ if tx.get("from", "").lower() != me:
31
+ continue
32
+ to = tx.get("to", "").lower()
33
+ if labels.type_of(to) != "bridge":
34
+ continue
35
+ val = int(tx.get("value", 0)) / config.WEI
36
+ if val <= 0:
37
+ continue
38
+ outs.append({
39
+ "bridge": labels.label_of(to) or to,
40
+ "bridge_address": to,
41
+ "amount": val,
42
+ "timestamp": int(tx.get("timeStamp", "0")),
43
+ "hash": tx.get("hash", ""),
44
+ })
45
+ return outs
46
+
47
+
48
+ def find_arrivals(address: str, source_chain: str, amount: float, after_ts: int,
49
+ tol: float = 0.05, window_h: int = 48) -> List[Dict]:
50
+ """Search every other supported chain for an inbound transfer to `address`
51
+ of ~`amount` (within `tol`) within `window_h` hours after `after_ts`."""
52
+ me = address.lower()
53
+ hits = []
54
+ for ch in config.CHAINS:
55
+ if ch == source_chain:
56
+ continue
57
+ try:
58
+ txs = etherscan.get_txs(address, ch, limit=200, sort="asc")
59
+ except etherscan.EtherscanError:
60
+ continue
61
+ for tx in txs:
62
+ if tx.get("to", "").lower() != me:
63
+ continue
64
+ ts = int(tx.get("timeStamp", "0"))
65
+ if ts < after_ts or ts > after_ts + window_h * 3600:
66
+ continue
67
+ val = int(tx.get("value", 0)) / config.WEI
68
+ if val <= 0 or amount <= 0:
69
+ continue
70
+ if abs(val - amount) / amount <= tol:
71
+ hits.append({
72
+ "chain": ch,
73
+ "from": tx.get("from", "").lower(),
74
+ "amount": val,
75
+ "timestamp": ts,
76
+ "delay_min": round((ts - after_ts) / 60, 1),
77
+ "hash": tx.get("hash", ""),
78
+ })
79
+ return hits
80
+
81
+
82
+ def trace_cross(address: str, chain: str = "eth", tol: float = 0.05,
83
+ window_h: int = 48) -> List[Dict]:
84
+ """For each bridge-out from `address`, list likely continuations on other chains."""
85
+ results = []
86
+ for out in bridge_outflows(address, chain):
87
+ arrivals = find_arrivals(address, chain, out["amount"], out["timestamp"],
88
+ tol=tol, window_h=window_h)
89
+ results.append({"bridge_out": out, "arrivals": arrivals})
90
+ return results
crypttrace/chains.py ADDED
@@ -0,0 +1,254 @@
1
+ """Unified multi-chain adapter.
2
+
3
+ Every supported network — EVM (Etherscan v2), Bitcoin (UTXO, mempool.space),
4
+ Tron (TronGrid) and Solana (JSON-RPC) — is normalized to the same transfer row:
5
+
6
+ {"from", "to", "value", "timestamp", "hash", "symbol", "contract"?}
7
+
8
+ so the tracing engine, profiles and the web graph work identically everywhere.
9
+
10
+ Native coins and tokens are kept strictly separate: summing 5 TRX with 250 USDT
11
+ would be meaningless, so a caller always asks for one asset at a time.
12
+ """
13
+ from typing import List, Dict, Optional
14
+
15
+ from crypttrace import config
16
+ from crypttrace.fetchers import etherscan, bitcoin, tron, solana
17
+
18
+ EVM_CHAINS = set(config.CHAINS)
19
+ NON_EVM = {"btc", "tron", "sol"}
20
+ ALL_CHAINS = sorted(EVM_CHAINS | NON_EVM)
21
+
22
+ SYMBOL = {"eth": "ETH", "bsc": "BNB", "polygon": "MATIC", "arbitrum": "ETH",
23
+ "optimism": "ETH", "base": "ETH", "btc": "BTC", "tron": "TRX", "sol": "SOL"}
24
+
25
+ EXPLORER = {
26
+ "eth": "https://etherscan.io/address/{}", "bsc": "https://bscscan.com/address/{}",
27
+ "polygon": "https://polygonscan.com/address/{}", "arbitrum": "https://arbiscan.io/address/{}",
28
+ "optimism": "https://optimistic.etherscan.io/address/{}", "base": "https://basescan.org/address/{}",
29
+ "btc": "https://mempool.space/address/{}", "tron": "https://tronscan.org/#/address/{}",
30
+ "sol": "https://solscan.io/account/{}",
31
+ }
32
+
33
+ _UPSTREAM_ERRORS = (etherscan.EtherscanError, bitcoin.BitcoinError,
34
+ tron.TronError, solana.SolanaError)
35
+
36
+
37
+ class ChainError(RuntimeError):
38
+ pass
39
+
40
+
41
+ def is_evm(chain: str) -> bool:
42
+ return chain in EVM_CHAINS
43
+
44
+
45
+ def case_sensitive(chain: str) -> bool:
46
+ """Bitcoin/Tron/Solana use base58 — address case is significant."""
47
+ return chain in NON_EVM
48
+
49
+
50
+ def norm_addr(address: str, chain: str) -> str:
51
+ return address if case_sensitive(chain) else address.lower()
52
+
53
+
54
+ def symbol(chain: str) -> str:
55
+ return SYMBOL.get(chain, "?")
56
+
57
+
58
+ def explorer_url(address: str, chain: str) -> str:
59
+ return EXPLORER.get(chain, EXPLORER["eth"]).format(address)
60
+
61
+
62
+ def check(chain: str) -> None:
63
+ if chain not in ALL_CHAINS:
64
+ raise ChainError(f"unsupported chain '{chain}'. Options: {ALL_CHAINS}")
65
+
66
+
67
+ def balance(address: str, chain: str = "eth") -> float:
68
+ check(chain)
69
+ try:
70
+ if is_evm(chain):
71
+ return etherscan.get_balance(address, chain)
72
+ if chain == "btc":
73
+ return bitcoin.balance(address)
74
+ if chain == "tron":
75
+ return tron.balance(address)
76
+ if chain == "sol":
77
+ return solana.balance(address)
78
+ except _UPSTREAM_ERRORS as e:
79
+ raise ChainError(str(e))
80
+ return 0.0
81
+
82
+
83
+ # ---------- normalized row builders ----------
84
+
85
+ def _evm_native(address: str, chain: str, limit: int) -> List[Dict]:
86
+ rows = []
87
+ for tx in etherscan.get_txs(address, chain, limit=limit):
88
+ try:
89
+ val = int(tx.get("value", 0)) / config.WEI
90
+ except (TypeError, ValueError):
91
+ val = 0.0
92
+ rows.append({"from": tx.get("from", "").lower(), "to": tx.get("to", "").lower(),
93
+ "value": val, "timestamp": int(tx.get("timeStamp", "0") or 0),
94
+ "hash": tx.get("hash", ""), "symbol": symbol(chain)})
95
+ return rows
96
+
97
+
98
+ def _evm_token(address: str, chain: str, contract: Optional[str], limit: int) -> List[Dict]:
99
+ rows = []
100
+ want = (contract or "").lower()
101
+ for t in etherscan.get_token_txs(address, chain, limit=limit):
102
+ c = (t.get("contractAddress") or "").lower()
103
+ if want and c != want:
104
+ continue
105
+ try:
106
+ dec = int(t.get("tokenDecimal") or 18)
107
+ val = int(t.get("value", 0)) / (10 ** dec)
108
+ except (TypeError, ValueError):
109
+ continue
110
+ rows.append({"from": t.get("from", "").lower(), "to": t.get("to", "").lower(),
111
+ "value": val, "timestamp": int(t.get("timeStamp", "0") or 0),
112
+ "hash": t.get("hash", ""), "symbol": t.get("tokenSymbol", "?"),
113
+ "contract": c})
114
+ return rows
115
+
116
+
117
+ def _tron_token(address: str, contract: Optional[str], limit: int) -> List[Dict]:
118
+ rows = tron.token_transfers(address, limit)
119
+ if contract:
120
+ want = contract.lower()
121
+ rows = [r for r in rows if (r.get("contract") or "").lower() == want]
122
+ return rows
123
+
124
+
125
+ def _sol_rows(address: str, limit: int, token: bool, contract: Optional[str]) -> List[Dict]:
126
+ rows = solana.transfers(address, limit)
127
+ if token:
128
+ rows = [r for r in rows if r.get("symbol") != "SOL"]
129
+ if contract:
130
+ rows = [r for r in rows if r.get("mint") in (None, contract)
131
+ or (r.get("contract") or "") == contract]
132
+ else:
133
+ rows = [r for r in rows if r.get("symbol") == "SOL"]
134
+ return rows
135
+
136
+
137
+ USE_STORE = True # keep a local copy of what we fetch
138
+ FORCE_FRESH = False # ignore what's stored, but still store the new pull
139
+ OFFLINE = False # answer only from what is already stored
140
+
141
+
142
+ def transfers(address: str, chain: str = "eth", limit: int = 1000,
143
+ oldest_first: bool = False, asset: Optional[dict] = None,
144
+ fresh: bool = False) -> List[Dict]:
145
+ """Normalized transfers for one asset (native by default), newest first.
146
+
147
+ Served from the local store when it holds recent data for this address, so
148
+ repeat analysis costs nothing and works offline.
149
+ """
150
+ check(chain)
151
+ contract = asset.get("contract") if asset else None
152
+
153
+ from crypttrace import store
154
+ asset_key = (contract or "").lower()
155
+ skip_read = fresh or FORCE_FRESH
156
+ if USE_STORE and not skip_read:
157
+ if OFFLINE or store.is_fresh(chain, address, asset_key):
158
+ rows = store.load(address=address, chain=chain, contract=contract or "")
159
+ if rows or OFFLINE:
160
+ rows.sort(key=lambda r: r.get("timestamp", 0), reverse=not oldest_first)
161
+ return rows
162
+
163
+ if OFFLINE:
164
+ # nothing stored for this address and we're not allowed to fetch
165
+ return []
166
+
167
+ try:
168
+ if is_evm(chain):
169
+ rows = _evm_token(address, chain, contract, limit) if asset \
170
+ else _evm_native(address, chain, limit)
171
+ elif chain == "btc":
172
+ if asset:
173
+ raise ChainError("Bitcoin has no tokens.")
174
+ rows = bitcoin.transfers(address, limit)
175
+ elif chain == "tron":
176
+ # keep TRX and TRC20 strictly separate
177
+ rows = _tron_token(address, contract, limit) if asset \
178
+ else tron.transfers(address, limit)
179
+ elif chain == "sol":
180
+ rows = _sol_rows(address, limit, bool(asset), contract)
181
+ else:
182
+ rows = []
183
+ except _UPSTREAM_ERRORS as e:
184
+ raise ChainError(str(e))
185
+
186
+ if USE_STORE:
187
+ try:
188
+ store.save(chain, address, rows, asset_key, complete=len(rows) < limit)
189
+ except Exception:
190
+ pass # a store problem must never break an investigation
191
+
192
+ rows.sort(key=lambda r: r.get("timestamp", 0), reverse=not oldest_first)
193
+ return rows
194
+
195
+
196
+ def flows(address: str, chain: str, top: int, direction: str = "out",
197
+ limit: int = 1000, asset: Optional[dict] = None):
198
+ """Aggregated value per counterparty: [(other, total, tx_count)].
199
+
200
+ direction='out' — where this address SENT funds (follow the money forward).
201
+ direction='in' — where its funds CAME FROM (trace the source backward).
202
+ """
203
+ me = norm_addr(address, chain)
204
+ near, far = ("from", "to") if direction == "out" else ("to", "from")
205
+ agg: Dict[str, list] = {}
206
+ for r in transfers(address, chain, limit, asset=asset):
207
+ if r.get(near) != me:
208
+ continue
209
+ other = r.get(far)
210
+ if not other or r.get("value", 0) <= 0:
211
+ continue
212
+ rec = agg.setdefault(other, [0.0, 0])
213
+ rec[0] += r["value"]
214
+ rec[1] += 1
215
+ ranked = sorted(agg.items(), key=lambda kv: kv[1][0], reverse=True)
216
+ return [(a, v, c) for a, (v, c) in ranked][:top]
217
+
218
+
219
+ def outflows(address: str, chain: str, top: int, limit: int = 1000, asset=None):
220
+ return flows(address, chain, top, "out", limit, asset)
221
+
222
+
223
+ def inflows(address: str, chain: str, top: int, limit: int = 1000, asset=None):
224
+ return flows(address, chain, top, "in", limit, asset)
225
+
226
+
227
+ def token_holdings(address: str, chain: str = "eth", limit: int = 1000) -> List[Dict]:
228
+ """Approximate token holdings from transfer history (net in − out per token)."""
229
+ check(chain)
230
+ if chain == "btc":
231
+ return []
232
+ me = norm_addr(address, chain)
233
+ try:
234
+ if is_evm(chain):
235
+ rows = _evm_token(address, chain, None, limit)
236
+ elif chain == "tron":
237
+ rows = _tron_token(address, None, limit)
238
+ else:
239
+ rows = _sol_rows(address, limit, True, None)
240
+ except _UPSTREAM_ERRORS as e:
241
+ raise ChainError(str(e))
242
+
243
+ agg: Dict[str, dict] = {}
244
+ for r in rows:
245
+ key = r.get("contract") or r.get("symbol", "?")
246
+ rec = agg.setdefault(key, {"symbol": r.get("symbol", "?"), "contract": key,
247
+ "net": 0.0, "txs": 0})
248
+ if r.get("to") == me:
249
+ rec["net"] += r.get("value", 0.0)
250
+ if r.get("from") == me:
251
+ rec["net"] -= r.get("value", 0.0)
252
+ rec["txs"] += 1
253
+ return sorted([h for h in agg.values() if h["net"] > 1e-9],
254
+ key=lambda h: h["net"], reverse=True)