txo_parser
Version:
Parser for TXO URI Specification (v0.2)
674 lines (613 loc) • 33.3 kB
JavaScript
import { html, render, keyed, onUnmount } from 'https://losos.org/losos/html.js'
import { Blocktrail, verify, genesis as btGenesis, transition as btTransition, hexToBytes } from 'https://esm.sh/blocktrails@0.0.11'
import {
parseVoucherFromItem, decodeKey, bytesToHex,
buildTransaction, estimateVsize, fetchUtxos, fetchTxDetails, broadcastTx, getFeeRate, hexToU8,
wpToP2trAddress
} from '../lib/bitcoin.js'
export default {
label: 'Blocktrails',
icon: '\u26D3',
canHandle(subject, store) {
var node = store.get(subject.value)
if (!node) return false
var type = store.type(node)
return type && type.includes('VoucherPool')
},
render(subject, lionStore, container, rawData) {
// ── State ───────────────────────────────────────────
var TRAIL_URL = new URL('blocktrail.jsonld', location.href).href
var MEMPOOL_BASE = 'https://mempool.space/testnet4'
var trail = null
var privkeyHex = ''
var error = null
var verifyResult = null
var onchainStatus = {} // index → { funded, txid, confirmed }
var funding = false
var advancing = false
// Load persisted trail from blocktrail.jsonld
function loadTrailFromData(data) {
try {
var storedPrivkey = data['bt:privkey'] || ''
var stateNodes = data['bt:state'] || []
if (!Array.isArray(stateNodes)) stateNodes = [stateNodes]
var storedStates = stateNodes.map(function(n) {
return typeof n === 'string' ? n : (n['bt:value'] || '')
}).filter(Boolean)
if (storedPrivkey && storedStates.length > 0) {
privkeyHex = storedPrivkey
trail = new Blocktrail(storedPrivkey)
for (var i = 0; i < storedStates.length; i++) {
if (i === 0) trail.genesis(storedStates[i])
else trail.advance(storedStates[i])
}
// Restore on-chain status
for (var j = 0; j < stateNodes.length; j++) {
var n = stateNodes[j]
if (typeof n === 'object' && n['bt:txid']) {
onchainStatus[j] = { funded: true, txid: n['bt:txid'], confirmed: n['bt:confirmed'] || false }
}
}
}
} catch(e) {
console.warn('Failed to load trail:', e)
}
}
// Fetch trail data from server
fetch(TRAIL_URL + '?t=' + Date.now(), { cache: 'no-store' })
.then(function(res) { return res.ok ? res.json() : null })
.then(function(data) {
if (data && data['bt:privkey']) {
loadTrailFromData(data)
renderApp()
}
})
.catch(function(e) { console.warn('Failed to fetch trail:', e) })
// ── Helpers ─────────────────────────────────────────
function toast(msg) {
var t = document.createElement('div')
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'
t.textContent = msg
document.body.appendChild(t)
setTimeout(function() { t.remove() }, 2000)
}
function copyText(text) {
navigator.clipboard.writeText(text).then(function() { toast('Copied') })
}
function truncate(s, start, end) {
start = start || 8; end = end || 6
if (!s || s.length <= start + end + 3) return s
return s.slice(0, start) + '\u2026' + s.slice(-end)
}
// Get P2TR address for state at index (testnet4) from witness program
function getAddress(stateIndex) {
try {
var exp = trail.export()
var wp = exp.witnessPrograms[stateIndex]
if (!wp) return ''
return wpToP2trAddress(wp, true) // true = testnet
} catch(e) { return '' }
}
// Get signing key for state at index
function getSigningKey(stateIndex) {
try {
if (stateIndex === 0) {
var g = btGenesis(hexToBytes(privkeyHex), trail.states[0])
return g ? g.derivedPrivkey : ''
} else {
var prevStates = trail.states.slice(0, stateIndex)
var t = btTransition(hexToBytes(privkeyHex), prevStates, trail.states[stateIndex])
return t ? t.signingPrivkey : ''
}
} catch(e) { return '' }
}
function saveTrail() {
if (!trail || !privkeyHex) return
var exp = trail.export()
var jsonLd = {
'@context': { 'schema': 'https://schema.org/', 'bt': 'https://blocktrails.org/ns/' },
'@id': '#this',
'@type': 'bt:Trail',
'bt:privkey': privkeyHex,
'bt:pubkeyBase': exp.pubkeyBase,
'bt:state': exp.states.map(function(s, i) {
var stateObj = {
'@type': 'bt:State',
'bt:value': s,
'bt:witnessProgram': exp.witnessPrograms[i],
'bt:address': getAddress(i)
}
if (onchainStatus[i]) {
stateObj['bt:txid'] = onchainStatus[i].txid
stateObj['bt:confirmed'] = onchainStatus[i].confirmed
}
return stateObj
})
}
fetch(TRAIL_URL, {
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify(jsonLd, null, 2)
}).catch(function(e) { console.warn('PUT blocktrail failed:', e) })
}
// ── Actions ─────────────────────────────────────────
function initTrail() {
var keyInput = container.querySelector('.bt-key-input')
var stateInput = container.querySelector('.bt-state-input')
var key = keyInput && keyInput.value.trim()
var state = stateInput && stateInput.value.trim()
if (!key || key.length !== 64) { toast('Enter a 64-char hex private key'); return }
if (!state) { toast('Enter genesis state'); return }
try {
privkeyHex = key
trail = new Blocktrail(key)
trail.genesis(state)
error = null
saveTrail()
toast('Trail initialized')
renderApp()
} catch(e) {
error = e.message
renderApp()
}
}
function advanceTrail() {
var input = container.querySelector('.bt-advance-input')
var state = input && input.value.trim()
if (!state) { toast('Enter new state'); return }
if (!trail) { toast('No active trail'); return }
try {
trail.advance(state)
error = null
saveTrail()
toast('State advanced')
renderApp()
} catch(e) {
error = e.message
renderApp()
}
}
// Fund genesis address from a voucher
async function fundGenesis() {
if (!trail || funding) return
funding = true
renderApp()
try {
var genesisAddr = getAddress(0)
if (!genesisAddr) throw new Error('Could not derive genesis address')
// Find a funded voucher from the pool
var poolRes = await fetch(new URL('voucher-data.jsonld', location.href).href + '?t=' + Date.now(), { cache: 'no-store' })
var poolData = poolRes.ok ? await poolRes.json() : rawData
var items = (poolData && poolData['schema:itemListElement']) || []
var voucher = null
for (var item of items) {
var v = parseVoucherFromItem(item)
if (v.privkey && v.status === 'unspent' && v.amount > 1000) { voucher = v; break }
}
if (!voucher) throw new Error('No funded voucher available')
var decoded = await decodeKey(voucher.privkey)
var txDetails = await fetchTxDetails(voucher.txid)
var prevOut = txDetails.vout[voucher.vout]
if (!prevOut) throw new Error('Could not find UTXO')
var scriptPubKey = hexToU8(prevOut.scriptpubkey)
// Build tx: voucher → genesis address
var feeRate = await getFeeRate()
var vsize = estimateVsize(1, 1)
var fee = Math.ceil(vsize * feeRate)
var outputAmount = voucher.amount - fee
if (outputAmount <= 546) throw new Error('Voucher too small for fee')
// Genesis output script (P2TR)
var genesisWp = trail.export().witnessPrograms[0]
var outputScript = new Uint8Array(34)
outputScript[0] = 0x51 // OP_1
outputScript[1] = 0x20 // push 32 bytes
var wpBytes = hexToU8(genesisWp)
outputScript.set(wpBytes, 2)
if (!confirm('Fund genesis with ' + outputAmount.toLocaleString() + ' sats from voucher?\nFee: ' + fee + ' sats (' + feeRate + ' sat/vB)\nTo: ' + genesisAddr)) {
funding = false; renderApp(); return
}
var rawTx = await buildTransaction(
[{ txid: voucher.txid, vout: voucher.vout, amount: voucher.amount, scriptPubKey: scriptPubKey }],
[{ amount: outputAmount, scriptPubKey: outputScript }],
decoded.privkey
)
var newTxid = await broadcastTx(rawTx)
onchainStatus[0] = { funded: true, txid: newTxid, confirmed: false }
saveTrail()
toast('Genesis funded! ' + truncate(newTxid))
} catch(e) {
toast('Fund failed: ' + e.message)
console.error(e)
}
funding = false
renderApp()
}
// Advance on-chain: spend from current state to next
async function advanceOnChain() {
if (!trail || advancing) return
var input = container.querySelector('.bt-advance-input')
var newState = input && input.value.trim()
if (!newState) { toast('Enter new state'); return }
var currentIdx = trail.states.length - 1
if (!onchainStatus[currentIdx] || !onchainStatus[currentIdx].funded) {
toast('Current state not funded on-chain'); return
}
advancing = true
renderApp()
try {
// Get current state's UTXO
var currentAddr = getAddress(currentIdx)
var currentSigningKey = getSigningKey(currentIdx)
if (!currentSigningKey) throw new Error('Could not derive signing key')
var utxos = await fetchUtxos(currentAddr)
if (utxos.length === 0) throw new Error('No UTXO at current address')
var utxo = utxos[0]
// Advance the trail locally first to get new address
trail.advance(newState)
var newIdx = trail.states.length - 1
var newAddr = getAddress(newIdx)
var newWp = trail.export().witnessPrograms[newIdx]
// Build output script
var outputScript = new Uint8Array(34)
outputScript[0] = 0x51
outputScript[1] = 0x20
outputScript.set(hexToU8(newWp), 2)
// Get scriptPubKey for the input
var txDetails = await fetchTxDetails(utxo.txid)
var prevOut = txDetails.vout[utxo.vout]
var inputScript = hexToU8(prevOut.scriptpubkey)
var feeRate = await getFeeRate()
var vsize = estimateVsize(1, 1)
var fee = Math.ceil(vsize * feeRate)
var outputAmount = utxo.value - fee
if (outputAmount <= 546) throw new Error('UTXO too small for fee')
if (!confirm('Advance on-chain: ' + outputAmount.toLocaleString() + ' sats\nFee: ' + fee + ' sats (' + feeRate + ' sat/vB)\nTo: ' + newAddr)) {
// Undo the advance
trail = new Blocktrail(privkeyHex)
for (var i = 0; i < trail.states.length; i++) {
// Replay is not possible this way, need to reconstruct
}
// Simpler: reload
advancing = false; location.reload(); return
}
var rawTx = await buildTransaction(
[{ txid: utxo.txid, vout: utxo.vout, amount: utxo.value, scriptPubKey: inputScript }],
[{ amount: outputAmount, scriptPubKey: outputScript }],
hexToU8(currentSigningKey)
)
var newTxid = await broadcastTx(rawTx)
onchainStatus[newIdx] = { funded: true, txid: newTxid, confirmed: false }
saveTrail()
toast('State advanced on-chain! ' + truncate(newTxid))
} catch(e) {
toast('Advance failed: ' + e.message)
console.error(e)
}
advancing = false
renderApp()
}
// Check on-chain status for all states
async function checkOnChain() {
if (!trail) return
for (var i = 0; i < trail.states.length; i++) {
try {
var addr = getAddress(i)
if (!addr) continue
var utxos = await fetchUtxos(addr)
if (utxos.length > 0) {
onchainStatus[i] = {
funded: true,
txid: utxos[0].txid,
confirmed: utxos[0].status && utxos[0].status.confirmed
}
}
} catch(e) {}
}
saveTrail()
renderApp()
}
var verifying = false
async function verifyChain() {
if (!trail || verifying) return
verifying = true
verifyResult = { running: true, steps: [] }
renderApp()
var exp = trail.export()
// Step through each state with a delay for visual effect
// verify() expects witnessPrograms as Uint8Array[], but export() returns hex strings
var wpBytes = exp.witnessPrograms.map(function(wp) { return hexToU8(wp) })
for (var i = 0; i < exp.states.length; i++) {
await new Promise(function(r) { setTimeout(r, 300) })
try {
var partial = verify(exp.pubkeyBase, exp.states.slice(0, i + 1), wpBytes.slice(0, i + 1))
verifyResult.steps.push({ index: i, valid: partial.valid, state: exp.states[i] })
} catch(e) {
verifyResult.steps.push({ index: i, valid: false, state: exp.states[i], error: e.message })
}
renderApp()
}
await new Promise(function(r) { setTimeout(r, 200) })
var allValid = verifyResult.steps.every(function(s) { return s.valid })
verifyResult.running = false
verifyResult.valid = allValid
verifying = false
renderApp()
}
function resetTrail() {
if (!confirm('Reset blocktrail? This clears all state history.')) return
trail = null
privkeyHex = ''
error = null
verifyResult = null
onchainStatus = {}
var emptyTrail = {
'@context': { 'schema': 'https://schema.org/', 'bt': 'https://blocktrails.org/ns/' },
'@id': '#this', '@type': 'bt:Trail', 'bt:pubkeyBase': '', 'bt:state': []
}
fetch(TRAIL_URL, {
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify(emptyTrail, null, 2)
}).catch(function(e) { console.warn('PUT reset failed:', e) })
renderApp()
}
async function useVoucherKey() {
try {
var poolRes = await fetch(new URL('voucher-data.jsonld', location.href).href + '?t=' + Date.now(), { cache: 'no-store' })
var poolData = poolRes.ok ? await poolRes.json() : rawData
var items = (poolData && poolData['schema:itemListElement']) || []
for (var item of items) {
var v = parseVoucherFromItem(item)
if (v.privkey) {
var keyInput = container.querySelector('.bt-key-input')
if (!keyInput) return
if (v.privkey.length === 64 && /^[0-9a-fA-F]{64}$/.test(v.privkey)) {
keyInput.value = v.privkey
} else {
try {
var decoded = await decodeKey(v.privkey)
keyInput.value = bytesToHex(decoded.privkey)
} catch(e) { keyInput.value = v.privkey }
}
toast('Key loaded from voucher pool')
return
}
}
toast('No keyed vouchers found')
} catch(e) { toast('Could not read voucher pool: ' + e.message) }
}
// ── Render ───────────────────────────────────────────
function renderApp() {
var exp = trail ? trail.export() : null
var states = exp ? exp.states : []
var programs = exp ? exp.witnessPrograms : []
var currentIdx = states.length - 1
var currentFunded = onchainStatus[currentIdx] && onchainStatus[currentIdx].funded
render(container, html`
<style>
.bt-wrap { padding: 0 16px 40px; }
.bt-hero { text-align: center; padding: 40px 0 32px; }
.bt-hero-icon { font-size: 3rem; margin-bottom: 8px; }
.bt-hero-title { font-size: 1.6rem; font-weight: 800; margin-bottom: 4px; }
.bt-hero-sub { font-size: 0.9rem; color: rgba(255,255,255,0.35); }
.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); }
.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; }
.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; }
.bt-input::placeholder { color: rgba(255,255,255,0.25); }
.bt-input:focus { border-color: rgba(247,147,26,0.5); }
.bt-row { display: flex; gap: 8px; }
.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; }
.bt-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
.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); }
.bt-btn-primary:hover { box-shadow: 0 6px 24px rgba(247,147,26,0.3); }
.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); }
.bt-btn-sm { padding: 5px 10px; font-size: 0.78rem; border-radius: 6px; }
.bt-btn-danger { color: #ef4444; }
.bt-btn-danger:hover { background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.3); }
.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; }
.bt-chain { position: relative; padding-left: 24px; }
.bt-chain::before { content: ''; position: absolute; left: 9px; top: 0; bottom: 0; width: 2px; background: rgba(247,147,26,0.2); }
.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; }
.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); }
.bt-state:last-child::before { box-shadow: 0 0 8px rgba(247,147,26,0.4); }
.bt-state-funded::before { background: #10b981; border-color: rgba(16,185,129,0.4); }
.bt-state-funded:last-child::before { box-shadow: 0 0 8px rgba(16,185,129,0.4); }
.bt-state-idx { font-size: 0.7rem; color: rgba(255,255,255,0.3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; }
.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; }
.bt-state-detail { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; font-size: 0.78rem; margin-top: 8px; }
.bt-state-label { color: rgba(255,255,255,0.25); }
.bt-state-val2 { color: rgba(255,255,255,0.5); font-family: 'SF Mono', 'Fira Code', monospace; cursor: pointer; word-break: break-all; }
.bt-state-val2:hover { color: rgba(255,255,255,0.8); }
.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; }
.bt-badge-onchain { background: rgba(16,185,129,0.12); border: 1px solid rgba(16,185,129,0.3); color: #10b981; }
.bt-badge-pending { background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.3); color: #fbbf24; }
.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); }
.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; }
@keyframes bt-slideIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.bt-verify-header { display: flex; align-items: center; gap: 10px; font-size: 1.1rem; font-weight: 700; margin-bottom: 16px; }
.bt-verify-icon { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1rem; }
.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); }
.bt-verify-failed { background: rgba(239,68,68,0.15); color: #ef4444; border: 2px solid rgba(239,68,68,0.4); }
.bt-verify-steps { display: flex; flex-direction: column; gap: 8px; }
.bt-verify-step { display: flex; align-items: center; gap: 10px; font-size: 0.85rem; padding: 6px 0; animation: bt-fadeStep 0.3s ease; }
@keyframes bt-fadeStep { from { opacity: 0; transform: translateX(-8px); } to { opacity: 1; transform: translateX(0); } }
.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; }
.bt-vdot-pass { background: rgba(16,185,129,0.15); color: #10b981; border: 1px solid rgba(16,185,129,0.3); }
.bt-vdot-fail { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); }
.bt-verify-step-label { color: rgba(255,255,255,0.5); font-weight: 600; min-width: 70px; }
.bt-verify-step-val { color: rgba(255,255,255,0.7); font-family: 'SF Mono', 'Fira Code', monospace; }
.bt-verify-step-err { color: #ef4444; font-size: 0.78rem; }
.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; }
.bt-stats { display: flex; gap: 16px; justify-content: center; margin-bottom: 24px; flex-wrap: wrap; }
.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; }
.bt-stat-val { font-size: 1.5rem; font-weight: 700; color: #f7931a; }
.bt-stat-label { font-size: 0.7rem; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
.bt-help { font-size: 0.78rem; color: rgba(255,255,255,0.25); margin-top: 10px; line-height: 1.5; }
.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; }
@media (max-width: 600px) { .bt-row { flex-direction: column; } }
</style>
<div class="bt-wrap">
<div class="bt-hero">
<div class="bt-hero-icon">\u26D3</div>
<div class="bt-hero-title">Blocktrails</div>
<div class="bt-hero-sub">Anchor state to Bitcoin with Nostr-native key chaining</div>
</div>
${error ? html`<div class="bt-error">${error}</div>` : null}
${!trail ? html`
<div class="bt-card">
<h2>Initialize Trail</h2>
<div class="bt-row" style="margin-bottom:10px">
<input class="bt-input bt-key-input" placeholder="Private key (64-char hex)\u2026" style="flex:1;margin-bottom:0" />
<button class="bt-btn bt-btn-sm" onclick="${useVoucherKey}" title="Load key from voucher pool">\uD83D\uDD11 From Pool</button>
</div>
<input class="bt-input bt-state-input" placeholder="Genesis state (e.g. JSON)\u2026"
onkeydown="${function(e) { if (e.key === 'Enter') initTrail() }}" />
<button class="bt-btn bt-btn-primary" onclick="${initTrail}">\u26D3 Create Trail</button>
<div class="bt-help">
Each state produces a unique P2TR address via chained key tweaking.<br/>
The chain of spends is the state history \u2014 immutably ordered by Bitcoin.
</div>
</div>
` : html`
<div class="bt-stats">
<div class="bt-stat">
<div class="bt-stat-val">${String(states.length)}</div>
<div class="bt-stat-label">States</div>
</div>
<div class="bt-stat">
<div class="bt-stat-val">${truncate(exp.pubkeyBase, 6, 4)}</div>
<div class="bt-stat-label">Base Pubkey</div>
</div>
<div class="bt-stat">
<div class="bt-stat-val">${String(Object.keys(onchainStatus).filter(function(k) { return onchainStatus[k].funded }).length)}</div>
<div class="bt-stat-label">On-Chain</div>
</div>
</div>
<div class="bt-card">
<h2>
${currentFunded ? 'Advance On-Chain' : 'Advance State'}
<div style="display:flex;gap:6px;flex-wrap:wrap">
${states.length > 0 && !onchainStatus[0] ? html`
<button class="bt-btn bt-btn-sm bt-btn-green" onclick="${fundGenesis}"
disabled="${funding}">
${funding ? html`<span class="bt-spinner"></span>` : '\u26A1'} Fund Genesis
</button>
` : null}
<button class="bt-btn bt-btn-sm" onclick="${checkOnChain}">\u21BB Check Chain</button>
<button class="bt-btn bt-btn-sm" onclick="${verifyChain}">\u2713 Verify</button>
<button class="bt-btn bt-btn-sm" onclick="${function() {
var exp = trail.export()
copyText(JSON.stringify({ pubkeyBase: exp.pubkeyBase, states: exp.states, witnessPrograms: exp.witnessPrograms }, null, 2))
}}">\u2398 Export</button>
<button class="bt-btn bt-btn-sm bt-btn-danger" onclick="${resetTrail}">\u2716 Reset</button>
</div>
</h2>
<div class="bt-row">
<input class="bt-input bt-advance-input" placeholder="New state\u2026" style="flex:1;margin-bottom:0"
onkeydown="${function(e) { if (e.key === 'Enter') { if (currentFunded) advanceOnChain(); else advanceTrail() } }}" />
${currentFunded ? html`
<button class="bt-btn bt-btn-green" onclick="${advanceOnChain}" disabled="${advancing}">
${advancing ? html`<span class="bt-spinner"></span>` : '\u26A1'} Advance + Broadcast
</button>
` : html`
<button class="bt-btn bt-btn-primary" onclick="${advanceTrail}">\u2192 Advance</button>
`}
</div>
${verifyResult ? html`
<div class="bt-verify-card">
<div class="bt-verify-header">
${verifyResult.running ? html`
<span class="bt-spinner"></span> Verifying chain\u2026
` : verifyResult.valid ? html`
<span class="bt-verify-icon bt-verify-pass">\u2713</span>
<span>Chain Verified</span>
` : html`
<span class="bt-verify-icon bt-verify-failed">\u2716</span>
<span>Verification Failed</span>
`}
</div>
${verifyResult.steps && verifyResult.steps.length > 0 ? html`
<div class="bt-verify-steps">
${verifyResult.steps.map(function(step) {
return html`
<div class="bt-verify-step">
<span class="${'bt-verify-dot ' + (step.valid ? 'bt-vdot-pass' : 'bt-vdot-fail')}">
${step.valid ? '\u2713' : '\u2716'}
</span>
<span class="bt-verify-step-label">
${step.index === 0 ? 'Genesis' : 'State ' + step.index}
</span>
<span class="bt-verify-step-val">${step.state}</span>
${step.error ? html`<span class="bt-verify-step-err">${step.error}</span>` : null}
</div>
`
})}
${verifyResult.running && verifyResult.steps.length < states.length ? html`
<div class="bt-verify-step">
<span class="bt-spinner" style="width:12px;height:12px"></span>
<span class="bt-verify-step-label" style="color:rgba(255,255,255,0.3)">
${verifyResult.steps.length === 0 ? 'Genesis' : 'State ' + verifyResult.steps.length}
</span>
</div>
` : null}
</div>
` : null}
${!verifyResult.running && verifyResult.valid ? html`
<div class="bt-verify-summary">
${String(states.length)} state${states.length > 1 ? 's' : ''} \u00B7 all witness programs match \u00B7 pubkey ${truncate(exp.pubkeyBase, 6, 4)}
</div>
` : null}
</div>
` : null}
</div>
<div class="bt-card">
<h2>State Chain</h2>
<div class="bt-chain">
${keyed(states.map(function(s, i) { return { s: s, i: i } }), function(item) { return 'state-' + item.i }, function(item) {
var s = item.s, i = item.i
var wp = programs[i] || ''
var addr = getAddress(i)
var isGenesis = i === 0
var isCurrent = i === states.length - 1
var status = onchainStatus[i]
var isFunded = status && status.funded
var isConfirmed = status && status.confirmed
return html`
<div class="${'bt-state' + (isFunded ? ' bt-state-funded' : '')}" style="${isCurrent ? 'border-color:rgba(247,147,26,0.3)' : ''}">
<div class="bt-state-idx">
${isGenesis ? 'Genesis' : 'State ' + i}${isCurrent ? ' (current)' : ''}
${isFunded ? html`
<span class="${'bt-badge ' + (isConfirmed ? 'bt-badge-onchain' : 'bt-badge-pending')}">
${isConfirmed ? '\u2713 Confirmed' : '\u23F3 Pending'}
</span>
` : html`<span class="bt-badge bt-badge-offchain">Off-chain</span>`}
</div>
<div class="bt-state-val">${s}</div>
<div class="bt-state-detail">
${addr ? html`
<span class="bt-state-label">Address</span>
<span class="bt-state-val2" onclick="${function() { copyText(addr) }}">${truncate(addr, 12, 8)}</span>
` : null}
${wp ? html`
<span class="bt-state-label">Witness</span>
<span class="bt-state-val2" onclick="${function() { copyText(wp) }}">${truncate(wp, 10, 8)}</span>
` : null}
${status && status.txid ? html`
<span class="bt-state-label">TXID</span>
<a class="bt-state-val2" href="${MEMPOOL_BASE + '/tx/' + status.txid}" target="_blank" rel="noopener"
style="color:rgba(167,139,250,0.8);text-decoration:none">${truncate(status.txid)}</a>
` : null}
</div>
</div>
`
})}
</div>
</div>
`}
</div>
`)
}
renderApp()
onUnmount(container, function() {})
}
}