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.
@@ -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