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,673 @@
1
+ import { html, render, keyed, onUnmount } from 'https://losos.org/losos/html.js'
2
+ import { Blocktrail, verify, genesis as btGenesis, transition as btTransition, hexToBytes } from 'https://esm.sh/blocktrails@0.0.11'
3
+ import {
4
+ parseVoucherFromItem, decodeKey, bytesToHex,
5
+ buildTransaction, estimateVsize, fetchUtxos, fetchTxDetails, broadcastTx, getFeeRate, hexToU8,
6
+ wpToP2trAddress
7
+ } from '../lib/bitcoin.js'
8
+
9
+ export default {
10
+ label: 'Blocktrails',
11
+ icon: '\u26D3',
12
+
13
+ canHandle(subject, store) {
14
+ var node = store.get(subject.value)
15
+ if (!node) return false
16
+ var type = store.type(node)
17
+ return type && type.includes('VoucherPool')
18
+ },
19
+
20
+ render(subject, lionStore, container, rawData) {
21
+
22
+ // ── State ───────────────────────────────────────────
23
+
24
+ var TRAIL_URL = new URL('blocktrail.jsonld', location.href).href
25
+ var MEMPOOL_BASE = 'https://mempool.space/testnet4'
26
+ var trail = null
27
+ var privkeyHex = ''
28
+ var error = null
29
+ var verifyResult = null
30
+ var onchainStatus = {} // index → { funded, txid, confirmed }
31
+ var funding = false
32
+ var advancing = false
33
+
34
+ // Load persisted trail from blocktrail.jsonld
35
+ function loadTrailFromData(data) {
36
+ try {
37
+ var storedPrivkey = data['bt:privkey'] || ''
38
+ var stateNodes = data['bt:state'] || []
39
+ if (!Array.isArray(stateNodes)) stateNodes = [stateNodes]
40
+ var storedStates = stateNodes.map(function(n) {
41
+ return typeof n === 'string' ? n : (n['bt:value'] || '')
42
+ }).filter(Boolean)
43
+
44
+ if (storedPrivkey && storedStates.length > 0) {
45
+ privkeyHex = storedPrivkey
46
+ trail = new Blocktrail(storedPrivkey)
47
+ for (var i = 0; i < storedStates.length; i++) {
48
+ if (i === 0) trail.genesis(storedStates[i])
49
+ else trail.advance(storedStates[i])
50
+ }
51
+ // Restore on-chain status
52
+ for (var j = 0; j < stateNodes.length; j++) {
53
+ var n = stateNodes[j]
54
+ if (typeof n === 'object' && n['bt:txid']) {
55
+ onchainStatus[j] = { funded: true, txid: n['bt:txid'], confirmed: n['bt:confirmed'] || false }
56
+ }
57
+ }
58
+ }
59
+ } catch(e) {
60
+ console.warn('Failed to load trail:', e)
61
+ }
62
+ }
63
+
64
+ // Fetch trail data from server
65
+ fetch(TRAIL_URL + '?t=' + Date.now(), { cache: 'no-store' })
66
+ .then(function(res) { return res.ok ? res.json() : null })
67
+ .then(function(data) {
68
+ if (data && data['bt:privkey']) {
69
+ loadTrailFromData(data)
70
+ renderApp()
71
+ }
72
+ })
73
+ .catch(function(e) { console.warn('Failed to fetch trail:', e) })
74
+
75
+ // ── Helpers ─────────────────────────────────────────
76
+
77
+ function toast(msg) {
78
+ var t = document.createElement('div')
79
+ t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(247,147,26,0.9);color:#000;padding:8px 20px;border-radius:8px;font-size:0.85rem;font-weight:600;z-index:999;animation:v-fade 0.3s'
80
+ t.textContent = msg
81
+ document.body.appendChild(t)
82
+ setTimeout(function() { t.remove() }, 2000)
83
+ }
84
+
85
+ function copyText(text) {
86
+ navigator.clipboard.writeText(text).then(function() { toast('Copied') })
87
+ }
88
+
89
+ function truncate(s, start, end) {
90
+ start = start || 8; end = end || 6
91
+ if (!s || s.length <= start + end + 3) return s
92
+ return s.slice(0, start) + '\u2026' + s.slice(-end)
93
+ }
94
+
95
+ // Get P2TR address for state at index (testnet4) from witness program
96
+ function getAddress(stateIndex) {
97
+ try {
98
+ var exp = trail.export()
99
+ var wp = exp.witnessPrograms[stateIndex]
100
+ if (!wp) return ''
101
+ return wpToP2trAddress(wp, true) // true = testnet
102
+ } catch(e) { return '' }
103
+ }
104
+
105
+ // Get signing key for state at index
106
+ function getSigningKey(stateIndex) {
107
+ try {
108
+ if (stateIndex === 0) {
109
+ var g = btGenesis(hexToBytes(privkeyHex), trail.states[0])
110
+ return g ? g.derivedPrivkey : ''
111
+ } else {
112
+ var prevStates = trail.states.slice(0, stateIndex)
113
+ var t = btTransition(hexToBytes(privkeyHex), prevStates, trail.states[stateIndex])
114
+ return t ? t.signingPrivkey : ''
115
+ }
116
+ } catch(e) { return '' }
117
+ }
118
+
119
+ function saveTrail() {
120
+ if (!trail || !privkeyHex) return
121
+ var exp = trail.export()
122
+ var jsonLd = {
123
+ '@context': { 'schema': 'https://schema.org/', 'bt': 'https://blocktrails.org/ns/' },
124
+ '@id': '#this',
125
+ '@type': 'bt:Trail',
126
+ 'bt:privkey': privkeyHex,
127
+ 'bt:pubkeyBase': exp.pubkeyBase,
128
+ 'bt:state': exp.states.map(function(s, i) {
129
+ var stateObj = {
130
+ '@type': 'bt:State',
131
+ 'bt:value': s,
132
+ 'bt:witnessProgram': exp.witnessPrograms[i],
133
+ 'bt:address': getAddress(i)
134
+ }
135
+ if (onchainStatus[i]) {
136
+ stateObj['bt:txid'] = onchainStatus[i].txid
137
+ stateObj['bt:confirmed'] = onchainStatus[i].confirmed
138
+ }
139
+ return stateObj
140
+ })
141
+ }
142
+ fetch(TRAIL_URL, {
143
+ method: 'PUT',
144
+ headers: { 'Content-Type': 'application/ld+json' },
145
+ body: JSON.stringify(jsonLd, null, 2)
146
+ }).catch(function(e) { console.warn('PUT blocktrail failed:', e) })
147
+ }
148
+
149
+ // ── Actions ─────────────────────────────────────────
150
+
151
+ function initTrail() {
152
+ var keyInput = container.querySelector('.bt-key-input')
153
+ var stateInput = container.querySelector('.bt-state-input')
154
+ var key = keyInput && keyInput.value.trim()
155
+ var state = stateInput && stateInput.value.trim()
156
+ if (!key || key.length !== 64) { toast('Enter a 64-char hex private key'); return }
157
+ if (!state) { toast('Enter genesis state'); return }
158
+
159
+ try {
160
+ privkeyHex = key
161
+ trail = new Blocktrail(key)
162
+ trail.genesis(state)
163
+ error = null
164
+ saveTrail()
165
+ toast('Trail initialized')
166
+ renderApp()
167
+ } catch(e) {
168
+ error = e.message
169
+ renderApp()
170
+ }
171
+ }
172
+
173
+ function advanceTrail() {
174
+ var input = container.querySelector('.bt-advance-input')
175
+ var state = input && input.value.trim()
176
+ if (!state) { toast('Enter new state'); return }
177
+ if (!trail) { toast('No active trail'); return }
178
+
179
+ try {
180
+ trail.advance(state)
181
+ error = null
182
+ saveTrail()
183
+ toast('State advanced')
184
+ renderApp()
185
+ } catch(e) {
186
+ error = e.message
187
+ renderApp()
188
+ }
189
+ }
190
+
191
+ // Fund genesis address from a voucher
192
+ async function fundGenesis() {
193
+ if (!trail || funding) return
194
+ funding = true
195
+ renderApp()
196
+
197
+ try {
198
+ var genesisAddr = getAddress(0)
199
+ if (!genesisAddr) throw new Error('Could not derive genesis address')
200
+
201
+ // Find a funded voucher from the pool
202
+ var poolRes = await fetch(new URL('voucher-data.jsonld', location.href).href + '?t=' + Date.now(), { cache: 'no-store' })
203
+ var poolData = poolRes.ok ? await poolRes.json() : rawData
204
+ var items = (poolData && poolData['schema:itemListElement']) || []
205
+ var voucher = null
206
+ for (var item of items) {
207
+ var v = parseVoucherFromItem(item)
208
+ if (v.privkey && v.status === 'unspent' && v.amount > 1000) { voucher = v; break }
209
+ }
210
+ if (!voucher) throw new Error('No funded voucher available')
211
+
212
+ var decoded = await decodeKey(voucher.privkey)
213
+ var txDetails = await fetchTxDetails(voucher.txid)
214
+ var prevOut = txDetails.vout[voucher.vout]
215
+ if (!prevOut) throw new Error('Could not find UTXO')
216
+ var scriptPubKey = hexToU8(prevOut.scriptpubkey)
217
+
218
+ // Build tx: voucher → genesis address
219
+ var feeRate = await getFeeRate()
220
+ var vsize = estimateVsize(1, 1)
221
+ var fee = Math.ceil(vsize * feeRate)
222
+ var outputAmount = voucher.amount - fee
223
+ if (outputAmount <= 546) throw new Error('Voucher too small for fee')
224
+
225
+ // Genesis output script (P2TR)
226
+ var genesisWp = trail.export().witnessPrograms[0]
227
+ var outputScript = new Uint8Array(34)
228
+ outputScript[0] = 0x51 // OP_1
229
+ outputScript[1] = 0x20 // push 32 bytes
230
+ var wpBytes = hexToU8(genesisWp)
231
+ outputScript.set(wpBytes, 2)
232
+
233
+ if (!confirm('Fund genesis with ' + outputAmount.toLocaleString() + ' sats from voucher?\nFee: ' + fee + ' sats (' + feeRate + ' sat/vB)\nTo: ' + genesisAddr)) {
234
+ funding = false; renderApp(); return
235
+ }
236
+
237
+ var rawTx = await buildTransaction(
238
+ [{ txid: voucher.txid, vout: voucher.vout, amount: voucher.amount, scriptPubKey: scriptPubKey }],
239
+ [{ amount: outputAmount, scriptPubKey: outputScript }],
240
+ decoded.privkey
241
+ )
242
+ var newTxid = await broadcastTx(rawTx)
243
+ onchainStatus[0] = { funded: true, txid: newTxid, confirmed: false }
244
+ saveTrail()
245
+ toast('Genesis funded! ' + truncate(newTxid))
246
+ } catch(e) {
247
+ toast('Fund failed: ' + e.message)
248
+ console.error(e)
249
+ }
250
+ funding = false
251
+ renderApp()
252
+ }
253
+
254
+ // Advance on-chain: spend from current state to next
255
+ async function advanceOnChain() {
256
+ if (!trail || advancing) return
257
+ var input = container.querySelector('.bt-advance-input')
258
+ var newState = input && input.value.trim()
259
+ if (!newState) { toast('Enter new state'); return }
260
+
261
+ var currentIdx = trail.states.length - 1
262
+ if (!onchainStatus[currentIdx] || !onchainStatus[currentIdx].funded) {
263
+ toast('Current state not funded on-chain'); return
264
+ }
265
+
266
+ advancing = true
267
+ renderApp()
268
+
269
+ try {
270
+ // Get current state's UTXO
271
+ var currentAddr = getAddress(currentIdx)
272
+ var currentSigningKey = getSigningKey(currentIdx)
273
+ if (!currentSigningKey) throw new Error('Could not derive signing key')
274
+
275
+ var utxos = await fetchUtxos(currentAddr)
276
+ if (utxos.length === 0) throw new Error('No UTXO at current address')
277
+ var utxo = utxos[0]
278
+
279
+ // Advance the trail locally first to get new address
280
+ trail.advance(newState)
281
+ var newIdx = trail.states.length - 1
282
+ var newAddr = getAddress(newIdx)
283
+ var newWp = trail.export().witnessPrograms[newIdx]
284
+
285
+ // Build output script
286
+ var outputScript = new Uint8Array(34)
287
+ outputScript[0] = 0x51
288
+ outputScript[1] = 0x20
289
+ outputScript.set(hexToU8(newWp), 2)
290
+
291
+ // Get scriptPubKey for the input
292
+ var txDetails = await fetchTxDetails(utxo.txid)
293
+ var prevOut = txDetails.vout[utxo.vout]
294
+ var inputScript = hexToU8(prevOut.scriptpubkey)
295
+
296
+ var feeRate = await getFeeRate()
297
+ var vsize = estimateVsize(1, 1)
298
+ var fee = Math.ceil(vsize * feeRate)
299
+ var outputAmount = utxo.value - fee
300
+ if (outputAmount <= 546) throw new Error('UTXO too small for fee')
301
+
302
+ if (!confirm('Advance on-chain: ' + outputAmount.toLocaleString() + ' sats\nFee: ' + fee + ' sats (' + feeRate + ' sat/vB)\nTo: ' + newAddr)) {
303
+ // Undo the advance
304
+ trail = new Blocktrail(privkeyHex)
305
+ for (var i = 0; i < trail.states.length; i++) {
306
+ // Replay is not possible this way, need to reconstruct
307
+ }
308
+ // Simpler: reload
309
+ advancing = false; location.reload(); return
310
+ }
311
+
312
+ var rawTx = await buildTransaction(
313
+ [{ txid: utxo.txid, vout: utxo.vout, amount: utxo.value, scriptPubKey: inputScript }],
314
+ [{ amount: outputAmount, scriptPubKey: outputScript }],
315
+ hexToU8(currentSigningKey)
316
+ )
317
+ var newTxid = await broadcastTx(rawTx)
318
+ onchainStatus[newIdx] = { funded: true, txid: newTxid, confirmed: false }
319
+ saveTrail()
320
+ toast('State advanced on-chain! ' + truncate(newTxid))
321
+ } catch(e) {
322
+ toast('Advance failed: ' + e.message)
323
+ console.error(e)
324
+ }
325
+ advancing = false
326
+ renderApp()
327
+ }
328
+
329
+ // Check on-chain status for all states
330
+ async function checkOnChain() {
331
+ if (!trail) return
332
+ for (var i = 0; i < trail.states.length; i++) {
333
+ try {
334
+ var addr = getAddress(i)
335
+ if (!addr) continue
336
+ var utxos = await fetchUtxos(addr)
337
+ if (utxos.length > 0) {
338
+ onchainStatus[i] = {
339
+ funded: true,
340
+ txid: utxos[0].txid,
341
+ confirmed: utxos[0].status && utxos[0].status.confirmed
342
+ }
343
+ }
344
+ } catch(e) {}
345
+ }
346
+ saveTrail()
347
+ renderApp()
348
+ }
349
+
350
+ var verifying = false
351
+
352
+ async function verifyChain() {
353
+ if (!trail || verifying) return
354
+ verifying = true
355
+ verifyResult = { running: true, steps: [] }
356
+ renderApp()
357
+
358
+ var exp = trail.export()
359
+
360
+ // Step through each state with a delay for visual effect
361
+ // verify() expects witnessPrograms as Uint8Array[], but export() returns hex strings
362
+ var wpBytes = exp.witnessPrograms.map(function(wp) { return hexToU8(wp) })
363
+
364
+ for (var i = 0; i < exp.states.length; i++) {
365
+ await new Promise(function(r) { setTimeout(r, 300) })
366
+ try {
367
+ var partial = verify(exp.pubkeyBase, exp.states.slice(0, i + 1), wpBytes.slice(0, i + 1))
368
+ verifyResult.steps.push({ index: i, valid: partial.valid, state: exp.states[i] })
369
+ } catch(e) {
370
+ verifyResult.steps.push({ index: i, valid: false, state: exp.states[i], error: e.message })
371
+ }
372
+ renderApp()
373
+ }
374
+
375
+ await new Promise(function(r) { setTimeout(r, 200) })
376
+ var allValid = verifyResult.steps.every(function(s) { return s.valid })
377
+ verifyResult.running = false
378
+ verifyResult.valid = allValid
379
+ verifying = false
380
+ renderApp()
381
+ }
382
+
383
+ function resetTrail() {
384
+ if (!confirm('Reset blocktrail? This clears all state history.')) return
385
+ trail = null
386
+ privkeyHex = ''
387
+ error = null
388
+ verifyResult = null
389
+ onchainStatus = {}
390
+ var emptyTrail = {
391
+ '@context': { 'schema': 'https://schema.org/', 'bt': 'https://blocktrails.org/ns/' },
392
+ '@id': '#this', '@type': 'bt:Trail', 'bt:pubkeyBase': '', 'bt:state': []
393
+ }
394
+ fetch(TRAIL_URL, {
395
+ method: 'PUT',
396
+ headers: { 'Content-Type': 'application/ld+json' },
397
+ body: JSON.stringify(emptyTrail, null, 2)
398
+ }).catch(function(e) { console.warn('PUT reset failed:', e) })
399
+ renderApp()
400
+ }
401
+
402
+ async function useVoucherKey() {
403
+ try {
404
+ var poolRes = await fetch(new URL('voucher-data.jsonld', location.href).href + '?t=' + Date.now(), { cache: 'no-store' })
405
+ var poolData = poolRes.ok ? await poolRes.json() : rawData
406
+ var items = (poolData && poolData['schema:itemListElement']) || []
407
+ for (var item of items) {
408
+ var v = parseVoucherFromItem(item)
409
+ if (v.privkey) {
410
+ var keyInput = container.querySelector('.bt-key-input')
411
+ if (!keyInput) return
412
+ if (v.privkey.length === 64 && /^[0-9a-fA-F]{64}$/.test(v.privkey)) {
413
+ keyInput.value = v.privkey
414
+ } else {
415
+ try {
416
+ var decoded = await decodeKey(v.privkey)
417
+ keyInput.value = bytesToHex(decoded.privkey)
418
+ } catch(e) { keyInput.value = v.privkey }
419
+ }
420
+ toast('Key loaded from voucher pool')
421
+ return
422
+ }
423
+ }
424
+ toast('No keyed vouchers found')
425
+ } catch(e) { toast('Could not read voucher pool: ' + e.message) }
426
+ }
427
+
428
+ // ── Render ───────────────────────────────────────────
429
+
430
+ function renderApp() {
431
+ var exp = trail ? trail.export() : null
432
+ var states = exp ? exp.states : []
433
+ var programs = exp ? exp.witnessPrograms : []
434
+ var currentIdx = states.length - 1
435
+ var currentFunded = onchainStatus[currentIdx] && onchainStatus[currentIdx].funded
436
+
437
+ render(container, html`
438
+ <style>
439
+ .bt-wrap { padding: 0 16px 40px; }
440
+ .bt-hero { text-align: center; padding: 40px 0 32px; }
441
+ .bt-hero-icon { font-size: 3rem; margin-bottom: 8px; }
442
+ .bt-hero-title { font-size: 1.6rem; font-weight: 800; margin-bottom: 4px; }
443
+ .bt-hero-sub { font-size: 0.9rem; color: rgba(255,255,255,0.35); }
444
+ .bt-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); }
445
+ .bt-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; flex-wrap: wrap; gap: 8px; }
446
+ .bt-input { width: 100%; 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; margin-bottom: 10px; }
447
+ .bt-input::placeholder { color: rgba(255,255,255,0.25); }
448
+ .bt-input:focus { border-color: rgba(247,147,26,0.5); }
449
+ .bt-row { display: flex; gap: 8px; }
450
+ .bt-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; }
451
+ .bt-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
452
+ .bt-btn-primary { background: linear-gradient(135deg, #f7931a, #e8850f); border-color: rgba(247,147,26,0.4); color: #000; box-shadow: 0 4px 16px rgba(247,147,26,0.2); }
453
+ .bt-btn-primary:hover { box-shadow: 0 6px 24px rgba(247,147,26,0.3); }
454
+ .bt-btn-green { background: linear-gradient(135deg, #10b981, #059669); border-color: rgba(16,185,129,0.4); color: #fff; box-shadow: 0 4px 16px rgba(16,185,129,0.2); }
455
+ .bt-btn-sm { padding: 5px 10px; font-size: 0.78rem; border-radius: 6px; }
456
+ .bt-btn-danger { color: #ef4444; }
457
+ .bt-btn-danger:hover { background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.3); }
458
+ .bt-error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: #ef4444; border-radius: 8px; padding: 10px 14px; font-size: 0.85rem; margin-bottom: 12px; }
459
+ .bt-chain { position: relative; padding-left: 24px; }
460
+ .bt-chain::before { content: ''; position: absolute; left: 9px; top: 0; bottom: 0; width: 2px; background: rgba(247,147,26,0.2); }
461
+ .bt-state { position: relative; margin-bottom: 16px; padding: 14px 16px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; }
462
+ .bt-state::before { content: ''; position: absolute; left: -19px; top: 18px; width: 10px; height: 10px; border-radius: 50%; background: #f7931a; border: 2px solid rgba(247,147,26,0.4); }
463
+ .bt-state:last-child::before { box-shadow: 0 0 8px rgba(247,147,26,0.4); }
464
+ .bt-state-funded::before { background: #10b981; border-color: rgba(16,185,129,0.4); }
465
+ .bt-state-funded:last-child::before { box-shadow: 0 0 8px rgba(16,185,129,0.4); }
466
+ .bt-state-idx { font-size: 0.7rem; color: rgba(255,255,255,0.3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; }
467
+ .bt-state-val { font-size: 0.88rem; color: rgba(255,255,255,0.8); word-break: break-all; margin-bottom: 6px; font-family: 'SF Mono', 'Fira Code', monospace; }
468
+ .bt-state-detail { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; font-size: 0.78rem; margin-top: 8px; }
469
+ .bt-state-label { color: rgba(255,255,255,0.25); }
470
+ .bt-state-val2 { color: rgba(255,255,255,0.5); font-family: 'SF Mono', 'Fira Code', monospace; cursor: pointer; word-break: break-all; }
471
+ .bt-state-val2:hover { color: rgba(255,255,255,0.8); }
472
+ .bt-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 5px; font-size: 0.68rem; font-weight: 600; margin-left: 8px; }
473
+ .bt-badge-onchain { background: rgba(16,185,129,0.12); border: 1px solid rgba(16,185,129,0.3); color: #10b981; }
474
+ .bt-badge-pending { background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.3); color: #fbbf24; }
475
+ .bt-badge-offchain { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); color: rgba(255,255,255,0.3); }
476
+ .bt-verify-card { margin-top: 16px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 20px; animation: bt-slideIn 0.3s ease; }
477
+ @keyframes bt-slideIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
478
+ .bt-verify-header { display: flex; align-items: center; gap: 10px; font-size: 1.1rem; font-weight: 700; margin-bottom: 16px; }
479
+ .bt-verify-icon { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1rem; }
480
+ .bt-verify-pass { background: rgba(16,185,129,0.15); color: #10b981; border: 2px solid rgba(16,185,129,0.4); box-shadow: 0 0 16px rgba(16,185,129,0.2); }
481
+ .bt-verify-failed { background: rgba(239,68,68,0.15); color: #ef4444; border: 2px solid rgba(239,68,68,0.4); }
482
+ .bt-verify-steps { display: flex; flex-direction: column; gap: 8px; }
483
+ .bt-verify-step { display: flex; align-items: center; gap: 10px; font-size: 0.85rem; padding: 6px 0; animation: bt-fadeStep 0.3s ease; }
484
+ @keyframes bt-fadeStep { from { opacity: 0; transform: translateX(-8px); } to { opacity: 1; transform: translateX(0); } }
485
+ .bt-verify-dot { width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; font-weight: 700; flex-shrink: 0; }
486
+ .bt-vdot-pass { background: rgba(16,185,129,0.15); color: #10b981; border: 1px solid rgba(16,185,129,0.3); }
487
+ .bt-vdot-fail { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); }
488
+ .bt-verify-step-label { color: rgba(255,255,255,0.5); font-weight: 600; min-width: 70px; }
489
+ .bt-verify-step-val { color: rgba(255,255,255,0.7); font-family: 'SF Mono', 'Fira Code', monospace; }
490
+ .bt-verify-step-err { color: #ef4444; font-size: 0.78rem; }
491
+ .bt-verify-summary { margin-top: 14px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.06); font-size: 0.78rem; color: rgba(16,185,129,0.6); letter-spacing: 0.02em; }
492
+ .bt-stats { display: flex; gap: 16px; justify-content: center; margin-bottom: 24px; flex-wrap: wrap; }
493
+ .bt-stat { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; padding: 14px 24px; text-align: center; }
494
+ .bt-stat-val { font-size: 1.5rem; font-weight: 700; color: #f7931a; }
495
+ .bt-stat-label { font-size: 0.7rem; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
496
+ .bt-help { font-size: 0.78rem; color: rgba(255,255,255,0.25); margin-top: 10px; line-height: 1.5; }
497
+ .bt-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.15); border-top-color: #f7931a; border-radius: 50%; animation: v-spin 0.6s linear infinite; display: inline-block; }
498
+ @media (max-width: 600px) { .bt-row { flex-direction: column; } }
499
+ </style>
500
+
501
+ <div class="bt-wrap">
502
+ <div class="bt-hero">
503
+ <div class="bt-hero-icon">\u26D3</div>
504
+ <div class="bt-hero-title">Blocktrails</div>
505
+ <div class="bt-hero-sub">Anchor state to Bitcoin with Nostr-native key chaining</div>
506
+ </div>
507
+
508
+ ${error ? html`<div class="bt-error">${error}</div>` : null}
509
+
510
+ ${!trail ? html`
511
+ <div class="bt-card">
512
+ <h2>Initialize Trail</h2>
513
+ <div class="bt-row" style="margin-bottom:10px">
514
+ <input class="bt-input bt-key-input" placeholder="Private key (64-char hex)\u2026" style="flex:1;margin-bottom:0" />
515
+ <button class="bt-btn bt-btn-sm" onclick="${useVoucherKey}" title="Load key from voucher pool">\uD83D\uDD11 From Pool</button>
516
+ </div>
517
+ <input class="bt-input bt-state-input" placeholder="Genesis state (e.g. JSON)\u2026"
518
+ onkeydown="${function(e) { if (e.key === 'Enter') initTrail() }}" />
519
+ <button class="bt-btn bt-btn-primary" onclick="${initTrail}">\u26D3 Create Trail</button>
520
+ <div class="bt-help">
521
+ Each state produces a unique P2TR address via chained key tweaking.<br/>
522
+ The chain of spends is the state history \u2014 immutably ordered by Bitcoin.
523
+ </div>
524
+ </div>
525
+ ` : html`
526
+ <div class="bt-stats">
527
+ <div class="bt-stat">
528
+ <div class="bt-stat-val">${String(states.length)}</div>
529
+ <div class="bt-stat-label">States</div>
530
+ </div>
531
+ <div class="bt-stat">
532
+ <div class="bt-stat-val">${truncate(exp.pubkeyBase, 6, 4)}</div>
533
+ <div class="bt-stat-label">Base Pubkey</div>
534
+ </div>
535
+ <div class="bt-stat">
536
+ <div class="bt-stat-val">${String(Object.keys(onchainStatus).filter(function(k) { return onchainStatus[k].funded }).length)}</div>
537
+ <div class="bt-stat-label">On-Chain</div>
538
+ </div>
539
+ </div>
540
+
541
+ <div class="bt-card">
542
+ <h2>
543
+ ${currentFunded ? 'Advance On-Chain' : 'Advance State'}
544
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
545
+ ${states.length > 0 && !onchainStatus[0] ? html`
546
+ <button class="bt-btn bt-btn-sm bt-btn-green" onclick="${fundGenesis}"
547
+ disabled="${funding}">
548
+ ${funding ? html`<span class="bt-spinner"></span>` : '\u26A1'} Fund Genesis
549
+ </button>
550
+ ` : null}
551
+ <button class="bt-btn bt-btn-sm" onclick="${checkOnChain}">\u21BB Check Chain</button>
552
+ <button class="bt-btn bt-btn-sm" onclick="${verifyChain}">\u2713 Verify</button>
553
+ <button class="bt-btn bt-btn-sm" onclick="${function() {
554
+ var exp = trail.export()
555
+ copyText(JSON.stringify({ pubkeyBase: exp.pubkeyBase, states: exp.states, witnessPrograms: exp.witnessPrograms }, null, 2))
556
+ }}">\u2398 Export</button>
557
+ <button class="bt-btn bt-btn-sm bt-btn-danger" onclick="${resetTrail}">\u2716 Reset</button>
558
+ </div>
559
+ </h2>
560
+ <div class="bt-row">
561
+ <input class="bt-input bt-advance-input" placeholder="New state\u2026" style="flex:1;margin-bottom:0"
562
+ onkeydown="${function(e) { if (e.key === 'Enter') { if (currentFunded) advanceOnChain(); else advanceTrail() } }}" />
563
+ ${currentFunded ? html`
564
+ <button class="bt-btn bt-btn-green" onclick="${advanceOnChain}" disabled="${advancing}">
565
+ ${advancing ? html`<span class="bt-spinner"></span>` : '\u26A1'} Advance + Broadcast
566
+ </button>
567
+ ` : html`
568
+ <button class="bt-btn bt-btn-primary" onclick="${advanceTrail}">\u2192 Advance</button>
569
+ `}
570
+ </div>
571
+
572
+ ${verifyResult ? html`
573
+ <div class="bt-verify-card">
574
+ <div class="bt-verify-header">
575
+ ${verifyResult.running ? html`
576
+ <span class="bt-spinner"></span> Verifying chain\u2026
577
+ ` : verifyResult.valid ? html`
578
+ <span class="bt-verify-icon bt-verify-pass">\u2713</span>
579
+ <span>Chain Verified</span>
580
+ ` : html`
581
+ <span class="bt-verify-icon bt-verify-failed">\u2716</span>
582
+ <span>Verification Failed</span>
583
+ `}
584
+ </div>
585
+ ${verifyResult.steps && verifyResult.steps.length > 0 ? html`
586
+ <div class="bt-verify-steps">
587
+ ${verifyResult.steps.map(function(step) {
588
+ return html`
589
+ <div class="bt-verify-step">
590
+ <span class="${'bt-verify-dot ' + (step.valid ? 'bt-vdot-pass' : 'bt-vdot-fail')}">
591
+ ${step.valid ? '\u2713' : '\u2716'}
592
+ </span>
593
+ <span class="bt-verify-step-label">
594
+ ${step.index === 0 ? 'Genesis' : 'State ' + step.index}
595
+ </span>
596
+ <span class="bt-verify-step-val">${step.state}</span>
597
+ ${step.error ? html`<span class="bt-verify-step-err">${step.error}</span>` : null}
598
+ </div>
599
+ `
600
+ })}
601
+ ${verifyResult.running && verifyResult.steps.length < states.length ? html`
602
+ <div class="bt-verify-step">
603
+ <span class="bt-spinner" style="width:12px;height:12px"></span>
604
+ <span class="bt-verify-step-label" style="color:rgba(255,255,255,0.3)">
605
+ ${verifyResult.steps.length === 0 ? 'Genesis' : 'State ' + verifyResult.steps.length}
606
+ </span>
607
+ </div>
608
+ ` : null}
609
+ </div>
610
+ ` : null}
611
+ ${!verifyResult.running && verifyResult.valid ? html`
612
+ <div class="bt-verify-summary">
613
+ ${String(states.length)} state${states.length > 1 ? 's' : ''} \u00B7 all witness programs match \u00B7 pubkey ${truncate(exp.pubkeyBase, 6, 4)}
614
+ </div>
615
+ ` : null}
616
+ </div>
617
+ ` : null}
618
+ </div>
619
+
620
+ <div class="bt-card">
621
+ <h2>State Chain</h2>
622
+ <div class="bt-chain">
623
+ ${keyed(states.map(function(s, i) { return { s: s, i: i } }), function(item) { return 'state-' + item.i }, function(item) {
624
+ var s = item.s, i = item.i
625
+ var wp = programs[i] || ''
626
+ var addr = getAddress(i)
627
+ var isGenesis = i === 0
628
+ var isCurrent = i === states.length - 1
629
+ var status = onchainStatus[i]
630
+ var isFunded = status && status.funded
631
+ var isConfirmed = status && status.confirmed
632
+
633
+ return html`
634
+ <div class="${'bt-state' + (isFunded ? ' bt-state-funded' : '')}" style="${isCurrent ? 'border-color:rgba(247,147,26,0.3)' : ''}">
635
+ <div class="bt-state-idx">
636
+ ${isGenesis ? 'Genesis' : 'State ' + i}${isCurrent ? ' (current)' : ''}
637
+ ${isFunded ? html`
638
+ <span class="${'bt-badge ' + (isConfirmed ? 'bt-badge-onchain' : 'bt-badge-pending')}">
639
+ ${isConfirmed ? '\u2713 Confirmed' : '\u23F3 Pending'}
640
+ </span>
641
+ ` : html`<span class="bt-badge bt-badge-offchain">Off-chain</span>`}
642
+ </div>
643
+ <div class="bt-state-val">${s}</div>
644
+
645
+ <div class="bt-state-detail">
646
+ ${addr ? html`
647
+ <span class="bt-state-label">Address</span>
648
+ <span class="bt-state-val2" onclick="${function() { copyText(addr) }}">${truncate(addr, 12, 8)}</span>
649
+ ` : null}
650
+ ${wp ? html`
651
+ <span class="bt-state-label">Witness</span>
652
+ <span class="bt-state-val2" onclick="${function() { copyText(wp) }}">${truncate(wp, 10, 8)}</span>
653
+ ` : null}
654
+ ${status && status.txid ? html`
655
+ <span class="bt-state-label">TXID</span>
656
+ <a class="bt-state-val2" href="${MEMPOOL_BASE + '/tx/' + status.txid}" target="_blank" rel="noopener"
657
+ style="color:rgba(167,139,250,0.8);text-decoration:none">${truncate(status.txid)}</a>
658
+ ` : null}
659
+ </div>
660
+ </div>
661
+ `
662
+ })}
663
+ </div>
664
+ </div>
665
+ `}
666
+ </div>
667
+ `)
668
+ }
669
+
670
+ renderApp()
671
+ onUnmount(container, function() {})
672
+ }
673
+ }