txo_parser
Version:
Parser for TXO URI Specification (v0.2)
694 lines (623 loc) • 32.3 kB
JavaScript
import { html, render, keyed, onUnmount } from 'https://losos.org/losos/html.js'
import {
decodeKey, privkeyToAddress, isHexKey,
buildTransaction, estimateVsize,
fetchUtxos, checkOutspend, fetchTxDetails, broadcastTx, getFeeRate,
bytesToHex, hexToU8,
parseTxoUri, isValidTxoUri,
toSats, buildTxoUri, parseVoucherFromItem
} from '../lib/bitcoin.js'
export default {
label: 'Vouchers',
icon: '\uD83C\uDFAB',
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) {
// ── Load state ──────────────────────────────────────
var DATA_URL = new URL('voucher-data.jsonld', location.href).href
var data = rawData || {}
var items = data['schema:itemListElement'] || []
var vouchers = items.map(parseVoucherFromItem)
var importing = false
var refreshing = false
var mergeMode = false
var mergeSelected = new Set()
var splitTarget = null
var addKeyTarget = null
var revealedKeys = new Set()
// ── Persistence ─────────────────────────────────────
function saveToStorage() {
var jsonLd = {
'@context': { 'schema': 'https://schema.org/', 'bt': 'https://blocktrails.org/ns/' },
'@id': '#this',
'@type': 'VoucherPool',
'schema:name': 'Voucher Pool',
'schema:description': 'Testnet4 voucher management and faucet',
'schema:hasPart': [
{ '@id': 'blocktrail.jsonld#this' },
{ '@id': 'webledger.jsonld#this' },
{ '@id': 'ledger-history.jsonld#this' }
],
'schema:itemListElement': vouchers.map(function(v) {
return {
'@type': 'schema:ListItem',
'@id': v.id,
'schema:identifier': buildTxoUri(v),
'schema:address': v.address,
'schema:status': v.status,
'schema:dateCreated': v.dateAdded
}
})
}
// PUT to Solid server
fetch(DATA_URL, {
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify(jsonLd, null, 2)
}).catch(function(e) { console.warn('PUT failed:', e) })
}
// ── Helpers ─────────────────────────────────────────
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)
}
function maskKey(wif) {
if (!wif || wif.length < 6) return '***'
return wif.slice(0, 3) + '\u2026' + wif.slice(-3)
}
function satsBtc(sats) { return (sats / 1e8).toFixed(8) }
function toast(msg) {
var t = document.createElement('div')
t.className = 'v-toast'
t.textContent = msg
document.body.appendChild(t)
setTimeout(function() { t.remove() }, 2000)
}
function copyText(text) {
navigator.clipboard.writeText(text).then(function() { toast('Copied') })
}
// ── Import ──────────────────────────────────────────
async function importKey(input) {
input = input.trim()
if (!input) return
if (input.startsWith('txo:') || isValidTxoUri(input)) {
try {
var parsed = parseTxoUri(input)
var exists = vouchers.some(function(v) { return v.txid === parsed.txid && v.vout === parsed.output })
if (exists) { toast('Already in pool'); return }
var v = {
id: Date.now().toString(36),
txid: parsed.txid, vout: parsed.output,
amount: toSats(parsed.amount),
privkey: parsed.privkey || parsed.key || '',
address: '', network: 'btc',
status: 'unknown',
dateAdded: new Date().toISOString()
}
if (v.privkey) {
try {
var decoded = await decodeKey(v.privkey)
v.address = privkeyToAddress(decoded.privkey, decoded.testnet)
} catch {}
}
vouchers.unshift(v)
saveToStorage()
toast('Voucher imported')
renderApp()
refreshStatus(vouchers.indexOf(v))
return
} catch (e) {
console.warn('TXO parse failed, trying as WIF:', e)
}
}
try {
var decoded = await decodeKey(input)
var address = privkeyToAddress(decoded.privkey, decoded.testnet)
var keyless = vouchers.filter(function(v) { return !v.privkey })
var matched = false
if (keyless.length > 0) {
var utxos = await fetchUtxos(address)
for (var kv of keyless) {
var matchesUtxo = utxos.some(function(u) { return u.txid === kv.txid && u.vout === kv.vout })
if (matchesUtxo) {
kv.privkey = input
kv.address = address
kv.status = 'unspent'
var u = utxos.find(function(u) { return u.txid === kv.txid && u.vout === kv.vout })
if (u && u.value) kv.amount = u.value
matched = true
}
}
if (matched) {
saveToStorage()
toast('Key matched to voucher')
renderApp()
return
}
}
toast('Looking up UTXOs\u2026')
var utxos = await fetchUtxos(address)
if (utxos.length === 0) { toast('No UTXOs found'); return }
var added = 0
for (var ux of utxos) {
var exists = vouchers.some(function(v) { return v.txid === ux.txid && v.vout === ux.vout })
if (exists) continue
vouchers.unshift({
id: Date.now().toString(36) + added,
txid: ux.txid, vout: ux.vout,
amount: ux.value,
privkey: input, address: address,
network: 'btc',
status: ux.status && ux.status.confirmed ? 'unspent' : 'unconfirmed',
dateAdded: new Date().toISOString()
})
added++
}
if (added === 0) toast('All UTXOs already in pool')
else { saveToStorage(); toast('Imported ' + added + ' voucher' + (added > 1 ? 's' : '')) }
renderApp()
} catch (e) {
toast('Import failed: ' + e.message)
console.error(e)
}
}
// ── Refresh status ──────────────────────────────────
async function refreshStatus(index) {
if (index !== undefined) {
var v = vouchers[index]
if (!v) return
var result = await checkOutspend(v.txid, v.vout)
if (result) {
var newStatus = result.spent ? 'spent' : 'unspent'
if (v.status !== newStatus) {
v.status = newStatus
saveToStorage()
}
renderApp()
}
return
}
refreshing = true
renderApp()
var changed = false
for (var i = 0; i < vouchers.length; i++) {
try {
var result = await checkOutspend(vouchers[i].txid, vouchers[i].vout)
if (result) {
var newStatus = result.spent ? 'spent' : 'unspent'
if (vouchers[i].status !== newStatus) {
vouchers[i].status = newStatus
changed = true
}
}
} catch {}
}
if (changed) saveToStorage()
refreshing = false
renderApp()
}
// ── Split ───────────────────────────────────────────
async function confirmSplit(voucherId) {
var v = vouchers.find(function(x) { return x.id === voucherId })
if (!v || !v.privkey || v.status !== 'unspent') {
toast('Voucher must be unspent with a key'); return
}
var countEl = container.querySelector('.v-split-input')
var numSplits = parseInt(countEl && countEl.value) || 2
if (numSplits < 2 || numSplits > 100) { toast('Split into 2\u2013100 outputs'); return }
var decoded = await decodeKey(v.privkey)
var txDetails = await fetchTxDetails(v.txid)
var prevOut = txDetails.vout[v.vout]
if (!prevOut) { toast('Could not find output'); return }
var scriptPubKey = hexToU8(prevOut.scriptpubkey)
var feeRate = await getFeeRate()
var vsize = estimateVsize(1, numSplits)
var fee = Math.ceil(vsize * feeRate)
var available = v.amount - fee
if (available <= 0) { toast('Not enough for fee'); return }
var perOutput = Math.floor(available / numSplits)
if (perOutput <= 546) { toast('Split amounts too small (dust)'); return }
if (!confirm('Split ' + v.amount.toLocaleString() + ' sats into ' + numSplits + ' outputs of ~' + perOutput.toLocaleString() + ' sats?\nFee: ' + fee + ' sats (' + feeRate + ' sat/vB)')) return
toast('Building transaction\u2026')
var outputs = []
var remaining = available
for (var i = 0; i < numSplits; i++) {
var amt = i === numSplits - 1 ? remaining : perOutput
outputs.push({ amount: amt, scriptPubKey: scriptPubKey })
remaining -= amt
}
var rawTx = await buildTransaction(
[{ txid: v.txid, vout: v.vout, amount: v.amount, scriptPubKey: scriptPubKey }],
outputs, decoded.privkey
)
var newTxid = await broadcastTx(rawTx)
toast('Split broadcast! ' + truncate(newTxid))
v.status = 'spent'
for (var i = 0; i < outputs.length; i++) {
vouchers.unshift({
id: Date.now().toString(36) + i,
txid: newTxid, vout: i,
amount: outputs[i].amount,
privkey: v.privkey, address: v.address,
network: 'btc', status: 'unspent',
dateAdded: new Date().toISOString()
})
}
splitTarget = null
saveToStorage()
renderApp()
}
// ── Merge ───────────────────────────────────────────
async function mergeVouchers() {
var selected = []
mergeSelected.forEach(function(id) {
var v = vouchers.find(function(x) { return x.id === id })
if (v) selected.push(v)
})
if (selected.length < 2) { toast('Select at least 2'); return }
if (selected.some(function(v) { return !v.privkey || v.status !== 'unspent' })) {
toast('All must be unspent with keys'); return
}
var key = selected[0].privkey
if (!selected.every(function(v) { return v.privkey === key })) {
toast('All must use the same key'); return
}
var decoded = await decodeKey(key)
var inputs = []
for (var sv of selected) {
var details = await fetchTxDetails(sv.txid)
var out = details.vout[sv.vout]
if (!out) { toast('Could not find output for ' + truncate(sv.txid)); return }
inputs.push({ txid: sv.txid, vout: sv.vout, amount: sv.amount, scriptPubKey: hexToU8(out.scriptpubkey) })
}
var feeRate = await getFeeRate()
var vsize = estimateVsize(selected.length, 1)
var fee = Math.ceil(vsize * feeRate)
var totalInput = selected.reduce(function(s, v) { return s + v.amount }, 0)
var outputAmount = totalInput - fee
if (outputAmount <= 546) { toast('Not enough for fee'); return }
if (!confirm('Merge ' + selected.length + ' vouchers (' + totalInput.toLocaleString() + ' sats) into one?\nOutput: ' + outputAmount.toLocaleString() + ' sats\nFee: ' + fee + ' sats (' + feeRate + ' sat/vB)')) return
toast('Building transaction\u2026')
var rawTx = await buildTransaction(inputs, [{ amount: outputAmount, scriptPubKey: inputs[0].scriptPubKey }], decoded.privkey)
var newTxid = await broadcastTx(rawTx)
toast('Merge broadcast! ' + truncate(newTxid))
for (var sv of selected) sv.status = 'spent'
vouchers.unshift({
id: Date.now().toString(36),
txid: newTxid, vout: 0,
amount: outputAmount,
privkey: key, address: selected[0].address,
network: 'btc', status: 'unspent',
dateAdded: new Date().toISOString()
})
mergeSelected.clear()
mergeMode = false
saveToStorage()
renderApp()
}
// ── Add key to voucher ──────────────────────────────
async function submitKey(voucherId) {
var input = container.querySelector('.v-key-input')
var wif = input && input.value.trim()
if (!wif) { toast('Paste a key'); return }
var v = vouchers.find(function(x) { return x.id === voucherId })
if (!v) return
try {
var decoded = await decodeKey(wif)
var address = privkeyToAddress(decoded.privkey, decoded.testnet)
var utxos = await fetchUtxos(address)
var match = utxos.find(function(u) { return u.txid === v.txid && u.vout === v.vout })
if (!match) { toast('Key does not match this UTXO'); return }
v.privkey = wif
v.address = address
v.status = 'unspent'
if (match.value) v.amount = match.value
addKeyTarget = null
saveToStorage()
toast('Key added!')
renderApp()
} catch (e) {
toast('Invalid key: ' + e.message)
}
}
// ── Render ───────────────────────────────────────────
function renderApp() {
var unspent = vouchers.filter(function(v) { return v.status === 'unspent' })
var totalSats = unspent.reduce(function(s, v) { return s + (v.amount || 0) }, 0)
var spentCount = vouchers.filter(function(v) { return v.status === 'spent' }).length
var canMerge = vouchers.filter(function(v) { return v.privkey && v.status === 'unspent' }).length >= 2
var mergeSats = 0
mergeSelected.forEach(function(id) {
var v = vouchers.find(function(x) { return x.id === id })
if (v) mergeSats += v.amount || 0
})
render(container, html`
<style>
.v-wrap { padding: 0 16px 40px; }
.v-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.v-title { font-size: 1.5em; font-weight: 800; display: flex; align-items: center; gap: 10px; }
.v-net { font-size: 0.6em; padding: 4px 10px; border-radius: 6px; background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.3); color: #fbbf24; font-weight: 600; letter-spacing: 0.04em; }
.v-hero { text-align: center; padding: 32px 0 28px; }
.v-hero-label { font-size: 0.78rem; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px; }
.v-hero-sats { font-size: 2.8rem; font-weight: 800; letter-spacing: -0.02em; }
.v-hero-btc { font-size: 0.95rem; color: rgba(255,255,255,0.35); margin-top: 2px; }
.v-card { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 24px; margin-bottom: 20px; backdrop-filter: blur(12px); }
.v-card h2 { font-size: 1rem; font-weight: 600; color: rgba(255,255,255,0.9); margin: 0 0 16px; display: flex; align-items: center; justify-content: space-between; }
.v-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
.v-stat { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; padding: 14px; text-align: center; }
.v-stat-val { font-size: 1.5rem; font-weight: 700; }
.v-stat-label { font-size: 0.7rem; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
.v-import-row { display: flex; gap: 8px; }
.v-input { flex: 1; padding: 10px 14px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.9); font: 0.88rem -apple-system, sans-serif; outline: none; transition: border-color 0.15s; }
.v-input::placeholder { color: rgba(255,255,255,0.25); }
.v-input:focus { border-color: rgba(124,58,237,0.5); }
.v-btn { display: inline-flex; align-items: center; gap: 6px; padding: 10px 20px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.8); font: 600 0.85rem -apple-system, sans-serif; cursor: pointer; transition: all 0.15s; }
.v-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.15); color: #fff; }
.v-btn-primary { background: linear-gradient(135deg, #7c3aed, #6d28d9); border-color: rgba(124,58,237,0.4); color: #fff; box-shadow: 0 4px 16px rgba(124,58,237,0.2); }
.v-btn-primary:hover { box-shadow: 0 6px 24px rgba(124,58,237,0.3); }
.v-btn-sm { padding: 5px 10px; font-size: 0.78rem; border-radius: 6px; }
.v-btn-icon { padding: 5px 8px; font-size: 0.82rem; border-radius: 6px; min-width: 30px; justify-content: center; }
.v-btn-danger { color: #ef4444; }
.v-btn-danger:hover { background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.3); }
.v-item { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; padding: 14px 16px; margin-bottom: 10px; transition: background 0.15s; }
.v-item:hover { background: rgba(255,255,255,0.06); }
.v-item-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.v-item-amount { font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.v-item-amount small { font-size: 0.7em; font-weight: 400; color: rgba(255,255,255,0.35); }
.v-item-actions { display: flex; gap: 4px; flex-wrap: wrap; }
.v-item-details { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 0.82rem; }
.v-item-label { color: rgba(255,255,255,0.3); }
.v-item-val { color: rgba(255,255,255,0.6); font-family: 'SF Mono', 'Fira Code', monospace; word-break: break-all; cursor: pointer; }
.v-item-val:hover { color: rgba(255,255,255,0.9); }
.v-badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 6px; font-size: 0.72rem; font-weight: 600; }
.v-badge-unspent { background: rgba(16,185,129,0.12); border: 1px solid rgba(16,185,129,0.3); color: #10b981; }
.v-badge-spent { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: #ef4444; }
.v-badge-unknown { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); color: rgba(255,255,255,0.35); }
.v-badge-locked { background: rgba(251,191,36,0.1); border: 1px solid rgba(251,191,36,0.3); color: #fbbf24; }
.v-badge-dot { width: 6px; height: 6px; border-radius: 50%; }
.v-empty { text-align: center; padding: 32px; color: rgba(255,255,255,0.25); font-style: italic; }
.v-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: rgba(16,185,129,0.9); color: #fff; padding: 8px 20px; border-radius: 8px; font-size: 0.85rem; font-weight: 600; z-index: 999; animation: v-fade 0.3s; }
@keyframes v-fade { from { opacity: 0; transform: translateX(-50%) translateY(8px); } }
.v-spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.15); border-top-color: #a78bfa; border-radius: 50%; animation: v-spin 0.6s linear infinite; display: inline-block; }
@keyframes v-spin { to { transform: rotate(360deg); } }
.v-help { font-size: 0.78rem; color: rgba(255,255,255,0.25); margin-top: 10px; line-height: 1.5; }
.v-help code { background: rgba(255,255,255,0.06); padding: 1px 5px; border-radius: 4px; font-size: 0.92em; }
.v-merge-bar { margin-top: 12px; padding: 14px; background: rgba(124,58,237,0.1); border: 1px solid rgba(124,58,237,0.3); border-radius: 10px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.v-expand { margin-top: 10px; }
@media (max-width: 600px) {
.v-stats { grid-template-columns: 1fr; }
.v-hero-sats { font-size: 2rem; }
.v-import-row { flex-direction: column; }
}
</style>
<div class="v-wrap">
<div class="v-header">
<div class="v-title">\uD83C\uDFAB Voucher Pool <span class="v-net">TESTNET4</span></div>
</div>
<div class="v-hero">
<div class="v-hero-label">Available Balance</div>
<div class="v-hero-sats">${totalSats.toLocaleString()} <span style="font-size:0.4em;color:rgba(255,255,255,0.35)">sats</span></div>
<div class="v-hero-btc">${satsBtc(totalSats)} tBTC</div>
</div>
<div class="v-stats">
<div class="v-stat">
<div class="v-stat-val">${String(vouchers.length)}</div>
<div class="v-stat-label">Total Vouchers</div>
</div>
<div class="v-stat">
<div class="v-stat-val" style="color:#10b981">${String(unspent.length)}</div>
<div class="v-stat-label">Unspent</div>
</div>
<div class="v-stat">
<div class="v-stat-val" style="color:#ef4444">${String(spentCount)}</div>
<div class="v-stat-label">Spent</div>
</div>
</div>
<div class="v-card">
<h2>Import Voucher</h2>
<div class="v-import-row">
<input class="v-input v-import-input" placeholder="Paste hex key, WIF, or TXO URI\u2026"
onkeydown="${function(e) { if (e.key === 'Enter') doImport() }}"
oninput="${function(e) { if (isHexKey(e.target.value.trim())) doImport() }}" />
<button class="v-btn v-btn-primary" onclick="${doImport}">
${importing ? html`<span class="v-spinner"></span>` : 'Import'}
</button>
</div>
<div class="v-help">
Accepts 64-char hex keys, WIF private keys (testnet4), or TXO URIs.
</div>
</div>
<div class="v-card">
<h2>
Vouchers
<div style="display:flex;gap:6px">
${canMerge ? html`
<button class="${'v-btn v-btn-sm' + (mergeMode ? ' v-btn-merge-active' : '')}"
style="${mergeMode ? 'color:#a78bfa;border-color:rgba(167,139,250,0.4)' : ''}"
onclick="${function() { mergeMode = !mergeMode; mergeSelected.clear(); renderApp() }}">
${mergeMode ? '\u2716 Cancel' : '\uD83D\uDD00 Merge'}
</button>
` : null}
<button class="v-btn v-btn-sm" onclick="${function() { refreshStatus() }}"
disabled="${refreshing}">
${refreshing ? html`<span class="v-spinner"></span> Checking\u2026` : '\u21BB Refresh'}
</button>
${vouchers.length > 0 ? html`
<button class="v-btn v-btn-sm v-btn-danger" onclick="${clearAll}">\u2716 Clear All</button>
` : null}
</div>
</h2>
${vouchers.length === 0 ? html`
<div class="v-empty">No vouchers yet. Import a key or TXO URI to get started.</div>
` : null}
${keyed(vouchers, function(v) { return v.id }, function(v) {
return renderVoucherItem(v)
})}
${mergeMode && mergeSelected.size >= 2 ? html`
<div class="v-merge-bar">
<span style="font-size:0.88rem">
Merge <strong>${String(mergeSelected.size)}</strong> vouchers (${mergeSats.toLocaleString()} sats)
</span>
<button class="v-btn v-btn-primary" onclick="${function() { mergeVouchers().catch(function(e) { toast('Merge failed: ' + e.message) }) }}">
\uD83D\uDD00 Merge Now
</button>
</div>
` : null}
</div>
</div>
`)
}
// ── Voucher item template ───────────────────────────
function renderVoucherItem(v) {
var hasKey = !!v.privkey
var badgeClass = v.status === 'unspent' ? 'v-badge v-badge-unspent'
: v.status === 'spent' ? 'v-badge v-badge-spent'
: 'v-badge v-badge-unknown'
var badgeLabel = v.status === 'unspent' ? 'Unspent' : v.status === 'spent' ? 'Spent' : 'Unknown'
var badgeDotStyle = v.status === 'unspent' ? 'background:#10b981'
: v.status === 'spent' ? 'background:#ef4444'
: 'background:rgba(255,255,255,0.3)'
var itemStyle = !hasKey ? 'border-color:rgba(251,191,36,0.2)'
: mergeSelected.has(v.id) ? 'border-color:rgba(167,139,250,0.4)'
: ''
var isRevealed = revealedKeys.has(v.id)
return html`
<div class="v-item" style="${itemStyle}">
<div class="v-item-top">
<div class="v-item-amount">
${mergeMode && hasKey && v.status === 'unspent' ? html`
<input type="checkbox" checked="${mergeSelected.has(v.id) ? true : false}"
style="width:16px;height:16px;accent-color:#7c3aed;cursor:pointer"
onchange="${function(e) {
if (e.target.checked) mergeSelected.add(v.id)
else mergeSelected.delete(v.id)
renderApp()
}}" />
` : null}
${(v.amount || 0).toLocaleString()} ${html`<small>sats</small>`}
<span class="${badgeClass}"><span class="v-badge-dot" style="${badgeDotStyle}"></span>${badgeLabel}</span>
${!hasKey ? html`<span class="v-badge v-badge-locked"><span class="v-badge-dot" style="background:#fbbf24"></span>No Key</span>` : null}
</div>
<div class="v-item-actions">
${!hasKey ? html`
<button class="v-btn v-btn-sm" style="color:#fbbf24;border-color:rgba(251,191,36,0.3)"
onclick="${function() { addKeyTarget = addKeyTarget === v.id ? null : v.id; renderApp() }}">
\uD83D\uDD11 Add Key
</button>
` : null}
${hasKey && v.status === 'unspent' && !mergeMode ? html`
<button class="v-btn v-btn-sm"
onclick="${function() { splitTarget = splitTarget === v.id ? null : v.id; renderApp() }}">
\u2702 Split
</button>
` : null}
${hasKey ? html`
<button class="v-btn v-btn-icon" title="Copy TXO URI"
onclick="${function() { copyText(buildTxoUri(v)) }}">\u2398</button>
` : null}
${hasKey ? html`
<button class="v-btn v-btn-icon" title="Copy share link"
onclick="${function() { copyText(location.origin + location.pathname + '?key=' + v.privkey) }}">\uD83D\uDD17</button>
` : null}
${hasKey ? html`
<button class="v-btn v-btn-icon" title="Copy private key"
onclick="${function() { copyText(v.privkey) }}">\uD83D\uDD11</button>
` : null}
<button class="v-btn v-btn-icon v-btn-danger" title="Delete"
onclick="${function() { vouchers = vouchers.filter(function(x) { return x.id !== v.id }); saveToStorage(); renderApp() }}">\u2716</button>
</div>
</div>
<div class="v-item-details">
<span class="v-item-label">TXID</span>
<a class="v-item-val" href="${'https://mempool.space/testnet4/tx/' + v.txid}" target="_blank" rel="noopener"
style="color:rgba(167,139,250,0.8);text-decoration:none">
${truncate(v.txid) + ':' + v.vout}
</a>
${v.address ? html`
<span class="v-item-label">Address</span>
<span class="v-item-val" onclick="${function() { copyText(v.address) }}">${truncate(v.address, 10, 6)}</span>
` : null}
${hasKey ? html`
<span class="v-item-label">Key</span>
<span class="v-item-val" style="cursor:pointer;user-select:none"
onclick="${function() {
if (isRevealed) revealedKeys.delete(v.id)
else revealedKeys.add(v.id)
renderApp()
}}">
${isRevealed ? v.privkey : maskKey(v.privkey)}
${!isRevealed ? html`<span style="font-size:0.8em;color:rgba(255,255,255,0.2)"> (click)</span>` : null}
</span>
` : null}
</div>
${v.id === addKeyTarget ? html`
<div class="v-expand">
<div class="v-import-row">
<input class="v-input v-key-input" placeholder="Paste hex or WIF private key\u2026"
onkeydown="${function(e) { if (e.key === 'Enter') submitKey(v.id) }}"
oninput="${function(e) { if (isHexKey(e.target.value.trim())) submitKey(v.id) }}" />
<button class="v-btn v-btn-primary v-btn-sm" onclick="${function() { submitKey(v.id) }}">\uD83D\uDD11 Unlock</button>
</div>
</div>
` : null}
${v.id === splitTarget ? html`
<div class="v-expand">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<span style="color:rgba(255,255,255,0.5);font-size:0.82rem">Split into</span>
<input type="number" class="v-input v-split-input" value="2" min="2" max="100"
style="width:70px;text-align:center" />
<span style="color:rgba(255,255,255,0.5);font-size:0.82rem">outputs</span>
<button class="v-btn v-btn-primary v-btn-sm"
onclick="${function() { confirmSplit(v.id).catch(function(e) { toast('Split failed: ' + e.message) }) }}">
\u2702 Confirm
</button>
<button class="v-btn v-btn-sm"
onclick="${function() { splitTarget = null; renderApp() }}">\u2716</button>
</div>
</div>
` : null}
</div>
`
}
// ── Actions ─────────────────────────────────────────
async function doImport() {
if (importing) return
var input = container.querySelector('.v-import-input')
if (!input || !input.value.trim()) return
importing = true
renderApp()
await importKey(input.value)
importing = false
renderApp()
}
function clearAll() {
if (!confirm('Delete all vouchers?')) return
vouchers = []
saveToStorage()
renderApp()
}
// ── Init ────────────────────────────────────────────
renderApp()
// Auto-import from ?key= parameter
if (window.__pendingImport) {
var keyToImport = window.__pendingImport
delete window.__pendingImport
importing = true
renderApp()
importKey(keyToImport).then(function() {
importing = false
renderApp()
})
}
// No auto-refresh on load — it triggers PUT which causes live-reload loop.
// User clicks "Refresh" manually.
onUnmount(container, function() {
// Cleanup if needed
})
}
}