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