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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Local label database + risk scoring.
|
|
2
|
+
|
|
3
|
+
Labels are the single most valuable part of a forensics tool: they turn an
|
|
4
|
+
anonymous hex string into "Binance hot wallet" or "Tornado Cash".
|
|
5
|
+
|
|
6
|
+
Two layers are merged:
|
|
7
|
+
1. known.json — the curated seed set shipped with the tool (rich names).
|
|
8
|
+
2. imported labels — downloaded by `crypttrace update-labels` from public
|
|
9
|
+
sources (OFAC sanctions, etc.), cached in the user's data dir.
|
|
10
|
+
The seed set takes priority on conflicts, so its richer names win.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional, Dict, List
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from crypttrace import addresses, config
|
|
19
|
+
|
|
20
|
+
_HERE = Path(__file__).parent
|
|
21
|
+
_IMPORTED = config.DATA_DIR / "imported_labels.json"
|
|
22
|
+
_KNOWN: Dict[str, dict] = {}
|
|
23
|
+
_PARTIAL: List[dict] = []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Public label sources. Each is fetched and merged on `update-labels`.
|
|
27
|
+
# "lines" format = one address per line (comments/blank lines skipped).
|
|
28
|
+
SOURCES: List[dict] = [
|
|
29
|
+
{
|
|
30
|
+
"name": "OFAC SDN — sanctioned ETH addresses",
|
|
31
|
+
"url": ("https://raw.githubusercontent.com/0xB10C/"
|
|
32
|
+
"ofac-sanctioned-digital-currency-addresses/lists/"
|
|
33
|
+
"sanctioned_addresses_ETH.txt"),
|
|
34
|
+
"format": "lines",
|
|
35
|
+
"label": "OFAC SDN (sanctioned)",
|
|
36
|
+
"type": "sanctioned",
|
|
37
|
+
},
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _valid(addr: str) -> bool:
|
|
42
|
+
"""Accept address shapes from every supported chain, not just EVM."""
|
|
43
|
+
a = addr.strip()
|
|
44
|
+
if a.startswith("0x"):
|
|
45
|
+
return len(a) == 42 # EVM
|
|
46
|
+
if a.startswith(("bc1", "tb1")):
|
|
47
|
+
return 26 <= len(a) <= 62 # Bitcoin bech32
|
|
48
|
+
if a.startswith(("1", "3")):
|
|
49
|
+
return 26 <= len(a) <= 35 # Bitcoin legacy / p2sh
|
|
50
|
+
if a.startswith("t"):
|
|
51
|
+
return len(a) == 34 # Tron (lower-cased 'T…')
|
|
52
|
+
return 32 <= len(a) <= 44 # Solana / other base58
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_partials() -> List[dict]:
|
|
56
|
+
"""Addresses published only in truncated form (e.g. 'bc1qq85v2c9…cu9r').
|
|
57
|
+
|
|
58
|
+
Incident reports often abbreviate. Requiring BOTH prefix and suffix to match
|
|
59
|
+
is specific enough to be safe, and lets the tool flag a wallet before the
|
|
60
|
+
full string is public. Exact labels always take priority.
|
|
61
|
+
"""
|
|
62
|
+
path = _HERE / "partial.json"
|
|
63
|
+
if not path.exists():
|
|
64
|
+
return []
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(path.read_text())
|
|
67
|
+
except (ValueError, OSError):
|
|
68
|
+
return []
|
|
69
|
+
out = []
|
|
70
|
+
for e in data.get("entries", []):
|
|
71
|
+
pre, suf = e.get("prefix", ""), e.get("suffix", "")
|
|
72
|
+
if len(pre) >= 8 and len(suf) >= 4: # guard against loose patterns
|
|
73
|
+
out.append(e)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _load() -> None:
|
|
78
|
+
global _KNOWN, _PARTIAL
|
|
79
|
+
if _KNOWN:
|
|
80
|
+
return
|
|
81
|
+
merged: Dict[str, dict] = {}
|
|
82
|
+
# imported first (lower priority)...
|
|
83
|
+
if _IMPORTED.exists():
|
|
84
|
+
try:
|
|
85
|
+
raw = json.loads(_IMPORTED.read_text())
|
|
86
|
+
merged.update({k.lower(): v for k, v in raw.items() if _valid(k.lower())})
|
|
87
|
+
except (ValueError, OSError):
|
|
88
|
+
pass
|
|
89
|
+
# ...then curated seed overrides. An entry that declares its chain is checked
|
|
90
|
+
# with that chain's validator on the original-case key: guessing from the
|
|
91
|
+
# lower-cased prefix drops Solana addresses that happen to start with 1, 3 or T.
|
|
92
|
+
seed = json.loads((_HERE / "known.json").read_text(encoding="utf-8"))
|
|
93
|
+
for k, v in seed.items():
|
|
94
|
+
chain = v.get("chain") if isinstance(v, dict) else None
|
|
95
|
+
ok = addresses.validate(k, chain)[0] if chain else _valid(k.lower())
|
|
96
|
+
if ok:
|
|
97
|
+
merged[k.lower()] = v
|
|
98
|
+
_KNOWN = merged
|
|
99
|
+
_PARTIAL = _load_partials()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _match_partial(address: str) -> Optional[dict]:
|
|
103
|
+
a = address.strip().lower()
|
|
104
|
+
for e in _PARTIAL:
|
|
105
|
+
if a.startswith(e["prefix"].lower()) and a.endswith(e["suffix"].lower()):
|
|
106
|
+
return {"name": e["name"], "type": e["type"], "partial": True,
|
|
107
|
+
"source": e.get("source", "")}
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def update(timeout: int = 30) -> List[tuple]:
|
|
112
|
+
"""Fetch every source and merge results into the imported-labels cache.
|
|
113
|
+
|
|
114
|
+
Returns a list of (source_name, count, error) tuples for reporting.
|
|
115
|
+
"""
|
|
116
|
+
imported: Dict[str, dict] = {}
|
|
117
|
+
if _IMPORTED.exists():
|
|
118
|
+
try:
|
|
119
|
+
imported = json.loads(_IMPORTED.read_text())
|
|
120
|
+
except (ValueError, OSError):
|
|
121
|
+
imported = {}
|
|
122
|
+
|
|
123
|
+
results = []
|
|
124
|
+
for src in SOURCES:
|
|
125
|
+
try:
|
|
126
|
+
resp = requests.get(src["url"], timeout=timeout)
|
|
127
|
+
resp.raise_for_status()
|
|
128
|
+
except requests.RequestException as e:
|
|
129
|
+
results.append((src["name"], 0, str(e)))
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
count = 0
|
|
133
|
+
if src["format"] == "lines":
|
|
134
|
+
for line in resp.text.splitlines():
|
|
135
|
+
addr = line.strip().lower()
|
|
136
|
+
if _valid(addr):
|
|
137
|
+
imported[addr] = {"name": src["label"], "type": src["type"]}
|
|
138
|
+
count += 1
|
|
139
|
+
results.append((src["name"], count, None))
|
|
140
|
+
|
|
141
|
+
_IMPORTED.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
_IMPORTED.write_text(json.dumps(imported, indent=2))
|
|
143
|
+
|
|
144
|
+
# invalidate cache so freshly imported labels take effect immediately
|
|
145
|
+
global _KNOWN
|
|
146
|
+
_KNOWN = {}
|
|
147
|
+
return results
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def count() -> int:
|
|
151
|
+
"""Total number of labelled addresses currently loaded."""
|
|
152
|
+
_load()
|
|
153
|
+
return len(_KNOWN)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# Risk weight per label type (0-100)
|
|
157
|
+
RISK = {
|
|
158
|
+
"sanctioned": 100,
|
|
159
|
+
"mixer": 90,
|
|
160
|
+
"scam": 85,
|
|
161
|
+
"bridge": 40,
|
|
162
|
+
"exchange": 20,
|
|
163
|
+
"unknown": 0,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
TYPE_ICON = {
|
|
167
|
+
"sanctioned": "\U0001F534", # red
|
|
168
|
+
"mixer": "\U0001F7E3", # purple
|
|
169
|
+
"scam": "\U0001F534",
|
|
170
|
+
"bridge": "\U0001F309", # bridge
|
|
171
|
+
"exchange": "\U0001F7E2", # green
|
|
172
|
+
"unknown": "⚪", # white
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def lookup(address: str) -> Optional[dict]:
|
|
177
|
+
_load()
|
|
178
|
+
hit = _KNOWN.get(address.lower())
|
|
179
|
+
if hit:
|
|
180
|
+
return hit
|
|
181
|
+
return _match_partial(address)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def label_of(address: str) -> str:
|
|
185
|
+
hit = lookup(address)
|
|
186
|
+
return hit["name"] if hit else ""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def type_of(address: str) -> str:
|
|
190
|
+
hit = lookup(address)
|
|
191
|
+
return hit["type"] if hit else "unknown"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def risk_score(address: str) -> int:
|
|
195
|
+
return RISK.get(type_of(address), 0)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def icon(address: str) -> str:
|
|
199
|
+
return TYPE_ICON.get(type_of(address), TYPE_ICON["unknown"])
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": [
|
|
3
|
+
"Addresses known publicly only in truncated form, as incident reports and",
|
|
4
|
+
"researcher threads often abbreviate them (e.g. 'bc1qq85v2c9…cu9r').",
|
|
5
|
+
"Matching requires BOTH prefix and suffix, which is specific enough to be",
|
|
6
|
+
"safe; minimum lengths are enforced in labels.py. Exact entries in",
|
|
7
|
+
"known.json always take priority — promote an address here to known.json as",
|
|
8
|
+
"soon as the full string is confirmed.",
|
|
9
|
+
"",
|
|
10
|
+
"Example entry:",
|
|
11
|
+
"{ \"prefix\": \"bc1qq85v2c9\", \"suffix\": \"cu9r\", \"chain\": \"btc\",",
|
|
12
|
+
" \"name\": \"Some Exploiter (2026)\", \"type\": \"scam\", \"source\": \"where it came from\" }"
|
|
13
|
+
],
|
|
14
|
+
"entries": []
|
|
15
|
+
}
|
crypttrace/offramp.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Off-ramp detection — spotting exchange deposit addresses.
|
|
2
|
+
|
|
3
|
+
When laundered funds reach a centralised exchange, they almost never land on the
|
|
4
|
+
exchange's labelled hot wallet directly. They land on a per-user *deposit
|
|
5
|
+
address* the exchange generated, which then forwards the funds inward to the hot
|
|
6
|
+
wallet. There are millions of these, so they're not in any label list — but they
|
|
7
|
+
give themselves away by behaviour: a deposit address receives money and sends
|
|
8
|
+
almost all of it onward to one known exchange wallet.
|
|
9
|
+
|
|
10
|
+
Detecting them turns a plain "⚪ unknown wallet" into "→ Binance deposit address",
|
|
11
|
+
i.e. the cash-out point — the exact place an investigation hands off to a legal
|
|
12
|
+
request (the exchange holds the depositor's KYC).
|
|
13
|
+
"""
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from crypttrace.fetchers import etherscan
|
|
17
|
+
from crypttrace.labels import labels
|
|
18
|
+
from crypttrace import config
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def detect(address: str, chain: str = "eth", threshold: float = 0.6) -> Optional[dict]:
|
|
22
|
+
"""Is `address` acting as a deposit/forwarding address for a known exchange?
|
|
23
|
+
|
|
24
|
+
Returns {exchange, exchange_address, forwarded, out_total, fraction} when at
|
|
25
|
+
least `threshold` of outgoing value goes to labelled exchange wallets,
|
|
26
|
+
otherwise None.
|
|
27
|
+
"""
|
|
28
|
+
me = address.lower()
|
|
29
|
+
txs = etherscan.get_txs(address, chain, limit=1000) # cached; shared with trace
|
|
30
|
+
out_total = 0.0
|
|
31
|
+
to_exchange = {} # exchange_addr -> value
|
|
32
|
+
for tx in txs:
|
|
33
|
+
if tx.get("from", "").lower() != me:
|
|
34
|
+
continue
|
|
35
|
+
to = tx.get("to", "").lower()
|
|
36
|
+
if not to:
|
|
37
|
+
continue
|
|
38
|
+
val = int(tx.get("value", 0)) / config.WEI
|
|
39
|
+
out_total += val
|
|
40
|
+
if labels.type_of(to) == "exchange":
|
|
41
|
+
to_exchange[to] = to_exchange.get(to, 0.0) + val
|
|
42
|
+
|
|
43
|
+
if out_total <= 0 or not to_exchange:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
forwarded = sum(to_exchange.values())
|
|
47
|
+
fraction = forwarded / out_total
|
|
48
|
+
if fraction < threshold:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
best = max(to_exchange, key=to_exchange.get)
|
|
52
|
+
return {
|
|
53
|
+
"exchange": labels.label_of(best),
|
|
54
|
+
"exchange_address": best,
|
|
55
|
+
"forwarded": forwarded,
|
|
56
|
+
"out_total": out_total,
|
|
57
|
+
"fraction": fraction,
|
|
58
|
+
}
|
crypttrace/prices.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""USD price lookups, with graceful offline degradation.
|
|
2
|
+
|
|
3
|
+
Stablecoins are pinned to $1. Everything else is fetched from CoinGecko
|
|
4
|
+
(free, no key) by contract address and cached for the session. If the network
|
|
5
|
+
is unavailable, price() returns None and callers simply show '—' for USD.
|
|
6
|
+
"""
|
|
7
|
+
from typing import Optional, Dict
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
# symbols treated as ~$1
|
|
12
|
+
_STABLE = {"usdt", "usdc", "dai", "busd", "tusd", "usdp", "gusd", "frax", "lusd"}
|
|
13
|
+
|
|
14
|
+
# CoinGecko platform id per chain
|
|
15
|
+
_PLATFORM = {"eth": "ethereum", "bsc": "binance-smart-chain", "polygon": "polygon-pos",
|
|
16
|
+
"arbitrum": "arbitrum-one", "optimism": "optimistic-ethereum", "base": "base",
|
|
17
|
+
"tron": "tron", "sol": "solana"}
|
|
18
|
+
_COINGECKO_NATIVE = {"eth": "ethereum", "bsc": "binancecoin", "polygon": "matic-network",
|
|
19
|
+
"arbitrum": "ethereum", "optimism": "ethereum", "base": "ethereum",
|
|
20
|
+
"btc": "bitcoin", "tron": "tron", "sol": "solana"}
|
|
21
|
+
|
|
22
|
+
_cache: Dict[str, Optional[float]] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_json(url: str, params: dict, timeout: int = 15):
|
|
26
|
+
try:
|
|
27
|
+
r = requests.get(url, params=params, timeout=timeout)
|
|
28
|
+
r.raise_for_status()
|
|
29
|
+
return r.json()
|
|
30
|
+
except (requests.RequestException, ValueError):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def native_price(chain: str = "eth") -> Optional[float]:
|
|
35
|
+
"""USD price of the chain's native coin (ETH, BTC, TRX, SOL, …).
|
|
36
|
+
|
|
37
|
+
Unknown chains return None rather than guessing — pricing a BTC balance with
|
|
38
|
+
ETH's price would silently corrupt an investigation.
|
|
39
|
+
"""
|
|
40
|
+
key = f"native:{chain}"
|
|
41
|
+
if key in _cache:
|
|
42
|
+
return _cache[key]
|
|
43
|
+
cid = _COINGECKO_NATIVE.get(chain)
|
|
44
|
+
if cid is None:
|
|
45
|
+
_cache[key] = None
|
|
46
|
+
return None
|
|
47
|
+
data = _get_json("https://api.coingecko.com/api/v3/simple/price",
|
|
48
|
+
{"ids": cid, "vs_currencies": "usd"})
|
|
49
|
+
price = None
|
|
50
|
+
if data and cid in data:
|
|
51
|
+
price = data[cid].get("usd")
|
|
52
|
+
_cache[key] = price
|
|
53
|
+
return price
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def token_price(contract: str, chain: str = "eth", symbol: str = "") -> Optional[float]:
|
|
57
|
+
"""USD price of an ERC-20 token by contract. Stablecoins short-circuit to $1."""
|
|
58
|
+
if symbol.lower() in _STABLE:
|
|
59
|
+
return 1.0
|
|
60
|
+
key = f"{chain}:{contract.lower()}"
|
|
61
|
+
if key in _cache:
|
|
62
|
+
return _cache[key]
|
|
63
|
+
platform = _PLATFORM.get(chain, "ethereum")
|
|
64
|
+
data = _get_json(f"https://api.coingecko.com/api/v3/simple/token_price/{platform}",
|
|
65
|
+
{"contract_addresses": contract, "vs_currencies": "usd"})
|
|
66
|
+
price = None
|
|
67
|
+
if data:
|
|
68
|
+
entry = data.get(contract.lower()) or next(iter(data.values()), None)
|
|
69
|
+
if isinstance(entry, dict):
|
|
70
|
+
price = entry.get("usd")
|
|
71
|
+
_cache[key] = price
|
|
72
|
+
return price
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def usd(amount: float, price: Optional[float]) -> Optional[float]:
|
|
76
|
+
if price is None:
|
|
77
|
+
return None
|
|
78
|
+
return amount * price
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fmt_usd(value: Optional[float]) -> str:
|
|
82
|
+
"""Human-friendly USD string: $1.2M, $340.5K, $12.34, or '—'."""
|
|
83
|
+
if value is None:
|
|
84
|
+
return "—"
|
|
85
|
+
a = abs(value)
|
|
86
|
+
if a >= 1_000_000:
|
|
87
|
+
return f"${value/1_000_000:.2f}M"
|
|
88
|
+
if a >= 1_000:
|
|
89
|
+
return f"${value/1_000:.1f}K"
|
|
90
|
+
return f"${value:.2f}"
|
crypttrace/render.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Terminal rendering helpers (rich)."""
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from rich.tree import Tree
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
from crypttrace.labels import labels
|
|
8
|
+
from crypttrace import config, prices
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _ts(unix: str) -> str:
|
|
12
|
+
try:
|
|
13
|
+
return datetime.fromtimestamp(int(unix), tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
|
14
|
+
except (ValueError, TypeError):
|
|
15
|
+
return "?"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def addr_label(address: str) -> Text:
|
|
19
|
+
"""Coloured address with icon + known label."""
|
|
20
|
+
t = labels.type_of(address)
|
|
21
|
+
color = {
|
|
22
|
+
"sanctioned": "bold red", "mixer": "magenta", "scam": "bold red",
|
|
23
|
+
"bridge": "cyan", "exchange": "green", "unknown": "white",
|
|
24
|
+
}.get(t, "white")
|
|
25
|
+
name = labels.label_of(address)
|
|
26
|
+
short = address[:10] + "…" + address[-6:]
|
|
27
|
+
txt = Text()
|
|
28
|
+
txt.append(labels.icon(address) + " ")
|
|
29
|
+
txt.append(short, style=color)
|
|
30
|
+
if name:
|
|
31
|
+
txt.append(f" [{name}]", style=color + " dim")
|
|
32
|
+
return txt
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def profile_table(address: str, chain: str, balance: float, txs: list,
|
|
36
|
+
native_price=None) -> Table:
|
|
37
|
+
t = Table(title=f"Profile — {address} ({chain})", show_header=True, header_style="bold")
|
|
38
|
+
t.add_column("Field")
|
|
39
|
+
t.add_column("Value")
|
|
40
|
+
bal_usd = prices.usd(balance, native_price)
|
|
41
|
+
bal_str = f"{balance:.6f} (native)"
|
|
42
|
+
if bal_usd is not None:
|
|
43
|
+
bal_str += f" ≈ {prices.fmt_usd(bal_usd)}"
|
|
44
|
+
t.add_row("Balance", bal_str)
|
|
45
|
+
t.add_row("Total txs (fetched)", str(len(txs)))
|
|
46
|
+
if txs:
|
|
47
|
+
t.add_row("First seen", _ts(txs[-1]["timeStamp"]))
|
|
48
|
+
t.add_row("Last seen", _ts(txs[0]["timeStamp"]))
|
|
49
|
+
hit = labels.lookup(address)
|
|
50
|
+
if hit:
|
|
51
|
+
t.add_row("Label", f"{hit['name']} ({hit['type']})")
|
|
52
|
+
t.add_row("Risk score", f"{labels.risk_score(address)}/100")
|
|
53
|
+
return t
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def counterparties_table(address: str, txs: list, top: int = 10) -> Table:
|
|
57
|
+
"""Aggregate value moved per counterparty."""
|
|
58
|
+
me = address.lower()
|
|
59
|
+
agg = {} # counterparty -> [in_value, out_value, count]
|
|
60
|
+
for tx in txs:
|
|
61
|
+
frm, to = tx.get("from", "").lower(), tx.get("to", "").lower()
|
|
62
|
+
val = int(tx.get("value", 0)) / config.WEI
|
|
63
|
+
other = to if frm == me else frm
|
|
64
|
+
if not other:
|
|
65
|
+
continue
|
|
66
|
+
rec = agg.setdefault(other, [0.0, 0.0, 0])
|
|
67
|
+
if frm == me:
|
|
68
|
+
rec[1] += val # outgoing
|
|
69
|
+
else:
|
|
70
|
+
rec[0] += val # incoming
|
|
71
|
+
rec[2] += 1
|
|
72
|
+
rows = sorted(agg.items(), key=lambda kv: kv[1][0] + kv[1][1], reverse=True)[:top]
|
|
73
|
+
|
|
74
|
+
t = Table(title="Top counterparties", header_style="bold")
|
|
75
|
+
t.add_column("Address")
|
|
76
|
+
t.add_column("In", justify="right")
|
|
77
|
+
t.add_column("Out", justify="right")
|
|
78
|
+
t.add_column("Txs", justify="right")
|
|
79
|
+
for other, (vin, vout, cnt) in rows:
|
|
80
|
+
t.add_row(addr_label(other), f"{vin:.4f}", f"{vout:.4f}", str(cnt))
|
|
81
|
+
return t
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def profile_rows_table(address: str, chain: str, balance: float, rows: list,
|
|
85
|
+
native_price=None, symbol: str = "") -> Table:
|
|
86
|
+
"""Profile built from normalized transfer rows (works on every chain)."""
|
|
87
|
+
t = Table(title=f"Profile — {address} ({chain})", show_header=True, header_style="bold")
|
|
88
|
+
t.add_column("Field")
|
|
89
|
+
t.add_column("Value")
|
|
90
|
+
bal_usd = prices.usd(balance, native_price)
|
|
91
|
+
bal_str = f"{balance:.8f} {symbol}".rstrip()
|
|
92
|
+
if bal_usd is not None:
|
|
93
|
+
bal_str += f" ≈ {prices.fmt_usd(bal_usd)}"
|
|
94
|
+
t.add_row("Balance", bal_str)
|
|
95
|
+
t.add_row("Transfers (fetched)", str(len(rows)))
|
|
96
|
+
if rows:
|
|
97
|
+
t.add_row("First seen", _ts(str(rows[-1].get("timestamp", 0))))
|
|
98
|
+
t.add_row("Last seen", _ts(str(rows[0].get("timestamp", 0))))
|
|
99
|
+
hit = labels.lookup(address)
|
|
100
|
+
if hit:
|
|
101
|
+
t.add_row("Label", f"{hit['name']} ({hit['type']})")
|
|
102
|
+
t.add_row("Risk score", f"{labels.risk_score(address)}/100")
|
|
103
|
+
return t
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def counterparties_rows_table(address: str, chain: str, rows: list, top: int = 10) -> Table:
|
|
107
|
+
"""Counterparties from normalized rows (works on every chain)."""
|
|
108
|
+
me = address if chain in ("btc", "tron", "sol") else address.lower()
|
|
109
|
+
agg = {}
|
|
110
|
+
for r in rows:
|
|
111
|
+
frm, to = r.get("from", ""), r.get("to", "")
|
|
112
|
+
other = to if frm == me else frm
|
|
113
|
+
if not other:
|
|
114
|
+
continue
|
|
115
|
+
rec = agg.setdefault(other, [0.0, 0.0, 0])
|
|
116
|
+
if frm == me:
|
|
117
|
+
rec[1] += r.get("value", 0.0)
|
|
118
|
+
else:
|
|
119
|
+
rec[0] += r.get("value", 0.0)
|
|
120
|
+
rec[2] += 1
|
|
121
|
+
ranked = sorted(agg.items(), key=lambda kv: kv[1][0] + kv[1][1], reverse=True)[:top]
|
|
122
|
+
|
|
123
|
+
t = Table(title="Top counterparties", header_style="bold")
|
|
124
|
+
t.add_column("Address")
|
|
125
|
+
t.add_column("In", justify="right")
|
|
126
|
+
t.add_column("Out", justify="right")
|
|
127
|
+
t.add_column("Txs", justify="right")
|
|
128
|
+
for other, (vin, vout, cnt) in ranked:
|
|
129
|
+
t.add_row(addr_label(other), f"{vin:.4f}", f"{vout:.4f}", str(cnt))
|
|
130
|
+
return t
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def sources_table(rows: list, symbol: str, top: int = 25) -> Table:
|
|
134
|
+
"""Addresses that fed a wallet — in a mass theft, the victim list."""
|
|
135
|
+
t = Table(title=f"Addresses that sent funds here (top {min(top, len(rows))} of {len(rows)})",
|
|
136
|
+
header_style="bold")
|
|
137
|
+
t.add_column("Address")
|
|
138
|
+
t.add_column("Hop", justify="right")
|
|
139
|
+
t.add_column(f"Sent ({symbol})", justify="right")
|
|
140
|
+
t.add_column("Txs", justify="right")
|
|
141
|
+
t.add_column("When (UTC)")
|
|
142
|
+
for r in rows[:top]:
|
|
143
|
+
t.add_row(addr_label(r["address"]), str(r.get("hop", 1)),
|
|
144
|
+
f"{r['value']:.8f}".rstrip("0").rstrip("."), str(r["txs"]),
|
|
145
|
+
_ts(str(r.get("first_ts") or 0)))
|
|
146
|
+
return t
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def timeline_chart(tl: dict, symbol: str, width: int = 42) -> Table:
|
|
150
|
+
"""Text histogram of activity over time."""
|
|
151
|
+
t = Table(title="Activity over time", header_style="bold", box=None, pad_edge=False)
|
|
152
|
+
t.add_column("From (UTC)")
|
|
153
|
+
t.add_column("Transfers", justify="right")
|
|
154
|
+
t.add_column("")
|
|
155
|
+
t.add_column(f"In ({symbol})", justify="right")
|
|
156
|
+
t.add_column(f"Out ({symbol})", justify="right")
|
|
157
|
+
peak = max((b["count"] for b in tl["buckets"]), default=0) or 1
|
|
158
|
+
for b in tl["buckets"]:
|
|
159
|
+
if b["count"] == 0:
|
|
160
|
+
continue
|
|
161
|
+
bar = "█" * max(1, int(b["count"] / peak * width))
|
|
162
|
+
t.add_row(_ts(str(b["start"])), str(b["count"]),
|
|
163
|
+
f"[cyan]{bar}[/cyan]",
|
|
164
|
+
f"{b['in']:.4f}" if b["in"] else "",
|
|
165
|
+
f"{b['out']:.4f}" if b["out"] else "")
|
|
166
|
+
return t
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cluster_table(address: str, peers: list) -> Table:
|
|
170
|
+
"""Bitcoin common-input-ownership clustering results."""
|
|
171
|
+
t = Table(title=f"Likely same-owner addresses — {address}", header_style="bold")
|
|
172
|
+
t.add_column("Address")
|
|
173
|
+
t.add_column("Co-signed inputs", justify="right")
|
|
174
|
+
for a, n in peers:
|
|
175
|
+
t.add_row(addr_label(a), str(n))
|
|
176
|
+
return t
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def crosschain_tree(address: str, chain: str, results: list) -> Tree:
|
|
180
|
+
"""Render bridge-outs and their likely cross-chain continuations."""
|
|
181
|
+
root = Tree(Text.assemble(addr_label(address), Text(f" (source chain: {chain})")))
|
|
182
|
+
for r in results:
|
|
183
|
+
out = r["bridge_out"]
|
|
184
|
+
when = _ts(str(out["timestamp"]))
|
|
185
|
+
bnode = root.add(Text(f"🌉 bridged {out['amount']:.4f} via {out['bridge']} ({when})",
|
|
186
|
+
style="cyan"))
|
|
187
|
+
if not r["arrivals"]:
|
|
188
|
+
bnode.add(Text("↳ no matching arrival found on other chains "
|
|
189
|
+
"(try a wider --window / --tol, or funds bridged as a token)",
|
|
190
|
+
style="dim"))
|
|
191
|
+
continue
|
|
192
|
+
for a in r["arrivals"]:
|
|
193
|
+
bnode.add(Text.assemble(
|
|
194
|
+
Text(f"↳ likely continued on {a['chain'].upper()}: received "
|
|
195
|
+
f"{a['amount']:.4f} ", style="green"),
|
|
196
|
+
Text(f"(+{a['delay_min']:.0f} min, from "),
|
|
197
|
+
addr_label(a["from"]),
|
|
198
|
+
Text(")"),
|
|
199
|
+
))
|
|
200
|
+
return root
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def funding_tree(address: str, hops: list) -> Tree:
|
|
204
|
+
"""Render a backward funding chain: target ← funder ← funder …"""
|
|
205
|
+
root = Tree(addr_label(address))
|
|
206
|
+
if not hops:
|
|
207
|
+
root.add(Text("no inbound funding tx found (first-funded internally, or too old)",
|
|
208
|
+
style="dim"))
|
|
209
|
+
return root
|
|
210
|
+
node = root
|
|
211
|
+
for h in hops:
|
|
212
|
+
when = _ts(h["timestamp"])
|
|
213
|
+
edge = Text(f"◀── funded by {h['value']:.4f} ETH ({when}) ")
|
|
214
|
+
node = node.add(Text.assemble(edge, addr_label(h["funder"])))
|
|
215
|
+
if h["terminal"]:
|
|
216
|
+
kind = h["funder_type"]
|
|
217
|
+
note = {"exchange": "KYC identification point",
|
|
218
|
+
"mixer": "mixer — trail obscured",
|
|
219
|
+
"sanctioned": "sanctioned entity",
|
|
220
|
+
"bridge": "cross-chain bridge"}.get(kind, kind)
|
|
221
|
+
node.add(Text(f"↳ chain ends at {kind} ({note})", style="dim"))
|
|
222
|
+
return root
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def holdings_table(address: str, chain: str, holdings: list) -> Table:
|
|
226
|
+
"""Token holdings with per-token and total USD value."""
|
|
227
|
+
t = Table(title=f"Token holdings — {address} ({chain})", header_style="bold")
|
|
228
|
+
t.add_column("Token")
|
|
229
|
+
t.add_column("Amount", justify="right")
|
|
230
|
+
t.add_column("USD", justify="right")
|
|
231
|
+
t.add_column("Txs", justify="right")
|
|
232
|
+
total = 0.0
|
|
233
|
+
for h in holdings:
|
|
234
|
+
price = prices.token_price(h["contract"], chain, h["symbol"])
|
|
235
|
+
usd = prices.usd(h["net"], price)
|
|
236
|
+
if usd is not None:
|
|
237
|
+
total += usd
|
|
238
|
+
t.add_row(h["symbol"], f"{h['net']:.4f}", prices.fmt_usd(usd), str(h["txs"]))
|
|
239
|
+
t.add_section()
|
|
240
|
+
t.add_row("[bold]Total[/bold]", "", f"[bold]{prices.fmt_usd(total)}[/bold]", "")
|
|
241
|
+
return t
|