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,660 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>crypttrace — on-chain investigation</title>
7
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.9/dist/vis-network.min.js"></script>
8
+ <style>
9
+ :root{
10
+ --bg:#080b12; --glass:rgba(22,27,34,.55); --glass-2:rgba(38,45,58,.45);
11
+ --stroke:rgba(255,255,255,.09); --stroke-2:rgba(255,255,255,.16);
12
+ --text:#e8eef7; --dim:#93a1b5;
13
+ --green:#3fdf87; --purple:#c48cff; --red:#ff6b60; --cyan:#3fd8e8; --accent:#4d9dff;
14
+ }
15
+ *{box-sizing:border-box}
16
+ html{height:100%;overflow:hidden}
17
+ html,body{height:100%}
18
+ /* flex column: every row takes its natural height, the stage gets the rest.
19
+ A fixed calc() height broke as soon as a new row (the tabs) was added. */
20
+ body{
21
+ margin:0;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
22
+ color:var(--text);background:var(--bg);overflow:hidden;
23
+ height:100vh;display:flex;flex-direction:column;
24
+ }
25
+ header,.bar,.tabs,.legend{flex:0 0 auto}
26
+ /* animated aurora backdrop — the light that the glass refracts */
27
+ .aurora{position:fixed;inset:0;z-index:0;overflow:hidden}
28
+ .aurora i{position:absolute;border-radius:50%;filter:blur(90px);opacity:.5;animation:drift 22s ease-in-out infinite}
29
+ .aurora i:nth-child(1){width:520px;height:520px;background:#1e4bd8;top:-120px;left:-80px}
30
+ .aurora i:nth-child(2){width:460px;height:460px;background:#7b2ff7;bottom:-140px;right:10%;animation-delay:-7s}
31
+ .aurora i:nth-child(3){width:380px;height:380px;background:#0e8f8f;top:35%;right:-100px;animation-delay:-14s}
32
+ @keyframes drift{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(60px,40px) scale(1.12)}66%{transform:translate(-40px,20px) scale(.94)}}
33
+
34
+ .glass{
35
+ background:var(--glass);backdrop-filter:blur(22px) saturate(160%);
36
+ -webkit-backdrop-filter:blur(22px) saturate(160%);
37
+ border:1px solid var(--stroke);
38
+ box-shadow:0 8px 32px rgba(0,0,0,.38), inset 0 1px 0 rgba(255,255,255,.10);
39
+ }
40
+
41
+ header{position:relative;z-index:2;display:flex;align-items:center;gap:12px;padding:14px 22px;
42
+ border-bottom:1px solid var(--stroke);background:rgba(10,14,22,.5);backdrop-filter:blur(18px)}
43
+ header h1{margin:0;font-size:18px;font-weight:700;letter-spacing:-.02em}
44
+ header .tag{color:var(--dim);font-size:13px}
45
+
46
+ .bar{position:relative;z-index:2;display:flex;gap:10px;flex-wrap:wrap;align-items:center;padding:14px 22px}
47
+ input,select,button{
48
+ color:var(--text);font-size:14px;border-radius:12px;padding:10px 13px;
49
+ background:var(--glass-2);border:1px solid var(--stroke);
50
+ backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);
51
+ transition:all .25s cubic-bezier(.22,1,.36,1);outline:none;
52
+ }
53
+ input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 4px rgba(77,157,255,.16);transform:translateY(-1px)}
54
+ input#addr{flex:1;min-width:340px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.2px}
55
+ select{cursor:pointer}
56
+ select option{background:#161b22;color:var(--text)}
57
+ button{cursor:pointer;font-weight:650;
58
+ background:linear-gradient(135deg,rgba(77,157,255,.92),rgba(123,47,247,.85));
59
+ border:1px solid rgba(255,255,255,.22);
60
+ box-shadow:0 6px 20px rgba(77,157,255,.30), inset 0 1px 0 rgba(255,255,255,.35)}
61
+ button:hover{transform:translateY(-2px);box-shadow:0 10px 28px rgba(77,157,255,.42), inset 0 1px 0 rgba(255,255,255,.45)}
62
+ button:active{transform:translateY(0) scale(.98)}
63
+ button.ghost{background:var(--glass-2);box-shadow:none;font-weight:500;border:1px solid var(--stroke)}
64
+ button.ghost:hover{border-color:var(--stroke-2);box-shadow:0 6px 18px rgba(0,0,0,.3)}
65
+
66
+ /* Tabs live inside the graph column, never in the header stack — that way they
67
+ cannot collide with the search row regardless of window size. */
68
+ .main{flex:1 1 auto;display:flex;flex-direction:column;min-width:0;min-height:0}
69
+ .tabs{flex:0 0 auto;display:flex;gap:6px;margin-bottom:10px}
70
+ .tab{font-size:13px;font-weight:600;padding:8px 15px;border-radius:12px;cursor:pointer;
71
+ background:var(--glass-2);border:1px solid var(--stroke);color:var(--dim);
72
+ backdrop-filter:blur(12px);transition:.25s cubic-bezier(.22,1,.36,1)}
73
+ .tab:hover{color:var(--text);transform:translateY(-1px)}
74
+ .tab.on{color:#fff;border-color:rgba(255,255,255,.28);
75
+ background:linear-gradient(135deg,rgba(77,157,255,.9),rgba(123,47,247,.8));
76
+ box-shadow:0 6px 18px rgba(77,157,255,.28)}
77
+ .view{position:absolute;inset:0;overflow:auto;padding:18px 20px;display:none;animation:rise .4s both}
78
+ .view.on{display:block}
79
+ #graph.on{display:block}
80
+ .stat{display:flex;gap:22px;flex-wrap:wrap;margin-bottom:16px;font-size:13px;color:var(--dim)}
81
+ .stat b{color:var(--text);font-size:15px;display:block}
82
+ .verdict{padding:13px 16px;border-radius:14px;margin-bottom:16px;font-size:13.5px;line-height:1.6;
83
+ background:rgba(255,193,86,.09);border:1px solid rgba(255,193,86,.30);color:#ffd591}
84
+ .verdict.calm{background:var(--glass-2);border-color:var(--stroke);color:var(--dim)}
85
+ /* verification badge — says whether the numbers reconcile with the chain */
86
+ .vfy{display:inline-flex;align-items:center;gap:7px;padding:6px 12px;border-radius:999px;
87
+ font-size:12px;font-weight:650;border:1px solid currentColor;backdrop-filter:blur(10px)}
88
+ .vfy-verified{color:var(--green);background:rgba(63,223,135,.10)}
89
+ .vfy-consistent{color:var(--cyan);background:rgba(63,216,232,.10)}
90
+ .vfy-partial{color:#ffc857;background:rgba(255,200,87,.10)}
91
+ .vfy-mismatch{color:var(--red);background:rgba(255,107,96,.12)}
92
+ .vfy-unchecked,.vfy-unavailable{color:var(--dim);background:rgba(147,161,181,.10)}
93
+ .vfy-note{font-size:12px;color:var(--dim);line-height:1.55;margin-top:9px}
94
+
95
+ /* assessment: a stated conclusion and what it rests on */
96
+ .concl{padding:18px 20px;border-radius:16px;margin-bottom:18px;font-size:14.5px;line-height:1.65;
97
+ border:1px solid currentColor;backdrop-filter:blur(14px)}
98
+ .concl-high{color:var(--red);background:rgba(255,107,96,.09)}
99
+ .concl-med{color:#ffc857;background:rgba(255,200,87,.08)}
100
+ .concl-low{color:var(--green);background:rgba(63,223,135,.07)}
101
+ .concl b{color:var(--text)}
102
+ .sig{padding:13px 15px;border-radius:14px;margin-bottom:10px;
103
+ background:var(--glass-2);border:1px solid var(--stroke);transition:.25s}
104
+ .sig:hover{border-color:var(--stroke-2);transform:translateY(-1px)}
105
+ .sig-head{display:flex;align-items:center;gap:9px;margin-bottom:7px}
106
+ .sig-name{font-weight:650;font-size:13.5px}
107
+ .sig-conf{font-size:11px;font-weight:700;padding:2px 9px;border-radius:999px;
108
+ border:1px solid currentColor}
109
+ .conf-high{color:var(--green)} .conf-medium{color:#ffc857} .conf-low{color:var(--dim)}
110
+ .sig-line{font-size:12.5px;color:var(--dim);line-height:1.55}
111
+ .sig-line b{color:var(--text);font-weight:600}
112
+ .meters{display:flex;gap:20px;margin:6px 0 18px;font-size:12.5px;color:var(--dim)}
113
+ .meters b{display:block;color:var(--text);font-size:16px;margin-top:2px}
114
+ .caveat{padding:12px 15px;border-radius:13px;margin-top:16px;font-size:12.5px;line-height:1.6;
115
+ color:#ffd591;background:rgba(255,193,86,.08);border:1px solid rgba(255,193,86,.28)}
116
+ .vfy-rows{margin-top:10px;font-size:12px}
117
+ .vfy-rows div{display:flex;justify-content:space-between;gap:10px;padding:3px 0}
118
+ table.grid{width:100%;border-collapse:collapse;font-size:12.5px}
119
+ table.grid th{text-align:left;color:var(--dim);font-weight:600;padding:7px 9px;
120
+ border-bottom:1px solid var(--stroke);position:sticky;top:0;background:rgba(12,16,24,.95);
121
+ backdrop-filter:blur(8px)}
122
+ table.grid td{padding:7px 9px;border-bottom:1px solid rgba(255,255,255,.045)}
123
+ table.grid tr:hover td{background:rgba(255,255,255,.035)}
124
+ table.grid td.num{text-align:right;font-variant-numeric:tabular-nums}
125
+ .bars{display:flex;flex-direction:column;gap:5px}
126
+ .barrow{display:grid;grid-template-columns:132px 46px 1fr;align-items:center;gap:10px;font-size:12px}
127
+ .barrow .t{color:var(--dim)}
128
+ .barrow .n{text-align:right;color:var(--text);font-variant-numeric:tabular-nums}
129
+ /* histogram column — must NOT be called .bar, that class is the search row */
130
+ .tlbar{height:16px;border-radius:5px;background:linear-gradient(90deg,var(--accent),var(--purple));
131
+ box-shadow:0 0 12px rgba(77,157,255,.35);min-width:3px;transition:width .5s cubic-bezier(.22,1,.36,1)}
132
+
133
+ .legend{position:relative;z-index:2;display:flex;gap:8px;flex-wrap:wrap;align-items:center;padding:0 22px 12px}
134
+ .chip{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dim);
135
+ padding:5px 11px;border-radius:999px;background:var(--glass-2);border:1px solid var(--stroke);
136
+ backdrop-filter:blur(12px);transition:.25s}
137
+ .chip:hover{color:var(--text);border-color:var(--stroke-2);transform:translateY(-1px)}
138
+ .dot{width:9px;height:9px;border-radius:50%;box-shadow:0 0 10px currentColor}
139
+
140
+ .wrap{position:relative;z-index:1;display:flex;gap:16px;padding:0 22px 18px;
141
+ flex:1 1 auto;min-height:0}
142
+ .stage{flex:1 1 auto;min-height:0;position:relative;border-radius:20px;overflow:hidden;
143
+ background:rgba(12,16,24,.42);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
144
+ border:1px solid var(--stroke);box-shadow:0 12px 40px rgba(0,0,0,.42), inset 0 1px 0 rgba(255,255,255,.08);
145
+ animation:rise .6s cubic-bezier(.22,1,.36,1) both}
146
+ #graph{position:absolute;inset:0;opacity:0;transition:opacity .7s ease}
147
+ #graph.on{opacity:1}
148
+ .hint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
149
+ color:var(--dim);font-size:14px;text-align:center;padding:30px;pointer-events:none;transition:.4s}
150
+ .hint.hide{opacity:0}
151
+ .loader{position:absolute;top:0;left:0;height:2px;width:0;background:linear-gradient(90deg,var(--accent),var(--purple));
152
+ box-shadow:0 0 12px var(--accent);transition:width .4s ease;border-radius:2px}
153
+
154
+ /* floating zoom controls over the graph */
155
+ .zoomctl{position:absolute;right:14px;bottom:14px;display:flex;flex-direction:column;gap:6px;z-index:3}
156
+ .zoomctl button{width:38px;height:38px;padding:0;font-size:16px;font-weight:600;border-radius:12px;
157
+ background:var(--glass);border:1px solid var(--stroke);box-shadow:0 4px 16px rgba(0,0,0,.35);
158
+ backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);display:flex;align-items:center;justify-content:center}
159
+ .zoomctl button:hover{background:var(--glass-2);border-color:var(--stroke-2);transform:translateY(-2px)}
160
+ .scaleview{position:absolute;left:14px;bottom:14px;z-index:3;font-size:11.5px;color:var(--dim);
161
+ padding:6px 11px;border-radius:999px;background:var(--glass);border:1px solid var(--stroke);
162
+ backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px)}
163
+
164
+ .side{width:352px;overflow-y:auto;border-radius:20px;padding:16px;
165
+ background:var(--glass);backdrop-filter:blur(22px) saturate(160%);-webkit-backdrop-filter:blur(22px) saturate(160%);
166
+ border:1px solid var(--stroke);box-shadow:0 12px 40px rgba(0,0,0,.42), inset 0 1px 0 rgba(255,255,255,.10);
167
+ animation:rise .6s .08s cubic-bezier(.22,1,.36,1) both}
168
+ .side::-webkit-scrollbar{width:8px}
169
+ .side::-webkit-scrollbar-thumb{background:rgba(255,255,255,.12);border-radius:10px}
170
+ @keyframes rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
171
+
172
+ .card{border-radius:16px;padding:14px 15px;margin-bottom:13px;
173
+ background:var(--glass-2);border:1px solid var(--stroke);
174
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.08);
175
+ animation:rise .45s cubic-bezier(.22,1,.36,1) both;transition:.3s}
176
+ .card:hover{border-color:var(--stroke-2);transform:translateY(-2px);box-shadow:0 10px 26px rgba(0,0,0,.28)}
177
+ .card:nth-child(2){animation-delay:.05s} .card:nth-child(3){animation-delay:.1s}
178
+ .card:nth-child(4){animation-delay:.15s} .card:nth-child(5){animation-delay:.2s}
179
+ .card h3{margin:0 0 11px;font-size:11px;text-transform:uppercase;letter-spacing:.09em;color:var(--dim);font-weight:700}
180
+ .kv{display:flex;justify-content:space-between;gap:10px;padding:5px 0;font-size:13px;
181
+ border-bottom:1px solid rgba(255,255,255,.045)}
182
+ .kv:last-child{border-bottom:0}
183
+ .kv .k{color:var(--dim)}
184
+ .kv .v{text-align:right}
185
+ .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px}
186
+ .pill{display:inline-block;padding:3px 10px;border-radius:999px;font-size:11.5px;font-weight:700;
187
+ border:1px solid currentColor;backdrop-filter:blur(8px)}
188
+ .t-exchange{color:var(--green);background:rgba(63,223,135,.12)}
189
+ .t-mixer{color:var(--purple);background:rgba(196,140,255,.12)}
190
+ .t-sanctioned,.t-scam{color:var(--red);background:rgba(255,107,96,.12)}
191
+ .t-bridge{color:var(--cyan);background:rgba(63,216,232,.12)}
192
+ .t-unknown,.t-offramp{color:var(--dim);background:rgba(147,161,181,.12)}
193
+ .muted{color:var(--dim);font-size:13px;line-height:1.55}
194
+ .err{color:var(--red);font-size:13px}
195
+ .alert{border-color:rgba(255,107,96,.4);background:rgba(255,107,96,.08)}
196
+ a.btnlink{display:inline-block;margin-top:9px;font-size:12.5px;color:var(--accent);text-decoration:none;
197
+ padding:7px 12px;border-radius:10px;background:rgba(77,157,255,.10);border:1px solid rgba(77,157,255,.28);transition:.25s}
198
+ a.btnlink:hover{background:rgba(77,157,255,.2);transform:translateY(-1px)}
199
+ .status{margin-left:auto;font-size:12px;color:var(--dim)}
200
+ .spin{display:inline-block;width:11px;height:11px;margin-right:6px;vertical-align:-1px;
201
+ border:2px solid rgba(255,255,255,.2);border-top-color:var(--accent);border-radius:50%;animation:sp .7s linear infinite}
202
+ @keyframes sp{to{transform:rotate(360deg)}}
203
+ </style>
204
+ </head>
205
+ <body>
206
+ <div class="aurora"><i></i><i></i><i></i></div>
207
+
208
+ <header>
209
+ <h1>🔎 crypttrace</h1>
210
+ <span class="tag">trace stolen crypto — on-chain investigation</span>
211
+ </header>
212
+
213
+ <div class="bar">
214
+ <input id="addr" placeholder="0x… paste an address to investigate" spellcheck="false">
215
+ <select id="chain" title="Chain">
216
+ <option value="eth">eth</option><option value="bsc">bsc</option>
217
+ <option value="polygon">polygon</option><option value="arbitrum">arbitrum</option>
218
+ <option value="optimism">optimism</option><option value="base">base</option>
219
+ <option value="btc">btc</option><option value="tron">tron</option><option value="sol">sol</option>
220
+ </select>
221
+ <select id="asset" title="Asset to trace">
222
+ <option value="native">ETH (native)</option>
223
+ </select>
224
+ <select id="direction" title="Which way to follow the money">
225
+ <option value="out">→ where funds went</option>
226
+ <option value="in">← where funds came from</option>
227
+ </select>
228
+ <select id="depth" title="Depth"><option>2</option><option selected>3</option><option>4</option><option>5</option></select>
229
+ <button id="go">Investigate</button>
230
+ <button id="fit" class="ghost" title="Fit graph to view">Fit</button>
231
+ </div>
232
+
233
+ <div class="legend">
234
+ <span class="chip"><span class="dot" style="color:var(--green);background:var(--green)"></span>exchange</span>
235
+ <span class="chip"><span class="dot" style="color:var(--purple);background:var(--purple)"></span>mixer</span>
236
+ <span class="chip"><span class="dot" style="color:var(--red);background:var(--red)"></span>sanctioned / scam</span>
237
+ <span class="chip"><span class="dot" style="color:var(--cyan);background:var(--cyan)"></span>bridge</span>
238
+ <span class="chip"><span class="dot" style="color:var(--dim);background:var(--dim)"></span>unknown</span>
239
+ <span class="status" id="status"></span>
240
+ </div>
241
+
242
+ <div class="wrap">
243
+ <div class="main">
244
+ <div class="tabs">
245
+ <button class="tab on" data-view="graph">Flow graph</button>
246
+ <button class="tab" data-view="assess">Assessment</button>
247
+ <button class="tab" data-view="timeline">Timeline</button>
248
+ <button class="tab" data-view="victims">Who sent funds here</button>
249
+ </div>
250
+ <div class="stage">
251
+ <div class="loader" id="loader"></div>
252
+ <div id="graph"></div>
253
+ <div class="hint" id="hint">Paste an address above and hit <b style="color:var(--text);margin:0 4px">Investigate</b><br>
254
+ — flow runs left → right. Click a node to inspect it, click a transfer line to open it on the explorer.</div>
255
+ <div class="view" id="v-assess"></div>
256
+ <div class="view" id="v-timeline"></div>
257
+ <div class="view" id="v-victims"></div>
258
+ <div class="scaleview" id="scaleview" style="display:none">100%</div>
259
+ <div class="zoomctl" id="zoomctl">
260
+ <button id="zin" title="Zoom in">+</button>
261
+ <button id="zout" title="Zoom out">−</button>
262
+ <button id="zroot" title="Centre on the investigated address">⌖</button>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ <div class="side" id="side">
267
+ <div class="card"><h3>How it works</h3>
268
+ <div class="muted">The graph maps where funds moved, hop by hop. Nodes are coloured by what
269
+ they are — exchanges are cash-out points, mixers break the trail. Click any node for its
270
+ profile, first-funder chain and off-ramp check.</div>
271
+ </div>
272
+ </div>
273
+ </div>
274
+
275
+ <script>
276
+ const COLORS={exchange:'#3fdf87',mixer:'#c48cff',sanctioned:'#ff6b60',scam:'#ff6b60',bridge:'#3fd8e8',unknown:'#93a1b5'};
277
+ const EXPLORER={eth:'https://etherscan.io',bsc:'https://bscscan.com',polygon:'https://polygonscan.com',
278
+ arbitrum:'https://arbiscan.io',optimism:'https://optimistic.etherscan.io',base:'https://basescan.org'};
279
+ const NON_EVM={btc:a=>`https://mempool.space/address/${a}`,
280
+ tron:a=>`https://tronscan.org/#/address/${a}`,
281
+ sol:a=>`https://solscan.io/account/${a}`};
282
+ const $=s=>document.querySelector(s);
283
+ let network=null, lastGraph=null, rootId=null;
284
+ const MIN_SCALE=0.42; // below this, nodes/labels become unreadable dots
285
+
286
+ function showScale(){
287
+ if(!network) return;
288
+ const v=$('#scaleview');
289
+ v.style.display='block';
290
+ v.textContent=Math.round(network.getScale()*100)+'%';
291
+ }
292
+ // Fit the whole graph, but never zoom out past readability — if the trace is
293
+ // too wide, stay at MIN_SCALE and centre on the investigated address instead.
294
+ function fitNice(){
295
+ if(!network) return;
296
+ // Measure first (no animation), so the decision is made on the real scale.
297
+ network.fit();
298
+ const s=network.getScale();
299
+ if(s<MIN_SCALE){
300
+ const opts={scale:MIN_SCALE,animation:{duration:500,easingFunction:'easeInOutQuad'}};
301
+ if(rootId){ const p=network.getPositions([rootId])[rootId]; if(p) opts.position=p; }
302
+ network.moveTo(opts);
303
+ setTimeout(showScale,520);
304
+ }else{
305
+ showScale();
306
+ }
307
+ }
308
+ function zoomBy(f){
309
+ if(!network) return;
310
+ network.moveTo({scale:Math.min(3,Math.max(0.15,network.getScale()*f)),
311
+ animation:{duration:220,easingFunction:'easeInOutQuad'}});
312
+ setTimeout(showScale,240);
313
+ }
314
+
315
+ const chain=()=>$('#chain').value;
316
+ const explorer=()=>EXPLORER[chain()]||EXPLORER.eth;
317
+ const addrUrl=a=>NON_EVM[chain()]?NON_EVM[chain()](a):`${explorer()}/address/${a}`;
318
+ // EVM: advanced filter shows all transfers A→B. Non-EVM: fall back to the recipient's page.
319
+ const edgeUrl=(f,t)=>NON_EVM[chain()]?NON_EVM[chain()](t):`${explorer()}/advanced-filter?fadd=${f}&tadd=${t}`;
320
+
321
+ function setStatus(t,err,busy){
322
+ const s=$('#status');
323
+ s.innerHTML=(busy?'<span class="spin"></span>':'')+esc(t);
324
+ s.style.color=err?'var(--red)':'var(--dim)';
325
+ }
326
+ function loader(p){ $('#loader').style.width=p+'%'; if(p>=100) setTimeout(()=>$('#loader').style.width='0',500); }
327
+
328
+ async function api(path){
329
+ const r=await fetch(path);
330
+ const raw=await r.text();
331
+ let j;
332
+ try{ j=JSON.parse(raw); }
333
+ catch{ throw new Error(raw.slice(0,140).replace(/<[^>]*>/g,'').trim()||('HTTP '+r.status)); }
334
+ if(!r.ok||j.error) throw new Error(j.error||('HTTP '+r.status));
335
+ return j;
336
+ }
337
+ const fmtUsd=v=>v==null?'—':(Math.abs(v)>=1e6?'$'+(v/1e6).toFixed(2)+'M':Math.abs(v)>=1e3?'$'+(v/1e3).toFixed(1)+'K':'$'+v.toFixed(2));
338
+ const fmtTs=t=>t?new Date(t*1000).toISOString().slice(0,16).replace('T',' '):'—';
339
+ const short=a=>a.slice(0,10)+'…'+a.slice(-6);
340
+ // Everything from the backend is data: token symbols and names come straight
341
+ // from the chain, where anyone can deploy a token called whatever they like.
342
+ const esc=s=>String(s??'').replace(/[<>&"']/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[c]));
343
+ const safeUrl=u=>/^https?:\/\//i.test(String(u??''))?esc(u):'#';
344
+ const q=encodeURIComponent;
345
+
346
+ function renderGraph(g){
347
+ lastGraph=g;
348
+ const nodes=g.nodes.map(n=>{
349
+ const c=COLORS[n.type]||COLORS.unknown;
350
+ return {
351
+ id:n.id,
352
+ label:(n.label?n.label+'\n':'')+n.short,
353
+ title:`${n.label||n.type}\n${n.id}\n(click to inspect · double-click opens explorer)`,
354
+ shape:'dot', size:n.root?26:(n.terminal?18:14),
355
+ color:{background:c,border:n.root?'#ffffff':c,
356
+ highlight:{background:c,border:'#ffffff'},hover:{background:c,border:'#ffffff'}},
357
+ borderWidth:n.root?4:2,
358
+ shadow:{enabled:true,color:c+'88',size:n.root?26:14,x:0,y:0},
359
+ level:n.level, // our own hop distance — keeps the layout compact
360
+ font:{color:'#e8eef7',size:12,face:'-apple-system, Segoe UI, sans-serif',
361
+ strokeWidth:4,strokeColor:'rgba(8,11,18,.85)',multi:false}
362
+ };
363
+ });
364
+ const edges=g.edges.map(e=>({
365
+ from:e.from,to:e.to,arrows:{to:{enabled:true,scaleFactor:.7}},
366
+ label:`${e.value} ${g.symbol}${e.usd!=null?' ≈'+fmtUsd(e.usd):''}`,
367
+ title:`${e.value} ${g.symbol} in ${e.tx} tx — click to open on explorer`,
368
+ font:{color:'#93a1b5',size:10.5,strokeWidth:4,strokeColor:'rgba(8,11,18,.85)',align:'horizontal'},
369
+ color:{color:'rgba(255,255,255,.18)',highlight:'#4d9dff',hover:'#4d9dff'},
370
+ width:1.2, hoverWidth:2.4, selectionWidth:2.4,
371
+ smooth:{type:'cubicBezier',forceDirection:'horizontal',roundness:.5}
372
+ }));
373
+
374
+ const data={nodes:new vis.DataSet(nodes),edges:new vis.DataSet(edges)};
375
+ // tighten spacing as the graph grows, so wide traces stay readable
376
+ const n=g.nodes.length;
377
+ const lvl = n>40?200 : n>20?240 : 300;
378
+ const gap = n>40?70 : n>20?90 : 130;
379
+ const opts={
380
+ // Ordered flow, using the hop levels we computed server-side.
381
+ // 'out' → investigated address on the left; 'in' → sources on the left.
382
+ layout:{hierarchical:{enabled:true,direction:(g.direction==='in'?'RL':'LR'),
383
+ levelSeparation:lvl,nodeSpacing:gap,treeSpacing:gap+40,
384
+ blockShifting:true,edgeMinimization:true,parentCentralization:true}},
385
+ physics:false,
386
+ interaction:{hover:true,tooltipDelay:120,navigationButtons:false,zoomView:true,dragView:true},
387
+ nodes:{borderWidthSelected:4},
388
+ edges:{selectionWidth:2.4}
389
+ };
390
+ const el=$('#graph');
391
+ el.classList.remove('on');
392
+ network=new vis.Network(el,data,opts);
393
+ rootId=(g.nodes.find(x=>x.root)||g.nodes[0]||{}).id;
394
+ network.on('zoom',showScale);
395
+ network.once('afterDrawing',()=>{ el.classList.add('on'); fitNice(); });
396
+
397
+ network.on('click',p=>{
398
+ if(p.nodes.length){ inspect(p.nodes[0]); return; }
399
+ if(p.edges.length){ // click a transfer line → open on explorer
400
+ const e=data.edges.get(p.edges[0]);
401
+ window.open(edgeUrl(e.from,e.to),'_blank','noopener');
402
+ }
403
+ });
404
+ network.on('doubleClick',p=>{ if(p.nodes.length) window.open(addrUrl(p.nodes[0]),'_blank','noopener'); });
405
+ }
406
+
407
+ const card=(t,r,cls='')=>`<div class="card ${cls}"><h3>${t}</h3>${r}</div>`;
408
+ const kv=(k,v)=>`<div class="kv"><span class="k">${k}</span><span class="v">${v}</span></div>`;
409
+ const pill=(t,l)=>`<span class="pill t-${esc(t)}">${esc(l||t)}</span>`;
410
+
411
+ async function inspect(addr){
412
+ const c=chain();
413
+ $('#side').innerHTML=`<div class="card"><div class="muted"><span class="spin"></span>Loading ${esc(short(addr))}…</div></div>`;
414
+ try{
415
+ const [p,f,o]=await Promise.all([
416
+ api(`/api/profile?address=${q(addr)}&chain=${c}`),
417
+ api(`/api/funder?address=${q(addr)}&chain=${c}`).catch(()=>({hops:[]})),
418
+ api(`/api/offramp?address=${q(addr)}&chain=${c}`).catch(()=>({offramp:null}))
419
+ ]);
420
+ // A label is a claim about who controls an address — show what backs it.
421
+ let ev={known:false};
422
+ try{ ev=await api(`/api/evidence?address=${q(addr)}`); }catch{}
423
+ const basis = ev.known
424
+ ? (ev.source
425
+ ? `<div class="vfy-note"><b>basis:</b> ${esc(ev.source)}</div>`
426
+ : `<div class="vfy-note" style="color:#ffc857">No source recorded for this label —
427
+ it is inherited, not evidenced. Verify before acting on it.</div>`)
428
+ : '';
429
+
430
+ let h='';
431
+ h+=card('Address',
432
+ kv('address',`<span class="mono">${esc(short(addr))}</span>`)+
433
+ (p.label?kv('label',pill(p.type,p.label)):kv('type',pill(p.type)))+
434
+ kv('risk',`${p.risk||0}/100`)+
435
+ basis+
436
+ `<a class="btnlink" href="${safeUrl(addrUrl(addr))}" target="_blank" rel="noopener">Open on explorer ↗</a>`);
437
+ h+=card('Profile',
438
+ kv('balance',`${esc(p.balance)} ${esc(p.symbol||'')} ≈ ${fmtUsd(p.balance_usd)}`)+
439
+ kv('transfers',p.txs)+
440
+ kv('first seen',fmtTs(p.first_seen))+
441
+ kv('last seen',fmtTs(p.last_seen)));
442
+ if(o.offramp){
443
+ h+=card('⚠ Off-ramp detected',
444
+ `<div class="muted">~${Math.round(o.offramp.fraction*100)}% of outgoing funds forwarded to
445
+ <b style="color:var(--green)">${esc(o.offramp.exchange)}</b> — likely a deposit address, i.e. a
446
+ <b>KYC identification point</b>.</div>`,'alert');
447
+ }
448
+ if(f.hops&&f.hops.length){
449
+ h+=card('First-funder chain',
450
+ f.hops.map(x=>kv('◀ funded by',
451
+ `<span class="mono">${esc(short(x.funder))}</span> · ${x.value.toFixed(3)} ${x.terminal?pill(x.funder_type,x.funder_label||x.funder_type):''}`)).join(''));
452
+ }
453
+ $('#side').innerHTML=h;
454
+ }catch(e){ $('#side').innerHTML=card('Error',`<div class="err">${esc(e.message)}</div>`); }
455
+ }
456
+
457
+ async function run(){
458
+ const a=$('#addr').value.trim();
459
+ if(!a){ setStatus('enter an address',true); return; }
460
+ setStatus('tracing the chain…',false,true); loader(35);
461
+ $('#hint').classList.add('hide');
462
+ loaded={}; // new address -> other views must reload
463
+ $('#v-timeline').innerHTML=''; $('#v-victims').innerHTML=''; $('#v-assess').innerHTML='';
464
+ try{
465
+ const dir=$('#direction').value;
466
+ const g=await api(`/api/trace?address=${q(a)}&chain=${chain()}&depth=${$('#depth').value}&asset=${q($('#asset').value)}&direction=${dir}`);
467
+ loader(80); renderGraph(g);
468
+ setStatus(`${g.nodes.length} addresses · ${g.edges.length} transfers`);
469
+ // BTC/Tron/Solana addresses are case-sensitive — never lowercase them
470
+ inspect(NON_EVM[chain()] ? a : a.toLowerCase()); loader(100);
471
+ if(!g.edges.length){ // explain an empty graph instead of showing nothing
472
+ const other=dir==='out'?'← where funds came from':'→ where funds went';
473
+ $('#hint').innerHTML=dir==='out'
474
+ ? `This address has no <b>outgoing</b> transfers — it only ever received funds.<br>
475
+ Switch the direction to <b>${other}</b> to trace where its money came from.`
476
+ : `This address has no <b>incoming</b> transfers on record.<br>
477
+ Try <b>${other}</b> instead.`;
478
+ $('#hint').classList.remove('hide');
479
+ }
480
+ }catch(e){ setStatus(e.message,true); loader(100); $('#hint').classList.remove('hide'); }
481
+ }
482
+
483
+ // The asset list depends on the chain (USDT on Tron ≠ USDT on Ethereum),
484
+ // so ask the backend which assets it can actually trace here.
485
+ async function syncAssets(){
486
+ const sel=$('#asset'), c=chain();
487
+ $('#addr').placeholder = c==='btc' ? 'bc1… / 1… / 3… Bitcoin address'
488
+ : c==='tron' ? 'T… Tron address'
489
+ : c==='sol' ? 'Solana account address'
490
+ : '0x… paste an address to investigate';
491
+ try{
492
+ const r=await api(`/api/assets?chain=${c}`);
493
+ sel.innerHTML=r.assets.map(o=>`<option value="${esc(o.value)}">${esc(o.label)}</option>`).join('');
494
+ sel.disabled=r.assets.length<2;
495
+ sel.style.opacity=sel.disabled?.55:1;
496
+ sel.title=sel.disabled?'No tokens on this chain':'Asset to trace';
497
+ }catch{ /* keep whatever is there if the backend is unreachable */ }
498
+ }
499
+ $('#chain').addEventListener('change',syncAssets); syncAssets();
500
+
501
+ // ---------- views: flow graph / timeline / victims ----------
502
+ let currentView='graph', loaded={};
503
+
504
+ function showView(v){
505
+ currentView=v;
506
+ document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('on',t.dataset.view===v));
507
+ $('#graph').style.display = v==='graph'?'block':'none';
508
+ $('#zoomctl').style.display = v==='graph'?'flex':'none';
509
+ $('#scaleview').style.display = (v==='graph'&&network)?'block':'none';
510
+ $('#v-assess').classList.toggle('on',v==='assess');
511
+ $('#v-timeline').classList.toggle('on',v==='timeline');
512
+ $('#v-victims').classList.toggle('on',v==='victims');
513
+ const a=$('#addr').value.trim();
514
+ if(a && !loaded[v+a]) {
515
+ loaded[v+a]=true;
516
+ if(v==='timeline') loadTimeline(a);
517
+ if(v==='victims') loadVictims(a);
518
+ if(v==='assess') loadAssessment(a);
519
+ }
520
+ }
521
+ document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>showView(t.dataset.view));
522
+
523
+ const dstamp=t=>t?new Date(t*1000).toISOString().slice(0,16).replace('T',' '):'—';
524
+ async function loadTimeline(a){
525
+ const el=$('#v-timeline');
526
+ el.innerHTML='<div class="muted"><span class="spin"></span>Reading transfer history…</div>';
527
+ try{
528
+ const t=await api(`/api/timeline?address=${q(a)}&chain=${chain()}&asset=${q($('#asset').value)}&buckets=24`);
529
+ if(!t.events){ el.innerHTML='<div class="muted">No dated transfers found.</div>'; return; }
530
+ const peak=Math.max(...t.buckets.map(b=>b.count),1);
531
+ const bars=t.buckets.filter(b=>b.count).map(b=>`
532
+ <div class="barrow"><span class="t">${dstamp(b.start)}</span><span class="n">${b.count}</span>
533
+ <div class="tlbar" style="width:${Math.max(3,b.count/peak*100)}%"></div></div>`).join('');
534
+ const calm=!(t.verdict||'').includes('automated');
535
+ el.innerHTML=`
536
+ <div class="stat">
537
+ <div>transfers<b>${t.events}</b></div>
538
+ <div>received<b>${t.in_total.toFixed(6)} ${esc(t.symbol)}</b></div>
539
+ <div>sent<b>${t.out_total.toFixed(6)} ${esc(t.symbol)}</b></div>
540
+ <div>first<b>${dstamp(t.first_ts)}</b></div>
541
+ <div>last<b>${dstamp(t.last_ts)}</b></div>
542
+ </div>
543
+ ${t.verdict?`<div class="verdict ${calm?'calm':''}">${esc(t.verdict)}</div>`:''}
544
+ <div class="bars">${bars}</div>
545
+ ${t.dust_skipped?`<div class="muted" style="margin-top:14px">${t.dust_skipped} dust-sized transfers ignored (spam sent to well-known addresses).</div>`:''}`;
546
+ }catch(e){ el.innerHTML=`<div class="err">${esc(e.message)}</div>`; }
547
+ }
548
+
549
+ // The assessment: what the evidence supports, how strongly, and on what basis.
550
+ // This is the part that separates a conclusion from a data dump, so every
551
+ // signal shows its observation and its meaning rather than just a score.
552
+ async function loadAssessment(a){
553
+ const el=$('#v-assess');
554
+ el.innerHTML='<div class="muted"><span class="spin"></span>Weighing the evidence…</div>';
555
+ try{
556
+ const r=await api(`/api/assess?address=${q(a)}&chain=${chain()}&asset=${q($('#asset').value)}`);
557
+ const band = r.risk>=60?'high' : r.risk>=25?'med' : 'low';
558
+ const sigs=(r.signals||[]).map(s=>`
559
+ <div class="sig">
560
+ <div class="sig-head">
561
+ <span class="sig-name">${esc(s.name)}</span>
562
+ <span class="sig-conf conf-${esc(s.confidence)}">${esc(s.confidence)} confidence</span>
563
+ </div>
564
+ <div class="sig-line"><b>observed</b> — ${esc(s.observed)}</div>
565
+ <div class="sig-line"><b>means</b> — ${esc(s.implication)}</div>
566
+ </div>`).join('') ||
567
+ '<div class="muted">No distinguishing signals. That is not a clearance — an address with unremarkable flows is simply unknown.</div>';
568
+
569
+ el.innerHTML=`
570
+ <div class="concl concl-${band}">${esc(r.assessment)}</div>
571
+ <div class="meters">
572
+ <div>risk<b>${r.risk}/100</b></div>
573
+ <div>confidence<b>${esc(r.confidence)}</b></div>
574
+ <div>arithmetic<b>${esc(r.verification?.status||'—')}</b></div>
575
+ <div>received<b>${(r.totals?.received??0).toFixed(8)}</b></div>
576
+ <div>sent<b>${(r.totals?.sent??0).toFixed(8)}</b></div>
577
+ </div>
578
+ <h3 style="font-size:11px;letter-spacing:.09em;color:var(--dim);margin:0 0 11px">WHAT THIS RESTS ON</h3>
579
+ ${sigs}
580
+ ${(r.caveats||[]).length?`<div class="caveat"><b>Read this before quoting the above.</b><br>
581
+ ${r.caveats.map(c=>esc(c)).join('<br>')}</div>`:''}`;
582
+ }catch(e){ el.innerHTML=`<div class="err">${esc(e.message)}</div>`; }
583
+ }
584
+
585
+ let victimRows=[], victimSym='';
586
+ async function loadVictims(a){
587
+ const el=$('#v-victims');
588
+ el.innerHTML='<div class="muted"><span class="spin"></span>Walking the money backwards…</div>';
589
+ try{
590
+ const v=await api(`/api/victims?address=${q(a)}&chain=${chain()}&asset=${q($('#asset').value)}&depth=1`);
591
+ victimRows=v.rows; victimSym=v.symbol;
592
+ if(!v.count){ el.innerHTML='<div class="muted">Nothing fed this address.</div>'; return; }
593
+ verifyInto(el, a); // is this list trustworthy?
594
+ const rows=v.rows.slice(0,300).map(r=>`
595
+ <tr><td class="mono">${esc(r.address.slice(0,18))}…${esc(r.address.slice(-6))}
596
+ ${r.label?`<span class="pill t-${esc(r.type)}" style="margin-left:6px">${esc(r.label)}</span>`:''}</td>
597
+ <td class="num">${r.hop}</td><td class="num">${r.value}</td><td class="num">${r.txs}</td>
598
+ <td>${dstamp(r.first_seen)}</td>
599
+ <td><a class="btnlink" style="padding:3px 9px;margin:0" href="${safeUrl(r.explorer)}" target="_blank" rel="noopener">open ↗</a></td></tr>`).join('');
600
+ el.innerHTML=`
601
+ <div class="stat">
602
+ <div>addresses<b>${v.count}</b></div>
603
+ <div>total sent in<b>${esc(v.total)} ${esc(v.symbol)}</b></div>
604
+ <div>&nbsp;<button class="ghost" id="csv" style="padding:7px 13px">Download CSV</button></div>
605
+ </div>
606
+ <table class="grid"><thead><tr>
607
+ <th>Address</th><th style="text-align:right">Hop</th>
608
+ <th style="text-align:right">Sent (${esc(v.symbol)})</th><th style="text-align:right">Txs</th>
609
+ <th>First seen (UTC)</th><th></th></tr></thead><tbody>${rows}</tbody></table>
610
+ ${v.count>300?`<div class="muted" style="margin-top:12px">Showing the first 300 of ${v.count}; the CSV contains all.</div>`:''}`;
611
+ $('#csv').onclick=downloadCsv;
612
+ }catch(e){ el.innerHTML=`<div class="err">${esc(e.message)}</div>`; }
613
+ }
614
+
615
+ // Ask the backend whether our own totals reconcile with the chain, and show it
616
+ // next to any figure a user might quote. A silent miscount is the worst failure
617
+ // mode for a tool like this.
618
+ const VFY_ICON={verified:'✓',consistent:'≈',partial:'◐',mismatch:'✗',unchecked:'–',unavailable:'–'};
619
+ async function verifyInto(container, addr){
620
+ const slot=document.createElement('div');
621
+ slot.style.margin='0 0 14px';
622
+ slot.innerHTML='<span class="vfy vfy-unchecked">checking…</span>';
623
+ container.prepend(slot);
624
+ try{
625
+ const v=await api(`/api/verify?address=${q(addr)}&chain=${chain()}&asset=${q($('#asset').value)}`);
626
+ const rows=(v.checks||[]).map(c=>`<div><span>${esc(c.name)}</span>
627
+ <span class="mono">${c.computed.toFixed(8)} vs ${c.chain.toFixed(8)} ${esc(v.symbol)} ${c.ok?'✓':'✗'}</span></div>`).join('');
628
+ slot.innerHTML=`
629
+ <span class="vfy vfy-${esc(v.status)}">${VFY_ICON[v.status]||''} ${esc(v.status)}</span>
630
+ <div class="vfy-note">${esc(v.headline)}</div>
631
+ ${rows?`<div class="vfy-rows">${rows}</div>`:''}
632
+ ${(v.notes||[]).slice(0,2).map(n=>`<div class="vfy-note">• ${esc(n)}</div>`).join('')}`;
633
+ }catch(e){ slot.innerHTML=`<span class="vfy vfy-unavailable">verification unavailable</span>`; }
634
+ }
635
+
636
+ function downloadCsv(){
637
+ const head=['address','hop',`amount_sent_${victimSym}`,'transactions','first_seen_utc','last_seen_utc','label','explorer'];
638
+ const body=victimRows.map(r=>[r.address,r.hop,r.value,r.txs,dstamp(r.first_seen),dstamp(r.last_seen),
639
+ (r.label||'').replace(/,/g,';'),r.explorer].join(','));
640
+ const blob=new Blob([[head.join(','),...body].join('\n')],{type:'text/csv;charset=utf-8'});
641
+ const u=URL.createObjectURL(blob), l=document.createElement('a');
642
+ l.href=u; l.download=`sources_${chain()}_${$('#addr').value.trim().slice(0,12)}.csv`;
643
+ l.click(); URL.revokeObjectURL(u);
644
+ }
645
+
646
+ $('#go').onclick=run;
647
+ $('#fit').onclick=()=>network&&fitNice();
648
+ $('#zin').onclick=()=>zoomBy(1.35);
649
+ $('#zout').onclick=()=>zoomBy(1/1.35);
650
+ $('#zroot').onclick=()=>{
651
+ if(!network||!rootId) return;
652
+ network.moveTo({position:network.getPositions([rootId])[rootId],
653
+ scale:Math.max(network.getScale(),0.8),
654
+ animation:{duration:500,easingFunction:'easeInOutQuad'}});
655
+ setTimeout(showScale,520);
656
+ };
657
+ $('#addr').addEventListener('keydown',e=>{ if(e.key==='Enter') run(); });
658
+ </script>
659
+ </body>
660
+ </html>