txo_parser
Version:
Parser for TXO URI Specification (v0.2)
663 lines (592 loc) • 32.9 kB
JavaScript
import { html, render, keyed, onUnmount } from 'https://losos.org/losos/html.js'
import { Blocktrail, verify } from 'https://esm.sh/blocktrails@0.0.11'
import {
decodeKey, bytesToHex, hexToU8, sha256,
buildTransaction, estimateVsize, fetchUtxos, fetchTxDetails, broadcastTx, getFeeRate,
wpToP2trAddress, parseVoucherFromItem, hexToBytes
} from '../lib/bitcoin.js'
export default {
label: 'Ledger',
icon: '\uD83D\uDCCA',
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) {
// ── URLs ────────────────────────────────────────────
var LEDGER_URL = new URL('webledger.jsonld', location.href).href
var TRAIL_URL = new URL('blocktrail.jsonld', location.href).href
var HISTORY_URL = new URL('ledger-history.jsonld', location.href).href
// ── State ───────────────────────────────────────────
var ledger = null
var trail = null
var privkeyHex = ''
var history = []
var saving = false
var error = null
var editingEntry = null // index being edited, or 'new'
// ── Load data ───────────────────────────────────────
var loaded = 0
var totalLoads = 3
function checkReady() {
loaded++
if (loaded >= totalLoads) renderApp()
}
// Load ledger
fetch(LEDGER_URL + '?t=' + Date.now(), { cache: 'no-store' })
.then(function(r) { return r.ok ? r.json() : null })
.then(function(data) {
if (data) ledger = data
else ledger = { '@context': 'https://w3id.org/webledgers', '@id': '#this', type: 'WebLedger', name: 'Voucher Pool Ledger', defaultCurrency: 'satoshi', entries: [] }
checkReady()
})
.catch(function() { ledger = { '@context': 'https://w3id.org/webledgers', '@id': '#this', type: 'WebLedger', name: 'Voucher Pool Ledger', defaultCurrency: 'satoshi', entries: [] }; checkReady() })
// Load trail
fetch(TRAIL_URL + '?t=' + Date.now(), { cache: 'no-store' })
.then(function(r) { return r.ok ? r.json() : null })
.then(function(data) {
if (data && data['bt:privkey']) {
try {
privkeyHex = data['bt:privkey']
trail = new Blocktrail(privkeyHex)
var stateNodes = data['bt:state'] || []
if (!Array.isArray(stateNodes)) stateNodes = [stateNodes]
stateNodes.forEach(function(n) {
var val = typeof n === 'string' ? n : (n['bt:value'] || '')
if (val) {
if (trail.states.length === 0) trail.genesis(val)
else trail.advance(val)
}
})
} catch(e) { console.warn('Trail load error:', e) }
}
checkReady()
})
.catch(function() { checkReady() })
// Load history
fetch(HISTORY_URL + '?t=' + Date.now(), { cache: 'no-store' })
.then(function(r) { return r.ok ? r.json() : null })
.then(function(data) {
if (data && data['bt:snapshots']) {
history = Array.isArray(data['bt:snapshots']) ? data['bt:snapshots'] : [data['bt:snapshots']]
}
checkReady()
})
.catch(function() { checkReady() })
// ── Helpers ─────────────────────────────────────────
function toast(msg) {
var t = document.createElement('div')
t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:rgba(59,130,246,0.9);color:#fff;padding:8px 20px;border-radius:8px;font-size:0.85rem;font-weight:600;z-index:999;animation:v-fade 0.3s'
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)
}
async function hashContent(content) {
var str = typeof content === 'string' ? content : JSON.stringify(content)
var hash = await sha256(str)
return bytesToHex(hash)
}
// ── Ledger operations ───────────────────────────────
function addEntry() {
var urlInput = container.querySelector('.wl-new-url')
var amountInput = container.querySelector('.wl-new-amount')
var url = urlInput && urlInput.value.trim()
var amount = amountInput && amountInput.value.trim()
if (!url || !amount) { toast('Enter URL and amount'); return }
if (!ledger.entries) ledger.entries = []
ledger.entries.push({ type: 'Entry', url: url, amount: amount })
editingEntry = null
renderApp()
}
function removeEntry(index) {
ledger.entries.splice(index, 1)
renderApp()
}
function updateEntry(index) {
var urlInput = container.querySelector('.wl-edit-url')
var amountInput = container.querySelector('.wl-edit-amount')
var url = urlInput && urlInput.value.trim()
var amount = amountInput && amountInput.value.trim()
if (!url || !amount) return
ledger.entries[index] = { type: 'Entry', url: url, amount: amount }
editingEntry = null
renderApp()
}
// ── Verify ───────────────────────────────────────────
var verifyResult = null
var verifying = false
async function verifyLedger() {
if (verifying) return
verifying = true
verifyResult = { running: true }
renderApp()
try {
// 1. Hash current ledger
var currentHash = await hashContent(JSON.stringify(ledger, null, 2))
verifyResult.currentHash = currentHash
// 2. Get latest state from trail
if (!trail || trail.states.length === 0) {
verifyResult = { running: false, status: 'no-trail', message: 'No blocktrail configured. Anchor the ledger first.' }
verifying = false; renderApp(); return
}
var latestState = trail.states[trail.states.length - 1]
verifyResult.anchoredHash = latestState
await new Promise(function(r) { setTimeout(r, 400) })
// 3. Compare
var hashMatch = currentHash === latestState
verifyResult.hashMatch = hashMatch
// 4. Verify the full blocktrail chain
if (hashMatch) {
await new Promise(function(r) { setTimeout(r, 300) })
var exp = trail.export()
var wpBytes = exp.witnessPrograms.map(function(wp) { return hexToU8(wp) })
var chainResult = verify(exp.pubkeyBase, exp.states, wpBytes)
verifyResult.chainValid = chainResult.valid
verifyResult.chainError = chainResult.error
}
// 5. Check if latest state is on-chain
if (hashMatch && verifyResult.chainValid) {
await new Promise(function(r) { setTimeout(r, 300) })
try {
var exp = trail.export()
var latestWp = exp.witnessPrograms[exp.witnessPrograms.length - 1]
var addr = wpToP2trAddress(latestWp, true)
var utxos = await fetchUtxos(addr)
verifyResult.onChain = utxos.length > 0
if (utxos.length > 0) {
verifyResult.txid = utxos[0].txid
verifyResult.confirmed = utxos[0].status && utxos[0].status.confirmed
}
} catch(e) {
verifyResult.onChain = null // couldn't check
}
}
verifyResult.running = false
verifyResult.status = hashMatch && verifyResult.chainValid ? 'valid' : hashMatch ? 'chain-broken' : 'modified'
} catch(e) {
verifyResult = { running: false, status: 'error', message: e.message }
}
verifying = false
renderApp()
}
// ── Save + Anchor ───────────────────────────────────
async function saveLedger(andAnchor) {
if (saving) return
saving = true
error = null
renderApp()
try {
// 1. Save ledger
var ledgerJson = JSON.stringify(ledger, null, 2)
await fetch(LEDGER_URL, {
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: ledgerJson
})
if (!andAnchor) {
toast('Ledger saved')
saving = false
renderApp()
return
}
// 2. Hash the ledger content
var contentHash = await hashContent(ledgerJson)
// 3. Snapshot to history
var snapshot = {
'@type': 'bt:Snapshot',
'bt:contentHash': 'sha256:' + contentHash,
'bt:timestamp': new Date().toISOString(),
'bt:content': JSON.parse(ledgerJson)
}
history.push(snapshot)
await fetch(HISTORY_URL, {
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify({
'@context': { 'schema': 'https://schema.org/', 'bt': 'https://blocktrails.org/ns/' },
'@id': '#this',
'@type': 'bt:History',
'bt:snapshots': history
}, null, 2)
})
// 4. Advance blocktrail with the hash as state
if (!trail || !privkeyHex) {
toast('Saved + snapshot. No blocktrail configured \u2014 go to Blocktrails tab to initialize.')
saving = false
renderApp()
return
}
var stateValue = contentHash
if (trail.states.length === 0) {
trail.genesis(stateValue)
} else {
trail.advance(stateValue)
}
// 5. Try to broadcast on-chain
var exp = trail.export()
var currentIdx = trail.states.length - 1
var prevIdx = currentIdx - 1
var newWp = exp.witnessPrograms[currentIdx]
var newAddr = wpToP2trAddress(newWp, true)
var txid = null
if (prevIdx >= 0) {
// Spend from previous state
var prevAddr = wpToP2trAddress(exp.witnessPrograms[prevIdx], true)
try {
var utxos = await fetchUtxos(prevAddr)
if (utxos.length > 0) {
var utxo = utxos[0]
var prevStates = trail.states.slice(0, currentIdx)
var signingKeyHex = null
if (prevIdx === 0) {
var g = (await import('https://esm.sh/blocktrails@0.0.11')).genesis
signingKeyHex = g(hexToBytes(privkeyHex), trail.states[0]).derivedPrivkey
} else {
var tr = (await import('https://esm.sh/blocktrails@0.0.11')).transition
signingKeyHex = tr(hexToBytes(privkeyHex), trail.states.slice(0, prevIdx), trail.states[prevIdx]).signingPrivkey
}
var txDetails = await fetchTxDetails(utxo.txid)
var prevOut = txDetails.vout[utxo.vout]
var inputScript = hexToU8(prevOut.scriptpubkey)
var outputScript = new Uint8Array(34)
outputScript[0] = 0x51; outputScript[1] = 0x20
outputScript.set(hexToU8(newWp), 2)
var feeRate = await getFeeRate()
var fee = Math.ceil(estimateVsize(1, 1) * feeRate)
var outputAmount = utxo.value - fee
if (outputAmount > 546) {
var rawTx = await buildTransaction(
[{ txid: utxo.txid, vout: utxo.vout, amount: utxo.value, scriptPubKey: inputScript }],
[{ amount: outputAmount, scriptPubKey: outputScript }],
hexToU8(signingKeyHex)
)
txid = await broadcastTx(rawTx)
}
}
} catch(e) {
console.warn('On-chain anchor failed (trail still saved):', e)
}
}
// 6. Save trail
var trailJsonLd = {
'@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': wpToP2trAddress(exp.witnessPrograms[i], true),
'bt:source': 'webledger.jsonld',
'bt:contentHash': 'sha256:' + s,
'bt:timestamp': i === currentIdx ? new Date().toISOString() : undefined
}
if (i === currentIdx && txid) {
stateObj['bt:txid'] = txid
stateObj['bt:confirmed'] = false
}
return stateObj
})
}
await fetch(TRAIL_URL, {
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify(trailJsonLd, null, 2)
})
snapshot['bt:txid'] = txid || null
// Update history with txid
await fetch(HISTORY_URL, {
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify({
'@context': { 'schema': 'https://schema.org/', 'bt': 'https://blocktrails.org/ns/' },
'@id': '#this',
'@type': 'bt:History',
'bt:snapshots': history
}, null, 2)
})
toast(txid ? 'Saved + anchored on Bitcoin! ' + truncate(txid) : 'Saved + trail advanced (off-chain)')
} catch(e) {
error = e.message
toast('Save failed: ' + e.message)
console.error(e)
}
saving = false
renderApp()
}
// ── Render ───────────────────────────────────────────
function renderApp() {
if (!ledger) return
var entries = ledger.entries || []
var totalBalance = entries.reduce(function(s, e) {
var amt = typeof e.amount === 'string' ? parseInt(e.amount) || 0 : 0
return s + amt
}, 0)
var hasTrail = trail && trail.states.length > 0
var stateCount = trail ? trail.states.length : 0
render(container, html`
<style>
.wl-wrap { padding: 0 16px 40px; }
.wl-hero { text-align: center; padding: 40px 0 32px; }
.wl-hero-icon { font-size: 3rem; margin-bottom: 8px; }
.wl-hero-title { font-size: 1.6rem; font-weight: 800; margin-bottom: 4px; }
.wl-hero-sub { font-size: 0.9rem; color: rgba(255,255,255,0.35); }
.wl-stats { display: flex; gap: 16px; justify-content: center; margin-bottom: 24px; flex-wrap: wrap; }
.wl-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; }
.wl-stat-val { font-size: 1.5rem; font-weight: 700; color: #3b82f6; }
.wl-stat-label { font-size: 0.7rem; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
.wl-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); }
.wl-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; }
.wl-input { 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; }
.wl-input::placeholder { color: rgba(255,255,255,0.25); }
.wl-input:focus { border-color: rgba(59,130,246,0.5); }
.wl-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; }
.wl-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
.wl-btn-primary { background: linear-gradient(135deg, #3b82f6, #2563eb); border-color: rgba(59,130,246,0.4); color: #fff; box-shadow: 0 4px 16px rgba(59,130,246,0.2); }
.wl-btn-primary:hover { box-shadow: 0 6px 24px rgba(59,130,246,0.3); }
.wl-btn-anchor { 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); }
.wl-btn-anchor:hover { box-shadow: 0 6px 24px rgba(247,147,26,0.3); }
.wl-btn-sm { padding: 5px 10px; font-size: 0.78rem; border-radius: 6px; }
.wl-btn-danger { color: #ef4444; }
.wl-btn-danger:hover { background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.3); }
.wl-entry { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; margin-bottom: 8px; transition: background 0.15s; }
.wl-entry:hover { background: rgba(255,255,255,0.06); }
.wl-entry-url { flex: 1; font-size: 0.88rem; color: rgba(255,255,255,0.7); font-family: 'SF Mono', 'Fira Code', monospace; word-break: break-all; cursor: pointer; }
.wl-entry-url:hover { color: rgba(255,255,255,0.9); }
.wl-entry-amount { font-size: 1.1rem; font-weight: 700; color: #3b82f6; min-width: 80px; text-align: right; }
.wl-entry-amount small { font-size: 0.65em; color: rgba(255,255,255,0.3); font-weight: 400; }
.wl-entry-actions { display: flex; gap: 4px; }
.wl-add-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.wl-empty { text-align: center; padding: 32px; color: rgba(255,255,255,0.25); font-style: italic; }
.wl-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; }
.wl-history-item { padding: 10px 14px; background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04); border-radius: 8px; margin-bottom: 6px; font-size: 0.82rem; }
.wl-history-hash { color: rgba(59,130,246,0.6); font-family: 'SF Mono', monospace; font-size: 0.75rem; }
.wl-history-time { color: rgba(255,255,255,0.25); font-size: 0.72rem; }
.wl-history-entries { color: rgba(255,255,255,0.4); font-size: 0.75rem; margin-top: 4px; }
.wl-spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.15); border-top-color: #3b82f6; border-radius: 50%; animation: v-spin 0.6s linear infinite; display: inline-block; }
.wl-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; }
@keyframes bt-slideIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.wl-verify-header { display: flex; align-items: center; gap: 10px; font-size: 1.1rem; font-weight: 700; margin-bottom: 14px; }
.wl-verify-icon { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1rem; }
.wl-vi-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); }
.wl-vi-fail { background: rgba(239,68,68,0.15); color: #ef4444; border: 2px solid rgba(239,68,68,0.4); }
.wl-vi-warn { background: rgba(251,191,36,0.15); color: #fbbf24; border: 2px solid rgba(251,191,36,0.4); }
.wl-vi-info { background: rgba(59,130,246,0.15); color: #3b82f6; border: 2px solid rgba(59,130,246,0.4); }
.wl-verify-steps { display: flex; flex-direction: column; gap: 8px; }
.wl-verify-step { display: flex; align-items: center; gap: 10px; font-size: 0.85rem; }
.wl-vdot { 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; }
.wl-vdot-pass { background: rgba(16,185,129,0.15); color: #10b981; border: 1px solid rgba(16,185,129,0.3); }
.wl-vdot-fail { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); }
.wl-verify-label { color: rgba(255,255,255,0.4); font-weight: 600; min-width: 110px; }
.wl-verify-val { color: rgba(255,255,255,0.6); font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.82rem; }
.wl-save-bar { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
@media (max-width: 600px) { .wl-add-row { flex-direction: column; } .wl-entry { flex-wrap: wrap; } }
</style>
<div class="wl-wrap">
<div class="wl-hero">
<div class="wl-hero-icon">\uD83D\uDCCA</div>
<div class="wl-hero-title">${ledger.name || 'Web Ledger'}</div>
<div class="wl-hero-sub">${ledger.description || 'URI-to-balance mappings anchored to Bitcoin'}</div>
</div>
${error ? html`<div class="wl-error">${error}</div>` : null}
<div class="wl-stats">
<div class="wl-stat">
<div class="wl-stat-val">${String(entries.length)}</div>
<div class="wl-stat-label">Entries</div>
</div>
<div class="wl-stat">
<div class="wl-stat-val">${totalBalance.toLocaleString()}</div>
<div class="wl-stat-label">${ledger.defaultCurrency || 'units'}</div>
</div>
<div class="wl-stat">
<div class="wl-stat-val">${String(stateCount)}</div>
<div class="wl-stat-label">Anchored</div>
</div>
<div class="wl-stat">
<div class="wl-stat-val">${String(history.length)}</div>
<div class="wl-stat-label">Snapshots</div>
</div>
</div>
<div class="wl-card">
<h2>
Entries
<button class="wl-btn wl-btn-sm" onclick="${function() { editingEntry = editingEntry === 'new' ? null : 'new'; renderApp() }}">
${editingEntry === 'new' ? '\u2716 Cancel' : '+ Add Entry'}
</button>
</h2>
${editingEntry === 'new' ? html`
<div class="wl-add-row" style="margin-bottom:12px">
<input class="wl-input wl-new-url" placeholder="did:nostr:pubkey or any URI" style="flex:2"
onkeydown="${function(e) { if (e.key === 'Enter') addEntry() }}" />
<input class="wl-input wl-new-amount" placeholder="Amount" style="flex:0.5;min-width:100px"
onkeydown="${function(e) { if (e.key === 'Enter') addEntry() }}" />
<button class="wl-btn wl-btn-primary wl-btn-sm" onclick="${addEntry}">Add</button>
</div>
` : null}
${entries.length === 0 ? html`
<div class="wl-empty">No entries. Add a URI and balance to get started.</div>
` : null}
${entries.map(function(entry, i) {
var amountStr = typeof entry.amount === 'string' ? entry.amount : JSON.stringify(entry.amount)
if (editingEntry === i) {
return html`
<div class="wl-add-row" style="margin-bottom:8px">
<input class="wl-input wl-edit-url" value="${entry.url}" style="flex:2" />
<input class="wl-input wl-edit-amount" value="${amountStr}" style="flex:0.5;min-width:100px" />
<button class="wl-btn wl-btn-primary wl-btn-sm" onclick="${function() { updateEntry(i) }}">\u2713</button>
<button class="wl-btn wl-btn-sm" onclick="${function() { editingEntry = null; renderApp() }}">\u2716</button>
</div>
`
}
return html`
<div class="wl-entry">
<div class="wl-entry-url" onclick="${function() { copyText(entry.url) }}" title="${entry.url}">
${truncate(entry.url, 20, 12)}
</div>
<div class="wl-entry-amount">
${parseInt(amountStr).toLocaleString()} ${html`<small>${ledger.defaultCurrency || ''}</small>`}
</div>
<div class="wl-entry-actions">
<button class="wl-btn wl-btn-sm" onclick="${function() { editingEntry = i; renderApp() }}">\u270E</button>
<button class="wl-btn wl-btn-sm wl-btn-danger" onclick="${function() { removeEntry(i); }}">\u2716</button>
</div>
</div>
`
})}
<div class="wl-save-bar">
<button class="wl-btn" onclick="${verifyLedger}" disabled="${verifying}">
${verifying ? html`<span class="wl-spinner"></span>` : '\u2713'} Verify
</button>
<button class="wl-btn wl-btn-primary" onclick="${function() { saveLedger(false) }}" disabled="${saving}">
${saving ? html`<span class="wl-spinner"></span>` : '\uD83D\uDCBE'} Save
</button>
<button class="wl-btn wl-btn-anchor" onclick="${function() { saveLedger(true) }}" disabled="${saving || !privkeyHex}">
${saving ? html`<span class="wl-spinner"></span>` : '\u26D3'} Save + Anchor to Bitcoin
</button>
</div>
${verifyResult && !verifyResult.running ? html`
<div class="wl-verify-card" style="animation: bt-slideIn 0.3s ease">
<div class="wl-verify-header">
${verifyResult.status === 'valid' ? html`
<span class="wl-verify-icon wl-vi-pass">\u2713</span>
<span>Ledger Verified</span>
` : verifyResult.status === 'modified' ? html`
<span class="wl-verify-icon wl-vi-warn">\u26A0</span>
<span>Ledger Modified</span>
` : verifyResult.status === 'no-trail' ? html`
<span class="wl-verify-icon wl-vi-info">\u2139</span>
<span>Not Anchored</span>
` : html`
<span class="wl-verify-icon wl-vi-fail">\u2716</span>
<span>${verifyResult.message || 'Verification Failed'}</span>
`}
</div>
${verifyResult.currentHash ? html`
<div class="wl-verify-steps">
<div class="wl-verify-step">
<span class="${'wl-vdot ' + (verifyResult.hashMatch ? 'wl-vdot-pass' : 'wl-vdot-fail')}">
${verifyResult.hashMatch ? '\u2713' : '\u2716'}
</span>
<span class="wl-verify-label">Content hash</span>
<span class="wl-verify-val" onclick="${function() { copyText(verifyResult.currentHash) }}" style="cursor:pointer">
${truncate('sha256:' + verifyResult.currentHash, 14, 8)}
</span>
</div>
${verifyResult.hashMatch === false ? html`
<div class="wl-verify-step">
<span class="wl-vdot wl-vdot-fail">\u2716</span>
<span class="wl-verify-label">Anchored hash</span>
<span class="wl-verify-val" style="color:#ef4444">${truncate('sha256:' + verifyResult.anchoredHash, 14, 8)}</span>
</div>
` : null}
${verifyResult.chainValid !== undefined ? html`
<div class="wl-verify-step">
<span class="${'wl-vdot ' + (verifyResult.chainValid ? 'wl-vdot-pass' : 'wl-vdot-fail')}">
${verifyResult.chainValid ? '\u2713' : '\u2716'}
</span>
<span class="wl-verify-label">Blocktrail chain</span>
<span class="wl-verify-val">${verifyResult.chainValid ? 'All witness programs match' : verifyResult.chainError || 'Invalid'}</span>
</div>
` : null}
${verifyResult.onChain !== undefined && verifyResult.onChain !== null ? html`
<div class="wl-verify-step">
<span class="${'wl-vdot ' + (verifyResult.onChain ? 'wl-vdot-pass' : 'wl-vdot-fail')}">
${verifyResult.onChain ? '\u2713' : '\u2716'}
</span>
<span class="wl-verify-label">Bitcoin</span>
<span class="wl-verify-val">
${verifyResult.confirmed ? 'Confirmed' : verifyResult.onChain ? 'Pending' : 'Not found'}
${verifyResult.txid ? html` \u00B7 <a href="${'https://mempool.space/testnet4/tx/' + verifyResult.txid}" target="_blank" rel="noopener" style="color:rgba(247,147,26,0.6);text-decoration:none">${truncate(verifyResult.txid, 6, 4)}</a>` : null}
</span>
</div>
` : null}
</div>
` : null}
${verifyResult.status === 'modified' ? html`
<div style="margin-top:12px;font-size:0.78rem;color:rgba(255,255,255,0.3)">
The ledger has been modified since last anchor. Click "Save + Anchor to Bitcoin" to record the new state.
</div>
` : null}
</div>
` : verifyResult && verifyResult.running ? html`
<div class="wl-verify-card" style="animation: bt-slideIn 0.3s ease">
<div class="wl-verify-header">
<span class="wl-spinner"></span>
<span>Verifying\u2026</span>
</div>
</div>
` : null}
</div>
${history.length > 0 ? html`
<div class="wl-card">
<h2>
History
<span style="font-size:0.75rem;color:rgba(255,255,255,0.25);font-weight:400">${String(history.length)} snapshot${history.length > 1 ? 's' : ''}</span>
</h2>
${history.slice().reverse().map(function(snap) {
var hash = snap['bt:contentHash'] || ''
var time = snap['bt:timestamp'] || ''
var txid = snap['bt:txid'] || ''
var content = snap['bt:content']
var entryCount = content && content.entries ? content.entries.length : '?'
var totalAmt = 0
if (content && content.entries) {
content.entries.forEach(function(e) { totalAmt += parseInt(e.amount) || 0 })
}
return html`
<div class="wl-history-item">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:4px">
<span class="wl-history-hash" onclick="${function() { copyText(hash) }}" style="cursor:pointer" title="Click to copy hash">
${truncate(hash, 12, 8)}
</span>
<span class="wl-history-time">${time ? new Date(time).toLocaleString() : ''}</span>
</div>
<div class="wl-history-entries">
${String(entryCount)} entries \u00B7 ${totalAmt.toLocaleString()} total
${txid ? html` \u00B7 <a href="${'https://mempool.space/testnet4/tx/' + txid}" target="_blank" rel="noopener" style="color:rgba(247,147,26,0.6);text-decoration:none">${truncate(txid, 6, 4)}</a>` : null}
</div>
</div>
`
})}
</div>
` : null}
</div>
`)
}
renderApp()
onUnmount(container, function() {})
}
}