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/report.py ADDED
@@ -0,0 +1,175 @@
1
+ """Generate a full investigation report (Markdown + JSON) for an address.
2
+
3
+ Runs the same analysis as `profile` + `trace`, then writes a self-contained
4
+ report to a folder the user can easily find (default: ~/crypttrace-reports).
5
+ """
6
+ import json
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+
12
+ from crypttrace import __version__, config, prices
13
+ from crypttrace.fetchers import etherscan
14
+ from crypttrace.labels import labels
15
+ from crypttrace import trace as trace_mod
16
+
17
+
18
+ def _now() -> str:
19
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
20
+
21
+
22
+ def _ts(unix: str) -> str:
23
+ try:
24
+ return datetime.fromtimestamp(int(unix), tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
25
+ except (ValueError, TypeError):
26
+ return "?"
27
+
28
+
29
+ def _counterparties(address: str, txs: list, top: int = 15):
30
+ me = address.lower()
31
+ agg = {}
32
+ for tx in txs:
33
+ frm, to = tx.get("from", "").lower(), tx.get("to", "").lower()
34
+ val = int(tx.get("value", 0)) / config.WEI
35
+ other = to if frm == me else frm
36
+ if not other:
37
+ continue
38
+ rec = agg.setdefault(other, [0.0, 0.0, 0])
39
+ if frm == me:
40
+ rec[1] += val
41
+ else:
42
+ rec[0] += val
43
+ rec[2] += 1
44
+ rows = sorted(agg.items(), key=lambda kv: kv[1][0] + kv[1][1], reverse=True)[:top]
45
+ return [
46
+ {"address": a, "in": round(vin, 6), "out": round(vout, 6), "txs": cnt,
47
+ "label": labels.label_of(a), "type": labels.type_of(a)}
48
+ for a, (vin, vout, cnt) in rows
49
+ ]
50
+
51
+
52
+ def _tree_text(tree) -> str:
53
+ """Render the rich trace tree to plain text (ANSI stripped)."""
54
+ con = Console(record=True, width=100, file=None)
55
+ with con.capture() as cap:
56
+ con.print(tree)
57
+ return cap.get()
58
+
59
+
60
+ def _headline(findings: list) -> str:
61
+ types = {f["type"] for f in findings}
62
+ if "sanctioned" in types:
63
+ return ("Funds from this address reach a **sanctioned / known-criminal wallet** — "
64
+ "escalate to law enforcement.")
65
+ if "mixer" in types:
66
+ return ("Funds from this address flow into a **mixer** (privacy pool), where on-chain "
67
+ "tracing terminates. Recovery from here requires timing/amount heuristics or "
68
+ "off-chain data.")
69
+ if "exchange" in types:
70
+ return ("Funds from this address reach a **centralised exchange** — a KYC handoff point. "
71
+ "Identifying the owner requires a legal request to that exchange.")
72
+ return ("No labelled entities were reached within the traced depth. Increase --depth or "
73
+ "extend the label database, then re-run.")
74
+
75
+
76
+ def generate(address: str, chain: str, depth: int, branching: int,
77
+ out_dir: Path, asset=None) -> Path:
78
+ balance = etherscan.get_balance(address, chain)
79
+ txs = etherscan.get_txs(address, chain, limit=1000)
80
+ tree, findings = trace_mod.build(address, chain, depth, branching, asset)
81
+
82
+ # de-duplicate findings by address, keep highest value_reached
83
+ uniq = {}
84
+ for f in findings:
85
+ cur = uniq.get(f["address"])
86
+ if cur is None or f["value_reached"] > cur["value_reached"]:
87
+ uniq[f["address"]] = f
88
+ findings = sorted(uniq.values(), key=lambda f: f["risk"], reverse=True)
89
+
90
+ counterparties = _counterparties(address, txs)
91
+ hit = labels.lookup(address)
92
+
93
+ data = {
94
+ "tool": f"crypttrace v{__version__}",
95
+ "generated": _now(),
96
+ "subject": address,
97
+ "chain": chain,
98
+ "trace_depth": depth,
99
+ "trace_branching": branching,
100
+ "summary": {
101
+ "balance_native": round(balance, 6),
102
+ "txs_analysed": len(txs),
103
+ "first_seen": _ts(txs[-1]["timeStamp"]) if txs else None,
104
+ "last_seen": _ts(txs[0]["timeStamp"]) if txs else None,
105
+ "label": hit["name"] if hit else None,
106
+ "type": labels.type_of(address),
107
+ "risk_score": labels.risk_score(address),
108
+ },
109
+ "traced_asset": asset["symbol"] if asset else "ETH (native)",
110
+ "key_findings": findings,
111
+ "top_counterparties": counterparties,
112
+ }
113
+
114
+ out_dir.mkdir(parents=True, exist_ok=True)
115
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
116
+ base = f"{chain}_{address[:10]}_{stamp}"
117
+ json_path = out_dir / f"{base}.json"
118
+ md_path = out_dir / f"{base}.md"
119
+
120
+ json_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
121
+ md_path.write_text(_render_md(data, _tree_text(tree), json_path.name), encoding="utf-8")
122
+ return md_path
123
+
124
+
125
+ def _render_md(d: dict, tree_text: str, json_name: str) -> str:
126
+ s = d["summary"]
127
+ L = []
128
+ L.append(f"# crypttrace investigation report\n")
129
+ L.append(f"**Generated:** {d['generated']} • **Tool:** {d['tool']} \n")
130
+ L.append(f"**Subject:** `{d['subject']}` • **Chain:** {d['chain']} • "
131
+ f"**Traced asset:** {d.get('traced_asset', 'ETH (native)')}\n")
132
+
133
+ L.append(f"\n## Assessment\n")
134
+ L.append(_headline(d["key_findings"]) + "\n")
135
+
136
+ L.append(f"\n## Summary\n")
137
+ L.append(f"| Field | Value |\n|---|---|\n")
138
+ L.append(f"| Balance | {s['balance_native']} (native) |\n")
139
+ L.append(f"| Transactions analysed | {s['txs_analysed']} |\n")
140
+ L.append(f"| First seen | {s['first_seen']} |\n")
141
+ L.append(f"| Last seen | {s['last_seen']} |\n")
142
+ L.append(f"| Label | {s['label'] or '—'} |\n")
143
+ L.append(f"| Risk score | {s['risk_score']}/100 |\n")
144
+
145
+ L.append(f"\n## Key findings\n")
146
+ if d["key_findings"]:
147
+ L.append("Labelled entities reached while tracing funds outward from the subject:\n\n")
148
+ L.append("| Entity | Type | Risk | Value reached | ≈ USD |\n|---|---|---|---|---|\n")
149
+ for f in d["key_findings"]:
150
+ sym = f.get("symbol", "")
151
+ usd = prices.fmt_usd(f.get("usd_reached"))
152
+ L.append(f"| {f['label']} | {f['type']} | {f['risk']}/100 | "
153
+ f"{f['value_reached']} {sym} | {usd} |\n")
154
+ else:
155
+ L.append("_None within the traced depth._\n")
156
+
157
+ L.append(f"\n## Top counterparties\n")
158
+ L.append("| Address | Label | In | Out | Txs |\n|---|---|---|---|---|\n")
159
+ for c in d["top_counterparties"]:
160
+ L.append(f"| `{c['address']}` | {c['label'] or '—'} | {c['in']} | {c['out']} | {c['txs']} |\n")
161
+
162
+ L.append(f"\n## Fund-flow trace (depth {d['trace_depth']})\n")
163
+ L.append("```\n" + tree_text.rstrip() + "\n```\n")
164
+
165
+ L.append(f"\n## Methodology & limitations\n")
166
+ L.append(
167
+ "Data is sourced from the public blockchain via Etherscan. Tracing follows the largest "
168
+ "outgoing transfers from each address and stops at identifiable entities (exchanges, "
169
+ "mixers, sanctioned wallets). Note: the blockchain is pseudonymous — reaching an address "
170
+ "does not identify its owner. Mixers sever the on-chain trail; exchanges require a legal "
171
+ "request to attribute ownership. This report is an investigative aid, not proof of "
172
+ "wrongdoing.\n")
173
+
174
+ L.append(f"\n---\n_Raw structured data: `{json_name}` (same folder)._\n")
175
+ return "".join(L)
crypttrace/store.py ADDED
@@ -0,0 +1,215 @@
1
+ """Local store of normalized transfers.
2
+
3
+ Until now every command re-fetched from the network, so looking at the same
4
+ case from a second angle meant paying for the whole traversal again — and the
5
+ depth of an investigation was capped by how much could be held in memory during
6
+ one run.
7
+
8
+ This keeps what was already fetched in SQLite, in the same normalized shape the
9
+ rest of the tool uses. Three things follow from that:
10
+
11
+ * re-analysis is instant and works with the network unplugged;
12
+ * a trace can be resumed or extended instead of restarted;
13
+ * addresses can be queried *across* each other in SQL, which is what a
14
+ multi-address case needs — the merging we previously did by hand.
15
+
16
+ Rows are deduplicated by content, so fetching the same transaction from both
17
+ ends of it stores one copy.
18
+ """
19
+ import hashlib
20
+ import sqlite3
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Dict, List, Optional
24
+
25
+ from crypttrace import config
26
+
27
+ DB_PATH: Path = config.DATA_DIR / "transfers.sqlite"
28
+
29
+ # how long stored data is considered current before we go back to the network
30
+ DEFAULT_MAX_AGE = 24 * 3600
31
+
32
+
33
+ def _conn() -> sqlite3.Connection:
34
+ c = sqlite3.connect(DB_PATH)
35
+ c.execute("PRAGMA journal_mode=WAL")
36
+ c.executescript("""
37
+ CREATE TABLE IF NOT EXISTS transfers (
38
+ id TEXT PRIMARY KEY,
39
+ chain TEXT NOT NULL,
40
+ tx_hash TEXT,
41
+ from_addr TEXT,
42
+ to_addr TEXT,
43
+ value REAL,
44
+ symbol TEXT,
45
+ contract TEXT,
46
+ ts INTEGER,
47
+ fee_share REAL
48
+ );
49
+ CREATE INDEX IF NOT EXISTS ix_to ON transfers(chain, to_addr);
50
+ CREATE INDEX IF NOT EXISTS ix_from ON transfers(chain, from_addr);
51
+ CREATE INDEX IF NOT EXISTS ix_ts ON transfers(ts);
52
+
53
+ CREATE TABLE IF NOT EXISTS fetched (
54
+ chain TEXT NOT NULL,
55
+ address TEXT NOT NULL,
56
+ asset TEXT NOT NULL DEFAULT '',
57
+ fetched_at INTEGER,
58
+ rows INTEGER,
59
+ complete INTEGER,
60
+ PRIMARY KEY (chain, address, asset)
61
+ );
62
+ """)
63
+ return c
64
+
65
+
66
+ def _row_id(chain: str, r: dict) -> str:
67
+ """Content hash — the same transfer seen from either side stores once."""
68
+ key = "|".join([
69
+ chain, str(r.get("hash", "")), str(r.get("from", "")), str(r.get("to", "")),
70
+ str(r.get("symbol", "")), f"{float(r.get('value', 0)):.12f}",
71
+ ])
72
+ return hashlib.sha1(key.encode()).hexdigest()
73
+
74
+
75
+ def save(chain: str, address: str, rows: List[dict], asset_key: str = "",
76
+ complete: bool = True) -> int:
77
+ """Persist transfers and note that this address was fetched."""
78
+ c = _conn()
79
+ try:
80
+ c.executemany(
81
+ "INSERT OR REPLACE INTO transfers"
82
+ "(id,chain,tx_hash,from_addr,to_addr,value,symbol,contract,ts,fee_share)"
83
+ " VALUES (?,?,?,?,?,?,?,?,?,?)",
84
+ [(_row_id(chain, r), chain, r.get("hash", ""), r.get("from", ""),
85
+ r.get("to", ""), float(r.get("value", 0) or 0), r.get("symbol", ""),
86
+ r.get("contract", ""), int(r.get("timestamp", 0) or 0),
87
+ float(r.get("fee_share", 0) or 0)) for r in rows])
88
+ c.execute("INSERT OR REPLACE INTO fetched(chain,address,asset,fetched_at,rows,complete)"
89
+ " VALUES (?,?,?,?,?,?)",
90
+ (chain, address, asset_key, int(time.time()), len(rows), int(complete)))
91
+ c.commit()
92
+ return len(rows)
93
+ finally:
94
+ c.close()
95
+
96
+
97
+ def age(chain: str, address: str, asset_key: str = "") -> Optional[int]:
98
+ """Seconds since this address was last fetched, or None if never."""
99
+ c = _conn()
100
+ try:
101
+ row = c.execute("SELECT fetched_at FROM fetched WHERE chain=? AND address=? AND asset=?",
102
+ (chain, address, asset_key)).fetchone()
103
+ finally:
104
+ c.close()
105
+ return None if not row else int(time.time()) - row[0]
106
+
107
+
108
+ def is_fresh(chain: str, address: str, asset_key: str = "",
109
+ max_age: int = DEFAULT_MAX_AGE) -> bool:
110
+ a = age(chain, address, asset_key)
111
+ return a is not None and a <= max_age
112
+
113
+
114
+ def load(chain: str, address: str, contract: str = "", native_symbol: str = "") -> List[dict]:
115
+ """Every stored transfer touching this address, newest first."""
116
+ c = _conn()
117
+ try:
118
+ if contract:
119
+ q = ("SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share"
120
+ " FROM transfers WHERE chain=? AND (from_addr=? OR to_addr=?)"
121
+ " AND lower(contract)=lower(?) ORDER BY ts DESC")
122
+ args = (chain, address, address, contract)
123
+ else:
124
+ # native asset: rows carry no contract
125
+ q = ("SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share"
126
+ " FROM transfers WHERE chain=? AND (from_addr=? OR to_addr=?)"
127
+ " AND (contract IS NULL OR contract='') ORDER BY ts DESC")
128
+ args = (chain, address, address)
129
+ rows = c.execute(q, args).fetchall()
130
+ finally:
131
+ c.close()
132
+ out = []
133
+ for f, t, v, sym, con, ts, h, fee in rows:
134
+ r = {"from": f, "to": t, "value": v, "symbol": sym,
135
+ "timestamp": ts, "hash": h}
136
+ if con:
137
+ r["contract"] = con
138
+ if fee:
139
+ r["fee_share"] = fee
140
+ out.append(r)
141
+ return out
142
+
143
+
144
+ # ---- queries that span addresses (what a multi-address case needs) ----
145
+
146
+ def sources_of(chain: str, addresses: List[str], min_value: float = 0.0) -> List[dict]:
147
+ """Everyone who funded any of these addresses, aggregated — across a whole case."""
148
+ if not addresses:
149
+ return []
150
+ marks = ",".join("?" * len(addresses))
151
+ c = _conn()
152
+ try:
153
+ rows = c.execute(
154
+ f"SELECT from_addr, SUM(value), COUNT(*), MIN(ts), MAX(ts)"
155
+ f" FROM transfers WHERE chain=? AND to_addr IN ({marks})"
156
+ f" AND value >= ? AND from_addr NOT IN ({marks})"
157
+ f" GROUP BY from_addr ORDER BY SUM(value) DESC",
158
+ (chain, *addresses, min_value, *addresses)).fetchall()
159
+ finally:
160
+ c.close()
161
+ return [{"address": a, "value": v, "txs": n, "first_ts": lo, "last_ts": hi}
162
+ for a, v, n, lo, hi in rows]
163
+
164
+
165
+ def path_exists(chain: str, src: str, dst: str, max_hops: int = 4) -> Optional[List[str]]:
166
+ """Shortest stored path of transfers from src to dst — 'are these connected?'"""
167
+ c = _conn()
168
+ try:
169
+ frontier = {src: [src]}
170
+ seen = {src}
171
+ for _ in range(max_hops):
172
+ if not frontier:
173
+ break
174
+ marks = ",".join("?" * len(frontier))
175
+ rows = c.execute(
176
+ f"SELECT from_addr, to_addr FROM transfers"
177
+ f" WHERE chain=? AND from_addr IN ({marks})",
178
+ (chain, *frontier.keys())).fetchall()
179
+ nxt = {}
180
+ for f, t in rows:
181
+ if not t or t in seen:
182
+ continue
183
+ p = frontier[f] + [t]
184
+ if t == dst:
185
+ return p
186
+ seen.add(t)
187
+ nxt[t] = p
188
+ frontier = nxt
189
+ finally:
190
+ c.close()
191
+ return None
192
+
193
+
194
+ def stats() -> Dict:
195
+ c = _conn()
196
+ try:
197
+ transfers = c.execute("SELECT COUNT(*) FROM transfers").fetchone()[0]
198
+ addresses = c.execute("SELECT COUNT(*) FROM fetched").fetchone()[0]
199
+ chains = c.execute("SELECT chain, COUNT(*) FROM transfers GROUP BY chain").fetchall()
200
+ oldest = c.execute("SELECT MIN(fetched_at) FROM fetched").fetchone()[0]
201
+ finally:
202
+ c.close()
203
+ return {"transfers": transfers, "addresses": addresses,
204
+ "by_chain": dict(chains), "oldest_fetch": oldest,
205
+ "path": str(DB_PATH),
206
+ "size_bytes": DB_PATH.stat().st_size if DB_PATH.exists() else 0}
207
+
208
+
209
+ def clear() -> None:
210
+ c = _conn()
211
+ try:
212
+ c.executescript("DELETE FROM transfers; DELETE FROM fetched;")
213
+ c.commit()
214
+ finally:
215
+ c.close()
crypttrace/trace.py ADDED
@@ -0,0 +1,165 @@
1
+ """Fund-flow tracing: follow outgoing value (native coin or a token) N hops deep."""
2
+ from typing import Optional
3
+ from rich.tree import Tree
4
+ from rich.text import Text
5
+
6
+ from crypttrace.fetchers import etherscan
7
+ from crypttrace.labels import labels
8
+ from crypttrace import config, render, assets, prices, offramp
9
+
10
+
11
+ def _outflows(address, chain, top, asset, direction="out"):
12
+ """Flows for one asset on any supported chain (native coin or a token)."""
13
+ from crypttrace import chains
14
+ return chains.flows(address, chain, top, direction, asset=asset)
15
+
16
+
17
+ class _Ctx:
18
+ """Holds constant tracing context so it isn't threaded through every arg."""
19
+ def __init__(self, chain, branching, asset, symbol, price, direction="out"):
20
+ self.chain = chain
21
+ self.branching = branching
22
+ self.asset = asset
23
+ self.symbol = symbol
24
+ self.price = price
25
+ self.direction = direction
26
+
27
+
28
+ def _edge_text(ctx, to, val, cnt):
29
+ usd = prices.fmt_usd(prices.usd(val, ctx.price))
30
+ money = f"{val:.4f} {ctx.symbol}"
31
+ tail = f" ≈{usd}" if ctx.price is not None else ""
32
+ arrow = "──▶ " if ctx.direction == "out" else "◀── "
33
+ return Text.assemble(Text(f"──{money} ({cnt} tx){tail}{arrow}"), render.addr_label(to))
34
+
35
+
36
+ def _record_finding(findings, ctx, to, val, cnt):
37
+ if findings is None:
38
+ return
39
+ hit = labels.lookup(to)
40
+ if not hit:
41
+ return
42
+ findings.append({"address": to, "type": hit["type"], "label": hit["name"],
43
+ "risk": labels.risk_score(to), "value_reached": round(val, 6),
44
+ "symbol": ctx.symbol, "usd_reached": prices.usd(val, ctx.price),
45
+ "tx_count": cnt})
46
+
47
+
48
+ def _expand(node, address, ctx, depth, seen, findings=None, is_root=False):
49
+ if depth <= 0:
50
+ return
51
+ if address.lower() in seen:
52
+ node.add(Text("↳ already visited (cycle)", style="dim")); return
53
+ seen.add(address.lower())
54
+ if not is_root and labels.type_of(address) in ("exchange", "mixer", "sanctioned"):
55
+ node.add(Text("↳ trail ends here (identifiable entity — subpoena / off-chain)", style="dim"))
56
+ return
57
+ if not is_root and labels.type_of(address) == "bridge":
58
+ node.add(Text("↳ bridge — funds leave this chain; run `crypttrace crosschain` "
59
+ "on the sender to find the destination chain", style="cyan"))
60
+ return
61
+ # Off-ramp heuristic: an unknown wallet that forwards most funds to an
62
+ # exchange is a deposit address — the cash-out / KYC point. Native only.
63
+ if not is_root and ctx.asset is None and labels.type_of(address) == "unknown":
64
+ off = offramp.detect(address, ctx.chain)
65
+ if off:
66
+ pct = int(off["fraction"] * 100)
67
+ node.add(Text(f"↳ off-ramp: ~{pct}% forwarded to {off['exchange']} "
68
+ f"— likely a deposit address (KYC point)", style="green"))
69
+ if findings is not None:
70
+ findings.append({"address": address, "type": "offramp",
71
+ "label": f"{off['exchange']} deposit (off-ramp)",
72
+ "risk": 30, "value_reached": round(off["forwarded"], 6),
73
+ "symbol": ctx.symbol,
74
+ "usd_reached": prices.usd(off["forwarded"], ctx.price),
75
+ "tx_count": 0})
76
+ return
77
+ for to, val, cnt in _outflows(address, ctx.chain, ctx.branching, ctx.asset, ctx.direction):
78
+ _record_finding(findings, ctx, to, val, cnt)
79
+ child = node.add(_edge_text(ctx, to, val, cnt))
80
+ _expand(child, to, ctx, depth - 1, seen, findings)
81
+
82
+
83
+ def build(address, chain, depth, branching, asset=None, direction="out"):
84
+ """Return (tree, findings). direction 'out' follows funds forward, 'in' traces their source."""
85
+ if asset is None:
86
+ from crypttrace import chains as _chains
87
+ symbol = _chains.symbol(chain)
88
+ price = prices.native_price(chain)
89
+ else:
90
+ symbol = asset["symbol"]
91
+ price = prices.token_price(asset["contract"], chain, symbol)
92
+ ctx = _Ctx(chain, branching, asset, symbol, price, direction)
93
+
94
+ root = Tree(render.addr_label(address))
95
+ findings = []
96
+ _expand(root, address, ctx, depth, set(), findings, is_root=True)
97
+ return root, findings
98
+
99
+
100
+ def build_tree(address, chain, depth, branching, asset=None, direction="out"):
101
+ tree, _ = build(address, chain, depth, branching, asset, direction)
102
+ return tree
103
+
104
+
105
+ def build_graph(address, chain, depth, branching, asset=None, direction="out"):
106
+ """Return {nodes, edges, symbol} for graph visualization (web UI).
107
+
108
+ direction 'out' follows where funds went; 'in' traces where they came from.
109
+ """
110
+ if asset is None:
111
+ from crypttrace import chains as _chains
112
+ symbol = _chains.symbol(chain)
113
+ price = prices.native_price(chain)
114
+ else:
115
+ symbol = asset["symbol"]
116
+ price = prices.token_price(asset["contract"], chain, symbol)
117
+
118
+ # Bitcoin / Tron / Solana addresses are case-sensitive (base58); only EVM
119
+ # addresses may be normalized to lowercase.
120
+ from crypttrace import chains as _c
121
+ _norm = (lambda a: a.lower()) if _c.is_evm(chain) else (lambda a: a)
122
+
123
+ nodes = {}
124
+ edges = []
125
+
126
+ def _node(addr, hop, is_root=False):
127
+ key = _norm(addr)
128
+ if key not in nodes:
129
+ nodes[key] = {
130
+ "id": key,
131
+ "short": addr[:10] + "…" + addr[-6:],
132
+ "type": labels.type_of(addr),
133
+ "label": labels.label_of(addr),
134
+ "root": is_root,
135
+ "terminal": False,
136
+ # hop distance from the investigated address — the renderer uses
137
+ # this as the layout level, which keeps the graph compact and
138
+ # ordered instead of letting the library invent deep levels.
139
+ "level": hop,
140
+ }
141
+ else:
142
+ nodes[key]["level"] = min(nodes[key]["level"], hop)
143
+ return nodes[key]
144
+
145
+ def _walk(addr, d, seen, is_root, hop=0):
146
+ _node(addr, hop, is_root)
147
+ key = _norm(addr)
148
+ if d <= 0 or key in seen:
149
+ return
150
+ seen.add(key)
151
+ if not is_root and labels.type_of(addr) in ("exchange", "mixer", "sanctioned", "bridge"):
152
+ nodes[key]["terminal"] = True
153
+ return
154
+ for to, val, cnt in _outflows(addr, chain, branching, asset, direction):
155
+ _node(to, hop + 1)
156
+ # arrows always point the way the money actually travelled
157
+ src, dst = (key, _norm(to)) if direction == "out" else (_norm(to), key)
158
+ edges.append({"from": src, "to": dst,
159
+ "value": round(val, 4), "tx": cnt,
160
+ "usd": prices.usd(val, price)})
161
+ _walk(to, d - 1, seen, False, hop + 1)
162
+
163
+ _walk(address, depth, set(), True, 0)
164
+ return {"nodes": list(nodes.values()), "edges": edges,
165
+ "symbol": symbol, "direction": direction}