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