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/webapp.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Local web UI for crypttrace.
|
|
2
|
+
|
|
3
|
+
A small Flask app that wraps the same modules the CLI uses and serves a
|
|
4
|
+
single-page frontend (an interactive fund-flow graph + address profile). Runs
|
|
5
|
+
entirely on the user's machine — no data leaves except the Etherscan/CoinGecko
|
|
6
|
+
calls the CLI already makes.
|
|
7
|
+
|
|
8
|
+
Launch with: crypttrace serve (then open http://127.0.0.1:8000)
|
|
9
|
+
"""
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from flask import Flask, jsonify, request, send_from_directory
|
|
14
|
+
|
|
15
|
+
from crypttrace.fetchers import etherscan
|
|
16
|
+
from crypttrace.labels import labels
|
|
17
|
+
from crypttrace import trace as trace_mod, funder as funder_mod, offramp as offramp_mod
|
|
18
|
+
from crypttrace import assets, prices, config, chains
|
|
19
|
+
|
|
20
|
+
_WEB_DIR = Path(__file__).parent / "web"
|
|
21
|
+
|
|
22
|
+
# Errors that mean "bad input / upstream said no", not "the app crashed".
|
|
23
|
+
_KNOWN_ERRORS = (chains.ChainError, etherscan.EtherscanError, ValueError)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate(address: str, chain: str):
|
|
27
|
+
"""Return a helpful message if the address obviously doesn't fit the chain."""
|
|
28
|
+
a = (address or "").strip()
|
|
29
|
+
if not a:
|
|
30
|
+
return "Enter an address."
|
|
31
|
+
if len(a) == 64 and all(c in "0123456789abcdefABCDEF" for c in a):
|
|
32
|
+
return ("That looks like a transaction ID, not an address. "
|
|
33
|
+
"Paste the wallet address instead.")
|
|
34
|
+
if chains.is_evm(chain):
|
|
35
|
+
if not (a.startswith("0x") and len(a) == 42):
|
|
36
|
+
return f"'{a[:14]}…' is not an {chain} address (expected 0x…, 42 chars)."
|
|
37
|
+
elif chain == "btc":
|
|
38
|
+
if not (a.startswith(("1", "3", "bc1", "tb1")) and 25 <= len(a) <= 62):
|
|
39
|
+
return f"'{a[:14]}…' is not a Bitcoin address (expected 1…, 3… or bc1…)."
|
|
40
|
+
elif chain == "tron":
|
|
41
|
+
if not (a.startswith("T") and len(a) == 34):
|
|
42
|
+
return f"'{a[:14]}…' is not a Tron address (expected T…, 34 chars)."
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_app() -> Flask:
|
|
47
|
+
app = Flask(__name__, static_folder=None)
|
|
48
|
+
|
|
49
|
+
# Always answer the API in JSON — an HTML error page would break the frontend.
|
|
50
|
+
@app.errorhandler(Exception)
|
|
51
|
+
def _json_errors(e):
|
|
52
|
+
code = getattr(e, "code", 500)
|
|
53
|
+
return jsonify({"error": getattr(e, "description", None) or str(e)}), code
|
|
54
|
+
|
|
55
|
+
@app.after_request
|
|
56
|
+
def _no_cache(resp):
|
|
57
|
+
# The UI is a single file that changes with every release; a cached copy
|
|
58
|
+
# means users keep seeing an old interface after upgrading.
|
|
59
|
+
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
|
60
|
+
resp.headers["Pragma"] = "no-cache"
|
|
61
|
+
resp.headers["Expires"] = "0"
|
|
62
|
+
return resp
|
|
63
|
+
|
|
64
|
+
@app.route("/")
|
|
65
|
+
def index():
|
|
66
|
+
resp = send_from_directory(_WEB_DIR, "index.html")
|
|
67
|
+
resp.headers["Cache-Control"] = "no-store"
|
|
68
|
+
return resp
|
|
69
|
+
|
|
70
|
+
@app.route("/api/assets")
|
|
71
|
+
def api_assets():
|
|
72
|
+
"""Which assets can be traced on a given chain (drives the UI dropdown)."""
|
|
73
|
+
chain = request.args.get("chain", "eth")
|
|
74
|
+
native = chains.symbol(chain)
|
|
75
|
+
opts = [{"value": "native", "label": f"{native} (native)"}]
|
|
76
|
+
for sym, meta in assets.tokens_for(chain).items():
|
|
77
|
+
opts.append({"value": sym, "label": meta["symbol"]})
|
|
78
|
+
return jsonify({"chain": chain, "assets": opts,
|
|
79
|
+
"tokens_supported": chain != "btc"})
|
|
80
|
+
|
|
81
|
+
@app.route("/api/label")
|
|
82
|
+
def api_label():
|
|
83
|
+
addr = request.args.get("address", "")
|
|
84
|
+
hit = labels.lookup(addr)
|
|
85
|
+
return jsonify({
|
|
86
|
+
"address": addr,
|
|
87
|
+
"label": hit["name"] if hit else None,
|
|
88
|
+
"type": labels.type_of(addr),
|
|
89
|
+
"risk": labels.risk_score(addr),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
@app.route("/api/profile")
|
|
93
|
+
def api_profile():
|
|
94
|
+
addr = request.args.get("address", "")
|
|
95
|
+
chain = request.args.get("chain", "eth")
|
|
96
|
+
bad = validate(addr, chain)
|
|
97
|
+
if bad:
|
|
98
|
+
return jsonify({"error": bad}), 400
|
|
99
|
+
try:
|
|
100
|
+
bal = chains.balance(addr, chain)
|
|
101
|
+
rows = chains.transfers(addr, chain, limit=1000)
|
|
102
|
+
except _KNOWN_ERRORS as e:
|
|
103
|
+
return jsonify({"error": str(e)}), 400
|
|
104
|
+
price = prices.native_price(chain)
|
|
105
|
+
hit = labels.lookup(addr)
|
|
106
|
+
return jsonify({
|
|
107
|
+
"address": addr, "chain": chain,
|
|
108
|
+
"balance": round(bal, 8),
|
|
109
|
+
"balance_usd": prices.usd(bal, price),
|
|
110
|
+
"symbol": chains.symbol(chain),
|
|
111
|
+
"txs": len(rows),
|
|
112
|
+
"first_seen": rows[-1]["timestamp"] if rows else None,
|
|
113
|
+
"last_seen": rows[0]["timestamp"] if rows else None,
|
|
114
|
+
"label": hit["name"] if hit else None,
|
|
115
|
+
"type": labels.type_of(addr),
|
|
116
|
+
"risk": labels.risk_score(addr),
|
|
117
|
+
"explorer": chains.explorer_url(addr, chain),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
@app.route("/api/trace")
|
|
121
|
+
def api_trace():
|
|
122
|
+
addr = request.args.get("address", "")
|
|
123
|
+
chain = request.args.get("chain", "eth")
|
|
124
|
+
depth = int(request.args.get("depth", 3))
|
|
125
|
+
branching = int(request.args.get("branching", 3))
|
|
126
|
+
asset_arg = request.args.get("asset", "eth")
|
|
127
|
+
direction = request.args.get("direction", "out")
|
|
128
|
+
if direction not in ("out", "in"):
|
|
129
|
+
direction = "out"
|
|
130
|
+
bad = validate(addr, chain)
|
|
131
|
+
if bad:
|
|
132
|
+
return jsonify({"error": bad}), 400
|
|
133
|
+
try:
|
|
134
|
+
asset = assets.resolve_asset(asset_arg, chain)
|
|
135
|
+
graph = trace_mod.build_graph(addr, chain, depth, branching, asset, direction)
|
|
136
|
+
except _KNOWN_ERRORS as e:
|
|
137
|
+
return jsonify({"error": str(e)}), 400
|
|
138
|
+
return jsonify(graph)
|
|
139
|
+
|
|
140
|
+
@app.route("/api/assess")
|
|
141
|
+
def api_assess():
|
|
142
|
+
"""The stated conclusion and the evidence it rests on."""
|
|
143
|
+
from crypttrace import assess as assess_mod
|
|
144
|
+
addr = request.args.get("address", "")
|
|
145
|
+
chain = request.args.get("chain", "eth")
|
|
146
|
+
asset_arg = request.args.get("asset", "native")
|
|
147
|
+
bad = validate(addr, chain)
|
|
148
|
+
if bad:
|
|
149
|
+
return jsonify({"error": bad}), 400
|
|
150
|
+
try:
|
|
151
|
+
asset = assets.resolve_asset(asset_arg, chain)
|
|
152
|
+
a = assess_mod.assess(addr, chain, asset)
|
|
153
|
+
except _KNOWN_ERRORS as e:
|
|
154
|
+
return jsonify({"error": str(e)}), 400
|
|
155
|
+
return jsonify(a)
|
|
156
|
+
|
|
157
|
+
@app.route("/api/evidence")
|
|
158
|
+
def api_evidence():
|
|
159
|
+
"""Why does the tool claim this address is what it says it is?"""
|
|
160
|
+
from crypttrace.labels import audit as audit_mod
|
|
161
|
+
addr = request.args.get("address", "")
|
|
162
|
+
return jsonify(audit_mod.evidence(addr))
|
|
163
|
+
|
|
164
|
+
@app.route("/api/verify")
|
|
165
|
+
def api_verify():
|
|
166
|
+
"""Cross-check the tool's totals against the chain's own figures."""
|
|
167
|
+
from crypttrace import verify as verify_mod
|
|
168
|
+
addr = request.args.get("address", "")
|
|
169
|
+
chain = request.args.get("chain", "eth")
|
|
170
|
+
asset_arg = request.args.get("asset", "native")
|
|
171
|
+
bad = validate(addr, chain)
|
|
172
|
+
if bad:
|
|
173
|
+
return jsonify({"error": bad}), 400
|
|
174
|
+
try:
|
|
175
|
+
asset = assets.resolve_asset(asset_arg, chain)
|
|
176
|
+
v = verify_mod.reconcile(addr, chain, asset)
|
|
177
|
+
except _KNOWN_ERRORS as e:
|
|
178
|
+
return jsonify({"error": str(e)}), 400
|
|
179
|
+
v["headline"] = verify_mod.headline(v)
|
|
180
|
+
v["symbol"] = asset["symbol"] if asset else chains.symbol(chain)
|
|
181
|
+
return jsonify(v)
|
|
182
|
+
|
|
183
|
+
@app.route("/api/victims")
|
|
184
|
+
def api_victims():
|
|
185
|
+
"""Addresses that fed this wallet — in a mass theft, the victim list."""
|
|
186
|
+
from crypttrace import analysis
|
|
187
|
+
addr = request.args.get("address", "")
|
|
188
|
+
chain = request.args.get("chain", "eth")
|
|
189
|
+
depth = int(request.args.get("depth", 1))
|
|
190
|
+
asset_arg = request.args.get("asset", "native")
|
|
191
|
+
include_dust = request.args.get("dust") == "1"
|
|
192
|
+
bad = validate(addr, chain)
|
|
193
|
+
if bad:
|
|
194
|
+
return jsonify({"error": bad}), 400
|
|
195
|
+
try:
|
|
196
|
+
asset = assets.resolve_asset(asset_arg, chain)
|
|
197
|
+
rows = analysis.collect_sources(addr, chain, depth, asset,
|
|
198
|
+
min_value=0.0 if include_dust else None)
|
|
199
|
+
except _KNOWN_ERRORS as e:
|
|
200
|
+
return jsonify({"error": str(e)}), 400
|
|
201
|
+
sym = asset["symbol"] if asset else chains.symbol(chain)
|
|
202
|
+
return jsonify({
|
|
203
|
+
"symbol": sym,
|
|
204
|
+
"total": round(sum(r["value"] for r in rows), 8),
|
|
205
|
+
"count": len(rows),
|
|
206
|
+
"rows": [{
|
|
207
|
+
"address": r["address"], "hop": r.get("hop", 1),
|
|
208
|
+
"value": round(r["value"], 8), "txs": r["txs"],
|
|
209
|
+
"first_seen": r.get("first_ts"), "last_seen": r.get("last_ts"),
|
|
210
|
+
"label": r.get("label", ""), "type": r.get("type", "unknown"),
|
|
211
|
+
"into": r.get("into", ""),
|
|
212
|
+
"explorer": chains.explorer_url(r["address"], chain),
|
|
213
|
+
} for r in rows],
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
@app.route("/api/timeline")
|
|
217
|
+
def api_timeline():
|
|
218
|
+
"""When the money moved, plus a plain-language read on the timing."""
|
|
219
|
+
from crypttrace import analysis
|
|
220
|
+
addr = request.args.get("address", "")
|
|
221
|
+
chain = request.args.get("chain", "eth")
|
|
222
|
+
asset_arg = request.args.get("asset", "native")
|
|
223
|
+
buckets = int(request.args.get("buckets", 24))
|
|
224
|
+
include_dust = request.args.get("dust") == "1"
|
|
225
|
+
bad = validate(addr, chain)
|
|
226
|
+
if bad:
|
|
227
|
+
return jsonify({"error": bad}), 400
|
|
228
|
+
try:
|
|
229
|
+
asset = assets.resolve_asset(asset_arg, chain)
|
|
230
|
+
tl = analysis.timeline(addr, chain, asset, buckets=buckets,
|
|
231
|
+
min_value=0.0 if include_dust else None)
|
|
232
|
+
except _KNOWN_ERRORS as e:
|
|
233
|
+
return jsonify({"error": str(e)}), 400
|
|
234
|
+
burst = tl.get("burst")
|
|
235
|
+
return jsonify({
|
|
236
|
+
"symbol": asset["symbol"] if asset else chains.symbol(chain),
|
|
237
|
+
"events": tl["events"], "buckets": tl["buckets"],
|
|
238
|
+
"first_ts": tl["first_ts"], "last_ts": tl["last_ts"],
|
|
239
|
+
"in_total": tl["in_total"], "out_total": tl["out_total"],
|
|
240
|
+
"dust_skipped": tl.get("dust_skipped", 0),
|
|
241
|
+
"burst": ({"span_seconds": burst[0], "count": burst[1],
|
|
242
|
+
"start": burst[2], "end": burst[3]} if burst else None),
|
|
243
|
+
"verdict": analysis.describe_burst(burst, tl["events"]),
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
@app.route("/api/funder")
|
|
247
|
+
def api_funder():
|
|
248
|
+
addr = request.args.get("address", "")
|
|
249
|
+
chain = request.args.get("chain", "eth")
|
|
250
|
+
if validate(addr, chain):
|
|
251
|
+
return jsonify({"hops": []})
|
|
252
|
+
try:
|
|
253
|
+
hops = funder_mod.funding_chain(addr, chain, 6)
|
|
254
|
+
except _KNOWN_ERRORS as e:
|
|
255
|
+
return jsonify({"error": str(e)}), 400
|
|
256
|
+
return jsonify({"hops": hops})
|
|
257
|
+
|
|
258
|
+
@app.route("/api/offramp")
|
|
259
|
+
def api_offramp():
|
|
260
|
+
addr = request.args.get("address", "")
|
|
261
|
+
chain = request.args.get("chain", "eth")
|
|
262
|
+
# off-ramp heuristic relies on exchange labels, which are EVM-only today
|
|
263
|
+
if not chains.is_evm(chain) or validate(addr, chain):
|
|
264
|
+
return jsonify({"offramp": None})
|
|
265
|
+
try:
|
|
266
|
+
hit = offramp_mod.detect(addr, chain)
|
|
267
|
+
except _KNOWN_ERRORS as e:
|
|
268
|
+
return jsonify({"error": str(e)}), 400
|
|
269
|
+
return jsonify({"offramp": hit})
|
|
270
|
+
|
|
271
|
+
return app
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def where() -> dict:
|
|
275
|
+
"""Which files this process is actually running — the answer to 'my edits
|
|
276
|
+
aren't showing up', which usually means a stale non-editable install is
|
|
277
|
+
shadowing the source tree."""
|
|
278
|
+
import crypttrace
|
|
279
|
+
index = _WEB_DIR / "index.html"
|
|
280
|
+
return {
|
|
281
|
+
"package": str(Path(crypttrace.__file__).parent),
|
|
282
|
+
"index_html": str(index),
|
|
283
|
+
"exists": index.exists(),
|
|
284
|
+
"size": index.stat().st_size if index.exists() else 0,
|
|
285
|
+
"modified": (datetime.fromtimestamp(index.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
286
|
+
if index.exists() else "-"),
|
|
287
|
+
"editable": "site-packages" not in str(_WEB_DIR),
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def serve(host: str = "127.0.0.1", port: int = 8000, debug: bool = False) -> None:
|
|
292
|
+
create_app().run(host=host, port=port, debug=debug)
|