txo_parser 0.0.1 → 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.
- package/LICENSE +13 -17
- package/README.md +36 -2
- package/demo/blocktrail.jsonld +10 -0
- package/demo/index.html +62 -0
- package/demo/ledger-history.jsonld +9 -0
- package/demo/lib/bitcoin.js +378 -0
- package/demo/panes/blocktrails-pane.js +673 -0
- package/demo/panes/builder-pane.js +190 -0
- package/demo/panes/faucet-pane.js +137 -0
- package/demo/panes/ledger-pane.js +662 -0
- package/demo/panes/parser-pane.js +171 -0
- package/demo/panes/spec-pane.js +117 -0
- package/demo/panes/voucher-pane.js +693 -0
- package/demo/voucher-data.jsonld +31 -0
- package/demo/webledger.jsonld +9 -0
- package/index.js +63 -11
- package/package.json +2 -2
|
@@ -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
|
+
}
|