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