txo_parser 0.0.2 → 0.0.3

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,693 @@
1
+ import { html, render, keyed, onUnmount } from 'https://losos.org/losos/html.js'
2
+ import {
3
+ decodeKey, privkeyToAddress, isHexKey,
4
+ buildTransaction, estimateVsize,
5
+ fetchUtxos, checkOutspend, fetchTxDetails, broadcastTx, getFeeRate,
6
+ bytesToHex, hexToU8,
7
+ parseTxoUri, isValidTxoUri,
8
+ toSats, buildTxoUri, parseVoucherFromItem
9
+ } from '../lib/bitcoin.js'
10
+
11
+ export default {
12
+ label: 'Vouchers',
13
+ icon: '\uD83C\uDFAB',
14
+
15
+ canHandle(subject, store) {
16
+ var node = store.get(subject.value)
17
+ if (!node) return false
18
+ var type = store.type(node)
19
+ return type && type.includes('VoucherPool')
20
+ },
21
+
22
+ render(subject, lionStore, container, rawData) {
23
+
24
+ // ── Load state ──────────────────────────────────────
25
+ var DATA_URL = new URL('voucher-data.jsonld', location.href).href
26
+ var data = rawData || {}
27
+ var items = data['schema:itemListElement'] || []
28
+ var vouchers = items.map(parseVoucherFromItem)
29
+
30
+ var importing = false
31
+ var refreshing = false
32
+ var mergeMode = false
33
+ var mergeSelected = new Set()
34
+ var splitTarget = null
35
+ var addKeyTarget = null
36
+ var revealedKeys = new Set()
37
+
38
+ // ── Persistence ─────────────────────────────────────
39
+
40
+ function saveToStorage() {
41
+ var jsonLd = {
42
+ '@context': { 'schema': 'https://schema.org/', 'bt': 'https://blocktrails.org/ns/' },
43
+ '@id': '#this',
44
+ '@type': 'VoucherPool',
45
+ 'schema:name': 'Voucher Pool',
46
+ 'schema:description': 'Testnet4 voucher management and faucet',
47
+ 'schema:hasPart': [
48
+ { '@id': 'blocktrail.jsonld#this' },
49
+ { '@id': 'webledger.jsonld#this' },
50
+ { '@id': 'ledger-history.jsonld#this' }
51
+ ],
52
+ 'schema:itemListElement': vouchers.map(function(v) {
53
+ return {
54
+ '@type': 'schema:ListItem',
55
+ '@id': v.id,
56
+ 'schema:identifier': buildTxoUri(v),
57
+ 'schema:address': v.address,
58
+ 'schema:status': v.status,
59
+ 'schema:dateCreated': v.dateAdded
60
+ }
61
+ })
62
+ }
63
+ // PUT to Solid server
64
+ fetch(DATA_URL, {
65
+ method: 'PUT',
66
+ headers: { 'Content-Type': 'application/ld+json' },
67
+ body: JSON.stringify(jsonLd, null, 2)
68
+ }).catch(function(e) { console.warn('PUT failed:', e) })
69
+ }
70
+
71
+ // ── Helpers ─────────────────────────────────────────
72
+
73
+ function truncate(s, start, end) {
74
+ start = start || 8; end = end || 6
75
+ if (!s || s.length <= start + end + 3) return s
76
+ return s.slice(0, start) + '\u2026' + s.slice(-end)
77
+ }
78
+
79
+ function maskKey(wif) {
80
+ if (!wif || wif.length < 6) return '***'
81
+ return wif.slice(0, 3) + '\u2026' + wif.slice(-3)
82
+ }
83
+
84
+ function satsBtc(sats) { return (sats / 1e8).toFixed(8) }
85
+
86
+ function toast(msg) {
87
+ var t = document.createElement('div')
88
+ t.className = 'v-toast'
89
+ t.textContent = msg
90
+ document.body.appendChild(t)
91
+ setTimeout(function() { t.remove() }, 2000)
92
+ }
93
+
94
+ function copyText(text) {
95
+ navigator.clipboard.writeText(text).then(function() { toast('Copied') })
96
+ }
97
+
98
+ // ── Import ──────────────────────────────────────────
99
+
100
+ async function importKey(input) {
101
+ input = input.trim()
102
+ if (!input) return
103
+
104
+ if (input.startsWith('txo:') || isValidTxoUri(input)) {
105
+ try {
106
+ var parsed = parseTxoUri(input)
107
+ var exists = vouchers.some(function(v) { return v.txid === parsed.txid && v.vout === parsed.output })
108
+ if (exists) { toast('Already in pool'); return }
109
+ var v = {
110
+ id: Date.now().toString(36),
111
+ txid: parsed.txid, vout: parsed.output,
112
+ amount: toSats(parsed.amount),
113
+ privkey: parsed.privkey || parsed.key || '',
114
+ address: '', network: 'btc',
115
+ status: 'unknown',
116
+ dateAdded: new Date().toISOString()
117
+ }
118
+ if (v.privkey) {
119
+ try {
120
+ var decoded = await decodeKey(v.privkey)
121
+ v.address = privkeyToAddress(decoded.privkey, decoded.testnet)
122
+ } catch {}
123
+ }
124
+ vouchers.unshift(v)
125
+ saveToStorage()
126
+ toast('Voucher imported')
127
+ renderApp()
128
+ refreshStatus(vouchers.indexOf(v))
129
+ return
130
+ } catch (e) {
131
+ console.warn('TXO parse failed, trying as WIF:', e)
132
+ }
133
+ }
134
+
135
+ try {
136
+ var decoded = await decodeKey(input)
137
+ var address = privkeyToAddress(decoded.privkey, decoded.testnet)
138
+ var keyless = vouchers.filter(function(v) { return !v.privkey })
139
+ var matched = false
140
+
141
+ if (keyless.length > 0) {
142
+ var utxos = await fetchUtxos(address)
143
+ for (var kv of keyless) {
144
+ var matchesUtxo = utxos.some(function(u) { return u.txid === kv.txid && u.vout === kv.vout })
145
+ if (matchesUtxo) {
146
+ kv.privkey = input
147
+ kv.address = address
148
+ kv.status = 'unspent'
149
+ var u = utxos.find(function(u) { return u.txid === kv.txid && u.vout === kv.vout })
150
+ if (u && u.value) kv.amount = u.value
151
+ matched = true
152
+ }
153
+ }
154
+ if (matched) {
155
+ saveToStorage()
156
+ toast('Key matched to voucher')
157
+ renderApp()
158
+ return
159
+ }
160
+ }
161
+
162
+ toast('Looking up UTXOs\u2026')
163
+ var utxos = await fetchUtxos(address)
164
+ if (utxos.length === 0) { toast('No UTXOs found'); return }
165
+
166
+ var added = 0
167
+ for (var ux of utxos) {
168
+ var exists = vouchers.some(function(v) { return v.txid === ux.txid && v.vout === ux.vout })
169
+ if (exists) continue
170
+ vouchers.unshift({
171
+ id: Date.now().toString(36) + added,
172
+ txid: ux.txid, vout: ux.vout,
173
+ amount: ux.value,
174
+ privkey: input, address: address,
175
+ network: 'btc',
176
+ status: ux.status && ux.status.confirmed ? 'unspent' : 'unconfirmed',
177
+ dateAdded: new Date().toISOString()
178
+ })
179
+ added++
180
+ }
181
+
182
+ if (added === 0) toast('All UTXOs already in pool')
183
+ else { saveToStorage(); toast('Imported ' + added + ' voucher' + (added > 1 ? 's' : '')) }
184
+ renderApp()
185
+ } catch (e) {
186
+ toast('Import failed: ' + e.message)
187
+ console.error(e)
188
+ }
189
+ }
190
+
191
+ // ── Refresh status ──────────────────────────────────
192
+
193
+ async function refreshStatus(index) {
194
+ if (index !== undefined) {
195
+ var v = vouchers[index]
196
+ if (!v) return
197
+ var result = await checkOutspend(v.txid, v.vout)
198
+ if (result) {
199
+ var newStatus = result.spent ? 'spent' : 'unspent'
200
+ if (v.status !== newStatus) {
201
+ v.status = newStatus
202
+ saveToStorage()
203
+ }
204
+ renderApp()
205
+ }
206
+ return
207
+ }
208
+
209
+ refreshing = true
210
+ renderApp()
211
+ var changed = false
212
+ for (var i = 0; i < vouchers.length; i++) {
213
+ try {
214
+ var result = await checkOutspend(vouchers[i].txid, vouchers[i].vout)
215
+ if (result) {
216
+ var newStatus = result.spent ? 'spent' : 'unspent'
217
+ if (vouchers[i].status !== newStatus) {
218
+ vouchers[i].status = newStatus
219
+ changed = true
220
+ }
221
+ }
222
+ } catch {}
223
+ }
224
+ if (changed) saveToStorage()
225
+ refreshing = false
226
+ renderApp()
227
+ }
228
+
229
+ // ── Split ───────────────────────────────────────────
230
+
231
+ async function confirmSplit(voucherId) {
232
+ var v = vouchers.find(function(x) { return x.id === voucherId })
233
+ if (!v || !v.privkey || v.status !== 'unspent') {
234
+ toast('Voucher must be unspent with a key'); return
235
+ }
236
+ var countEl = container.querySelector('.v-split-input')
237
+ var numSplits = parseInt(countEl && countEl.value) || 2
238
+ if (numSplits < 2 || numSplits > 100) { toast('Split into 2\u2013100 outputs'); return }
239
+
240
+ var decoded = await decodeKey(v.privkey)
241
+ var txDetails = await fetchTxDetails(v.txid)
242
+ var prevOut = txDetails.vout[v.vout]
243
+ if (!prevOut) { toast('Could not find output'); return }
244
+ var scriptPubKey = hexToU8(prevOut.scriptpubkey)
245
+
246
+ var feeRate = await getFeeRate()
247
+ var vsize = estimateVsize(1, numSplits)
248
+ var fee = Math.ceil(vsize * feeRate)
249
+ var available = v.amount - fee
250
+ if (available <= 0) { toast('Not enough for fee'); return }
251
+
252
+ var perOutput = Math.floor(available / numSplits)
253
+ if (perOutput <= 546) { toast('Split amounts too small (dust)'); return }
254
+
255
+ if (!confirm('Split ' + v.amount.toLocaleString() + ' sats into ' + numSplits + ' outputs of ~' + perOutput.toLocaleString() + ' sats?\nFee: ' + fee + ' sats (' + feeRate + ' sat/vB)')) return
256
+
257
+ toast('Building transaction\u2026')
258
+ var outputs = []
259
+ var remaining = available
260
+ for (var i = 0; i < numSplits; i++) {
261
+ var amt = i === numSplits - 1 ? remaining : perOutput
262
+ outputs.push({ amount: amt, scriptPubKey: scriptPubKey })
263
+ remaining -= amt
264
+ }
265
+
266
+ var rawTx = await buildTransaction(
267
+ [{ txid: v.txid, vout: v.vout, amount: v.amount, scriptPubKey: scriptPubKey }],
268
+ outputs, decoded.privkey
269
+ )
270
+ var newTxid = await broadcastTx(rawTx)
271
+ toast('Split broadcast! ' + truncate(newTxid))
272
+
273
+ v.status = 'spent'
274
+ for (var i = 0; i < outputs.length; i++) {
275
+ vouchers.unshift({
276
+ id: Date.now().toString(36) + i,
277
+ txid: newTxid, vout: i,
278
+ amount: outputs[i].amount,
279
+ privkey: v.privkey, address: v.address,
280
+ network: 'btc', status: 'unspent',
281
+ dateAdded: new Date().toISOString()
282
+ })
283
+ }
284
+ splitTarget = null
285
+ saveToStorage()
286
+ renderApp()
287
+ }
288
+
289
+ // ── Merge ───────────────────────────────────────────
290
+
291
+ async function mergeVouchers() {
292
+ var selected = []
293
+ mergeSelected.forEach(function(id) {
294
+ var v = vouchers.find(function(x) { return x.id === id })
295
+ if (v) selected.push(v)
296
+ })
297
+ if (selected.length < 2) { toast('Select at least 2'); return }
298
+ if (selected.some(function(v) { return !v.privkey || v.status !== 'unspent' })) {
299
+ toast('All must be unspent with keys'); return
300
+ }
301
+ var key = selected[0].privkey
302
+ if (!selected.every(function(v) { return v.privkey === key })) {
303
+ toast('All must use the same key'); return
304
+ }
305
+
306
+ var decoded = await decodeKey(key)
307
+ var inputs = []
308
+ for (var sv of selected) {
309
+ var details = await fetchTxDetails(sv.txid)
310
+ var out = details.vout[sv.vout]
311
+ if (!out) { toast('Could not find output for ' + truncate(sv.txid)); return }
312
+ inputs.push({ txid: sv.txid, vout: sv.vout, amount: sv.amount, scriptPubKey: hexToU8(out.scriptpubkey) })
313
+ }
314
+
315
+ var feeRate = await getFeeRate()
316
+ var vsize = estimateVsize(selected.length, 1)
317
+ var fee = Math.ceil(vsize * feeRate)
318
+ var totalInput = selected.reduce(function(s, v) { return s + v.amount }, 0)
319
+ var outputAmount = totalInput - fee
320
+ if (outputAmount <= 546) { toast('Not enough for fee'); return }
321
+
322
+ if (!confirm('Merge ' + selected.length + ' vouchers (' + totalInput.toLocaleString() + ' sats) into one?\nOutput: ' + outputAmount.toLocaleString() + ' sats\nFee: ' + fee + ' sats (' + feeRate + ' sat/vB)')) return
323
+
324
+ toast('Building transaction\u2026')
325
+ var rawTx = await buildTransaction(inputs, [{ amount: outputAmount, scriptPubKey: inputs[0].scriptPubKey }], decoded.privkey)
326
+ var newTxid = await broadcastTx(rawTx)
327
+ toast('Merge broadcast! ' + truncate(newTxid))
328
+
329
+ for (var sv of selected) sv.status = 'spent'
330
+ vouchers.unshift({
331
+ id: Date.now().toString(36),
332
+ txid: newTxid, vout: 0,
333
+ amount: outputAmount,
334
+ privkey: key, address: selected[0].address,
335
+ network: 'btc', status: 'unspent',
336
+ dateAdded: new Date().toISOString()
337
+ })
338
+ mergeSelected.clear()
339
+ mergeMode = false
340
+ saveToStorage()
341
+ renderApp()
342
+ }
343
+
344
+ // ── Add key to voucher ──────────────────────────────
345
+
346
+ async function submitKey(voucherId) {
347
+ var input = container.querySelector('.v-key-input')
348
+ var wif = input && input.value.trim()
349
+ if (!wif) { toast('Paste a key'); return }
350
+ var v = vouchers.find(function(x) { return x.id === voucherId })
351
+ if (!v) return
352
+ try {
353
+ var decoded = await decodeKey(wif)
354
+ var address = privkeyToAddress(decoded.privkey, decoded.testnet)
355
+ var utxos = await fetchUtxos(address)
356
+ var match = utxos.find(function(u) { return u.txid === v.txid && u.vout === v.vout })
357
+ if (!match) { toast('Key does not match this UTXO'); return }
358
+ v.privkey = wif
359
+ v.address = address
360
+ v.status = 'unspent'
361
+ if (match.value) v.amount = match.value
362
+ addKeyTarget = null
363
+ saveToStorage()
364
+ toast('Key added!')
365
+ renderApp()
366
+ } catch (e) {
367
+ toast('Invalid key: ' + e.message)
368
+ }
369
+ }
370
+
371
+ // ── Render ───────────────────────────────────────────
372
+
373
+ function renderApp() {
374
+ var unspent = vouchers.filter(function(v) { return v.status === 'unspent' })
375
+ var totalSats = unspent.reduce(function(s, v) { return s + (v.amount || 0) }, 0)
376
+ var spentCount = vouchers.filter(function(v) { return v.status === 'spent' }).length
377
+ var canMerge = vouchers.filter(function(v) { return v.privkey && v.status === 'unspent' }).length >= 2
378
+
379
+ var mergeSats = 0
380
+ mergeSelected.forEach(function(id) {
381
+ var v = vouchers.find(function(x) { return x.id === id })
382
+ if (v) mergeSats += v.amount || 0
383
+ })
384
+
385
+ render(container, html`
386
+ <style>
387
+ .v-wrap { padding: 0 16px 40px; }
388
+ .v-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
389
+ .v-title { font-size: 1.5em; font-weight: 800; display: flex; align-items: center; gap: 10px; }
390
+ .v-net { font-size: 0.6em; padding: 4px 10px; border-radius: 6px; background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.3); color: #fbbf24; font-weight: 600; letter-spacing: 0.04em; }
391
+ .v-hero { text-align: center; padding: 32px 0 28px; }
392
+ .v-hero-label { font-size: 0.78rem; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px; }
393
+ .v-hero-sats { font-size: 2.8rem; font-weight: 800; letter-spacing: -0.02em; }
394
+ .v-hero-btc { font-size: 0.95rem; color: rgba(255,255,255,0.35); margin-top: 2px; }
395
+ .v-card { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 24px; margin-bottom: 20px; backdrop-filter: blur(12px); }
396
+ .v-card h2 { font-size: 1rem; font-weight: 600; color: rgba(255,255,255,0.9); margin: 0 0 16px; display: flex; align-items: center; justify-content: space-between; }
397
+ .v-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
398
+ .v-stat { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; padding: 14px; text-align: center; }
399
+ .v-stat-val { font-size: 1.5rem; font-weight: 700; }
400
+ .v-stat-label { font-size: 0.7rem; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
401
+ .v-import-row { display: flex; gap: 8px; }
402
+ .v-input { flex: 1; padding: 10px 14px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.9); font: 0.88rem -apple-system, sans-serif; outline: none; transition: border-color 0.15s; }
403
+ .v-input::placeholder { color: rgba(255,255,255,0.25); }
404
+ .v-input:focus { border-color: rgba(124,58,237,0.5); }
405
+ .v-btn { display: inline-flex; align-items: center; gap: 6px; padding: 10px 20px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.8); font: 600 0.85rem -apple-system, sans-serif; cursor: pointer; transition: all 0.15s; }
406
+ .v-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.15); color: #fff; }
407
+ .v-btn-primary { background: linear-gradient(135deg, #7c3aed, #6d28d9); border-color: rgba(124,58,237,0.4); color: #fff; box-shadow: 0 4px 16px rgba(124,58,237,0.2); }
408
+ .v-btn-primary:hover { box-shadow: 0 6px 24px rgba(124,58,237,0.3); }
409
+ .v-btn-sm { padding: 5px 10px; font-size: 0.78rem; border-radius: 6px; }
410
+ .v-btn-icon { padding: 5px 8px; font-size: 0.82rem; border-radius: 6px; min-width: 30px; justify-content: center; }
411
+ .v-btn-danger { color: #ef4444; }
412
+ .v-btn-danger:hover { background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.3); }
413
+ .v-item { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; padding: 14px 16px; margin-bottom: 10px; transition: background 0.15s; }
414
+ .v-item:hover { background: rgba(255,255,255,0.06); }
415
+ .v-item-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
416
+ .v-item-amount { font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: 8px; }
417
+ .v-item-amount small { font-size: 0.7em; font-weight: 400; color: rgba(255,255,255,0.35); }
418
+ .v-item-actions { display: flex; gap: 4px; flex-wrap: wrap; }
419
+ .v-item-details { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 0.82rem; }
420
+ .v-item-label { color: rgba(255,255,255,0.3); }
421
+ .v-item-val { color: rgba(255,255,255,0.6); font-family: 'SF Mono', 'Fira Code', monospace; word-break: break-all; cursor: pointer; }
422
+ .v-item-val:hover { color: rgba(255,255,255,0.9); }
423
+ .v-badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 6px; font-size: 0.72rem; font-weight: 600; }
424
+ .v-badge-unspent { background: rgba(16,185,129,0.12); border: 1px solid rgba(16,185,129,0.3); color: #10b981; }
425
+ .v-badge-spent { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: #ef4444; }
426
+ .v-badge-unknown { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); color: rgba(255,255,255,0.35); }
427
+ .v-badge-locked { background: rgba(251,191,36,0.1); border: 1px solid rgba(251,191,36,0.3); color: #fbbf24; }
428
+ .v-badge-dot { width: 6px; height: 6px; border-radius: 50%; }
429
+ .v-empty { text-align: center; padding: 32px; color: rgba(255,255,255,0.25); font-style: italic; }
430
+ .v-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: rgba(16,185,129,0.9); color: #fff; padding: 8px 20px; border-radius: 8px; font-size: 0.85rem; font-weight: 600; z-index: 999; animation: v-fade 0.3s; }
431
+ @keyframes v-fade { from { opacity: 0; transform: translateX(-50%) translateY(8px); } }
432
+ .v-spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.15); border-top-color: #a78bfa; border-radius: 50%; animation: v-spin 0.6s linear infinite; display: inline-block; }
433
+ @keyframes v-spin { to { transform: rotate(360deg); } }
434
+ .v-help { font-size: 0.78rem; color: rgba(255,255,255,0.25); margin-top: 10px; line-height: 1.5; }
435
+ .v-help code { background: rgba(255,255,255,0.06); padding: 1px 5px; border-radius: 4px; font-size: 0.92em; }
436
+ .v-merge-bar { margin-top: 12px; padding: 14px; background: rgba(124,58,237,0.1); border: 1px solid rgba(124,58,237,0.3); border-radius: 10px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
437
+ .v-expand { margin-top: 10px; }
438
+ @media (max-width: 600px) {
439
+ .v-stats { grid-template-columns: 1fr; }
440
+ .v-hero-sats { font-size: 2rem; }
441
+ .v-import-row { flex-direction: column; }
442
+ }
443
+ </style>
444
+
445
+ <div class="v-wrap">
446
+ <div class="v-header">
447
+ <div class="v-title">\uD83C\uDFAB Voucher Pool <span class="v-net">TESTNET4</span></div>
448
+ </div>
449
+
450
+ <div class="v-hero">
451
+ <div class="v-hero-label">Available Balance</div>
452
+ <div class="v-hero-sats">${totalSats.toLocaleString()} <span style="font-size:0.4em;color:rgba(255,255,255,0.35)">sats</span></div>
453
+ <div class="v-hero-btc">${satsBtc(totalSats)} tBTC</div>
454
+ </div>
455
+
456
+ <div class="v-stats">
457
+ <div class="v-stat">
458
+ <div class="v-stat-val">${String(vouchers.length)}</div>
459
+ <div class="v-stat-label">Total Vouchers</div>
460
+ </div>
461
+ <div class="v-stat">
462
+ <div class="v-stat-val" style="color:#10b981">${String(unspent.length)}</div>
463
+ <div class="v-stat-label">Unspent</div>
464
+ </div>
465
+ <div class="v-stat">
466
+ <div class="v-stat-val" style="color:#ef4444">${String(spentCount)}</div>
467
+ <div class="v-stat-label">Spent</div>
468
+ </div>
469
+ </div>
470
+
471
+ <div class="v-card">
472
+ <h2>Import Voucher</h2>
473
+ <div class="v-import-row">
474
+ <input class="v-input v-import-input" placeholder="Paste hex key, WIF, or TXO URI\u2026"
475
+ onkeydown="${function(e) { if (e.key === 'Enter') doImport() }}"
476
+ oninput="${function(e) { if (isHexKey(e.target.value.trim())) doImport() }}" />
477
+ <button class="v-btn v-btn-primary" onclick="${doImport}">
478
+ ${importing ? html`<span class="v-spinner"></span>` : 'Import'}
479
+ </button>
480
+ </div>
481
+ <div class="v-help">
482
+ Accepts 64-char hex keys, WIF private keys (testnet4), or TXO URIs.
483
+ </div>
484
+ </div>
485
+
486
+ <div class="v-card">
487
+ <h2>
488
+ Vouchers
489
+ <div style="display:flex;gap:6px">
490
+ ${canMerge ? html`
491
+ <button class="${'v-btn v-btn-sm' + (mergeMode ? ' v-btn-merge-active' : '')}"
492
+ style="${mergeMode ? 'color:#a78bfa;border-color:rgba(167,139,250,0.4)' : ''}"
493
+ onclick="${function() { mergeMode = !mergeMode; mergeSelected.clear(); renderApp() }}">
494
+ ${mergeMode ? '\u2716 Cancel' : '\uD83D\uDD00 Merge'}
495
+ </button>
496
+ ` : null}
497
+ <button class="v-btn v-btn-sm" onclick="${function() { refreshStatus() }}"
498
+ disabled="${refreshing}">
499
+ ${refreshing ? html`<span class="v-spinner"></span> Checking\u2026` : '\u21BB Refresh'}
500
+ </button>
501
+ ${vouchers.length > 0 ? html`
502
+ <button class="v-btn v-btn-sm v-btn-danger" onclick="${clearAll}">\u2716 Clear All</button>
503
+ ` : null}
504
+ </div>
505
+ </h2>
506
+
507
+ ${vouchers.length === 0 ? html`
508
+ <div class="v-empty">No vouchers yet. Import a key or TXO URI to get started.</div>
509
+ ` : null}
510
+
511
+ ${keyed(vouchers, function(v) { return v.id }, function(v) {
512
+ return renderVoucherItem(v)
513
+ })}
514
+
515
+ ${mergeMode && mergeSelected.size >= 2 ? html`
516
+ <div class="v-merge-bar">
517
+ <span style="font-size:0.88rem">
518
+ Merge <strong>${String(mergeSelected.size)}</strong> vouchers (${mergeSats.toLocaleString()} sats)
519
+ </span>
520
+ <button class="v-btn v-btn-primary" onclick="${function() { mergeVouchers().catch(function(e) { toast('Merge failed: ' + e.message) }) }}">
521
+ \uD83D\uDD00 Merge Now
522
+ </button>
523
+ </div>
524
+ ` : null}
525
+ </div>
526
+ </div>
527
+ `)
528
+ }
529
+
530
+ // ── Voucher item template ───────────────────────────
531
+
532
+ function renderVoucherItem(v) {
533
+ var hasKey = !!v.privkey
534
+ var badgeClass = v.status === 'unspent' ? 'v-badge v-badge-unspent'
535
+ : v.status === 'spent' ? 'v-badge v-badge-spent'
536
+ : 'v-badge v-badge-unknown'
537
+ var badgeLabel = v.status === 'unspent' ? 'Unspent' : v.status === 'spent' ? 'Spent' : 'Unknown'
538
+ var badgeDotStyle = v.status === 'unspent' ? 'background:#10b981'
539
+ : v.status === 'spent' ? 'background:#ef4444'
540
+ : 'background:rgba(255,255,255,0.3)'
541
+
542
+ var itemStyle = !hasKey ? 'border-color:rgba(251,191,36,0.2)'
543
+ : mergeSelected.has(v.id) ? 'border-color:rgba(167,139,250,0.4)'
544
+ : ''
545
+
546
+ var isRevealed = revealedKeys.has(v.id)
547
+
548
+ return html`
549
+ <div class="v-item" style="${itemStyle}">
550
+ <div class="v-item-top">
551
+ <div class="v-item-amount">
552
+ ${mergeMode && hasKey && v.status === 'unspent' ? html`
553
+ <input type="checkbox" checked="${mergeSelected.has(v.id) ? true : false}"
554
+ style="width:16px;height:16px;accent-color:#7c3aed;cursor:pointer"
555
+ onchange="${function(e) {
556
+ if (e.target.checked) mergeSelected.add(v.id)
557
+ else mergeSelected.delete(v.id)
558
+ renderApp()
559
+ }}" />
560
+ ` : null}
561
+ ${(v.amount || 0).toLocaleString()} ${html`<small>sats</small>`}
562
+ <span class="${badgeClass}"><span class="v-badge-dot" style="${badgeDotStyle}"></span>${badgeLabel}</span>
563
+ ${!hasKey ? html`<span class="v-badge v-badge-locked"><span class="v-badge-dot" style="background:#fbbf24"></span>No Key</span>` : null}
564
+ </div>
565
+ <div class="v-item-actions">
566
+ ${!hasKey ? html`
567
+ <button class="v-btn v-btn-sm" style="color:#fbbf24;border-color:rgba(251,191,36,0.3)"
568
+ onclick="${function() { addKeyTarget = addKeyTarget === v.id ? null : v.id; renderApp() }}">
569
+ \uD83D\uDD11 Add Key
570
+ </button>
571
+ ` : null}
572
+ ${hasKey && v.status === 'unspent' && !mergeMode ? html`
573
+ <button class="v-btn v-btn-sm"
574
+ onclick="${function() { splitTarget = splitTarget === v.id ? null : v.id; renderApp() }}">
575
+ \u2702 Split
576
+ </button>
577
+ ` : null}
578
+ ${hasKey ? html`
579
+ <button class="v-btn v-btn-icon" title="Copy TXO URI"
580
+ onclick="${function() { copyText(buildTxoUri(v)) }}">\u2398</button>
581
+ ` : null}
582
+ ${hasKey ? html`
583
+ <button class="v-btn v-btn-icon" title="Copy share link"
584
+ onclick="${function() { copyText(location.origin + location.pathname + '?key=' + v.privkey) }}">\uD83D\uDD17</button>
585
+ ` : null}
586
+ ${hasKey ? html`
587
+ <button class="v-btn v-btn-icon" title="Copy private key"
588
+ onclick="${function() { copyText(v.privkey) }}">\uD83D\uDD11</button>
589
+ ` : null}
590
+ <button class="v-btn v-btn-icon v-btn-danger" title="Delete"
591
+ onclick="${function() { vouchers = vouchers.filter(function(x) { return x.id !== v.id }); saveToStorage(); renderApp() }}">\u2716</button>
592
+ </div>
593
+ </div>
594
+
595
+ <div class="v-item-details">
596
+ <span class="v-item-label">TXID</span>
597
+ <a class="v-item-val" href="${'https://mempool.space/testnet4/tx/' + v.txid}" target="_blank" rel="noopener"
598
+ style="color:rgba(167,139,250,0.8);text-decoration:none">
599
+ ${truncate(v.txid) + ':' + v.vout}
600
+ </a>
601
+ ${v.address ? html`
602
+ <span class="v-item-label">Address</span>
603
+ <span class="v-item-val" onclick="${function() { copyText(v.address) }}">${truncate(v.address, 10, 6)}</span>
604
+ ` : null}
605
+ ${hasKey ? html`
606
+ <span class="v-item-label">Key</span>
607
+ <span class="v-item-val" style="cursor:pointer;user-select:none"
608
+ onclick="${function() {
609
+ if (isRevealed) revealedKeys.delete(v.id)
610
+ else revealedKeys.add(v.id)
611
+ renderApp()
612
+ }}">
613
+ ${isRevealed ? v.privkey : maskKey(v.privkey)}
614
+ ${!isRevealed ? html`<span style="font-size:0.8em;color:rgba(255,255,255,0.2)"> (click)</span>` : null}
615
+ </span>
616
+ ` : null}
617
+ </div>
618
+
619
+ ${v.id === addKeyTarget ? html`
620
+ <div class="v-expand">
621
+ <div class="v-import-row">
622
+ <input class="v-input v-key-input" placeholder="Paste hex or WIF private key\u2026"
623
+ onkeydown="${function(e) { if (e.key === 'Enter') submitKey(v.id) }}"
624
+ oninput="${function(e) { if (isHexKey(e.target.value.trim())) submitKey(v.id) }}" />
625
+ <button class="v-btn v-btn-primary v-btn-sm" onclick="${function() { submitKey(v.id) }}">\uD83D\uDD11 Unlock</button>
626
+ </div>
627
+ </div>
628
+ ` : null}
629
+
630
+ ${v.id === splitTarget ? html`
631
+ <div class="v-expand">
632
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
633
+ <span style="color:rgba(255,255,255,0.5);font-size:0.82rem">Split into</span>
634
+ <input type="number" class="v-input v-split-input" value="2" min="2" max="100"
635
+ style="width:70px;text-align:center" />
636
+ <span style="color:rgba(255,255,255,0.5);font-size:0.82rem">outputs</span>
637
+ <button class="v-btn v-btn-primary v-btn-sm"
638
+ onclick="${function() { confirmSplit(v.id).catch(function(e) { toast('Split failed: ' + e.message) }) }}">
639
+ \u2702 Confirm
640
+ </button>
641
+ <button class="v-btn v-btn-sm"
642
+ onclick="${function() { splitTarget = null; renderApp() }}">\u2716</button>
643
+ </div>
644
+ </div>
645
+ ` : null}
646
+ </div>
647
+ `
648
+ }
649
+
650
+ // ── Actions ─────────────────────────────────────────
651
+
652
+ async function doImport() {
653
+ if (importing) return
654
+ var input = container.querySelector('.v-import-input')
655
+ if (!input || !input.value.trim()) return
656
+ importing = true
657
+ renderApp()
658
+ await importKey(input.value)
659
+ importing = false
660
+ renderApp()
661
+ }
662
+
663
+ function clearAll() {
664
+ if (!confirm('Delete all vouchers?')) return
665
+ vouchers = []
666
+ saveToStorage()
667
+ renderApp()
668
+ }
669
+
670
+ // ── Init ────────────────────────────────────────────
671
+
672
+ renderApp()
673
+
674
+ // Auto-import from ?key= parameter
675
+ if (window.__pendingImport) {
676
+ var keyToImport = window.__pendingImport
677
+ delete window.__pendingImport
678
+ importing = true
679
+ renderApp()
680
+ importKey(keyToImport).then(function() {
681
+ importing = false
682
+ renderApp()
683
+ })
684
+ }
685
+
686
+ // No auto-refresh on load — it triggers PUT which causes live-reload loop.
687
+ // User clicks "Refresh" manually.
688
+
689
+ onUnmount(container, function() {
690
+ // Cleanup if needed
691
+ })
692
+ }
693
+ }