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.
@@ -0,0 +1,662 @@
1
+ import { html, render, keyed, onUnmount } from 'https://losos.org/losos/html.js'
2
+ import { Blocktrail, verify } from 'https://esm.sh/blocktrails@0.0.11'
3
+ import {
4
+ decodeKey, bytesToHex, hexToU8, sha256,
5
+ buildTransaction, estimateVsize, fetchUtxos, fetchTxDetails, broadcastTx, getFeeRate,
6
+ wpToP2trAddress, parseVoucherFromItem, hexToBytes
7
+ } from '../lib/bitcoin.js'
8
+
9
+ export default {
10
+ label: 'Ledger',
11
+ icon: '\uD83D\uDCCA',
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
+ // ── URLs ────────────────────────────────────────────
23
+
24
+ var LEDGER_URL = new URL('webledger.jsonld', location.href).href
25
+ var TRAIL_URL = new URL('blocktrail.jsonld', location.href).href
26
+ var HISTORY_URL = new URL('ledger-history.jsonld', location.href).href
27
+
28
+ // ── State ───────────────────────────────────────────
29
+
30
+ var ledger = null
31
+ var trail = null
32
+ var privkeyHex = ''
33
+ var history = []
34
+ var saving = false
35
+ var error = null
36
+ var editingEntry = null // index being edited, or 'new'
37
+
38
+ // ── Load data ───────────────────────────────────────
39
+
40
+ var loaded = 0
41
+ var totalLoads = 3
42
+
43
+ function checkReady() {
44
+ loaded++
45
+ if (loaded >= totalLoads) renderApp()
46
+ }
47
+
48
+ // Load ledger
49
+ fetch(LEDGER_URL + '?t=' + Date.now(), { cache: 'no-store' })
50
+ .then(function(r) { return r.ok ? r.json() : null })
51
+ .then(function(data) {
52
+ if (data) ledger = data
53
+ else ledger = { '@context': 'https://w3id.org/webledgers', '@id': '#this', type: 'WebLedger', name: 'Voucher Pool Ledger', defaultCurrency: 'satoshi', entries: [] }
54
+ checkReady()
55
+ })
56
+ .catch(function() { ledger = { '@context': 'https://w3id.org/webledgers', '@id': '#this', type: 'WebLedger', name: 'Voucher Pool Ledger', defaultCurrency: 'satoshi', entries: [] }; checkReady() })
57
+
58
+ // Load trail
59
+ fetch(TRAIL_URL + '?t=' + Date.now(), { cache: 'no-store' })
60
+ .then(function(r) { return r.ok ? r.json() : null })
61
+ .then(function(data) {
62
+ if (data && data['bt:privkey']) {
63
+ try {
64
+ privkeyHex = data['bt:privkey']
65
+ trail = new Blocktrail(privkeyHex)
66
+ var stateNodes = data['bt:state'] || []
67
+ if (!Array.isArray(stateNodes)) stateNodes = [stateNodes]
68
+ stateNodes.forEach(function(n) {
69
+ var val = typeof n === 'string' ? n : (n['bt:value'] || '')
70
+ if (val) {
71
+ if (trail.states.length === 0) trail.genesis(val)
72
+ else trail.advance(val)
73
+ }
74
+ })
75
+ } catch(e) { console.warn('Trail load error:', e) }
76
+ }
77
+ checkReady()
78
+ })
79
+ .catch(function() { checkReady() })
80
+
81
+ // Load history
82
+ fetch(HISTORY_URL + '?t=' + Date.now(), { cache: 'no-store' })
83
+ .then(function(r) { return r.ok ? r.json() : null })
84
+ .then(function(data) {
85
+ if (data && data['bt:snapshots']) {
86
+ history = Array.isArray(data['bt:snapshots']) ? data['bt:snapshots'] : [data['bt:snapshots']]
87
+ }
88
+ checkReady()
89
+ })
90
+ .catch(function() { checkReady() })
91
+
92
+ // ── Helpers ─────────────────────────────────────────
93
+
94
+ function toast(msg) {
95
+ var t = document.createElement('div')
96
+ 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'
97
+ t.textContent = msg
98
+ document.body.appendChild(t)
99
+ setTimeout(function() { t.remove() }, 2000)
100
+ }
101
+
102
+ function copyText(text) {
103
+ navigator.clipboard.writeText(text).then(function() { toast('Copied') })
104
+ }
105
+
106
+ function truncate(s, start, end) {
107
+ start = start || 8; end = end || 6
108
+ if (!s || s.length <= start + end + 3) return s
109
+ return s.slice(0, start) + '\u2026' + s.slice(-end)
110
+ }
111
+
112
+ async function hashContent(content) {
113
+ var str = typeof content === 'string' ? content : JSON.stringify(content)
114
+ var hash = await sha256(str)
115
+ return bytesToHex(hash)
116
+ }
117
+
118
+ // ── Ledger operations ───────────────────────────────
119
+
120
+ function addEntry() {
121
+ var urlInput = container.querySelector('.wl-new-url')
122
+ var amountInput = container.querySelector('.wl-new-amount')
123
+ var url = urlInput && urlInput.value.trim()
124
+ var amount = amountInput && amountInput.value.trim()
125
+ if (!url || !amount) { toast('Enter URL and amount'); return }
126
+
127
+ if (!ledger.entries) ledger.entries = []
128
+ ledger.entries.push({ type: 'Entry', url: url, amount: amount })
129
+ editingEntry = null
130
+ renderApp()
131
+ }
132
+
133
+ function removeEntry(index) {
134
+ ledger.entries.splice(index, 1)
135
+ renderApp()
136
+ }
137
+
138
+ function updateEntry(index) {
139
+ var urlInput = container.querySelector('.wl-edit-url')
140
+ var amountInput = container.querySelector('.wl-edit-amount')
141
+ var url = urlInput && urlInput.value.trim()
142
+ var amount = amountInput && amountInput.value.trim()
143
+ if (!url || !amount) return
144
+ ledger.entries[index] = { type: 'Entry', url: url, amount: amount }
145
+ editingEntry = null
146
+ renderApp()
147
+ }
148
+
149
+ // ── Verify ───────────────────────────────────────────
150
+
151
+ var verifyResult = null
152
+ var verifying = false
153
+
154
+ async function verifyLedger() {
155
+ if (verifying) return
156
+ verifying = true
157
+ verifyResult = { running: true }
158
+ renderApp()
159
+
160
+ try {
161
+ // 1. Hash current ledger
162
+ var currentHash = await hashContent(JSON.stringify(ledger, null, 2))
163
+ verifyResult.currentHash = currentHash
164
+
165
+ // 2. Get latest state from trail
166
+ if (!trail || trail.states.length === 0) {
167
+ verifyResult = { running: false, status: 'no-trail', message: 'No blocktrail configured. Anchor the ledger first.' }
168
+ verifying = false; renderApp(); return
169
+ }
170
+
171
+ var latestState = trail.states[trail.states.length - 1]
172
+ verifyResult.anchoredHash = latestState
173
+
174
+ await new Promise(function(r) { setTimeout(r, 400) })
175
+
176
+ // 3. Compare
177
+ var hashMatch = currentHash === latestState
178
+ verifyResult.hashMatch = hashMatch
179
+
180
+ // 4. Verify the full blocktrail chain
181
+ if (hashMatch) {
182
+ await new Promise(function(r) { setTimeout(r, 300) })
183
+ var exp = trail.export()
184
+ var wpBytes = exp.witnessPrograms.map(function(wp) { return hexToU8(wp) })
185
+ var chainResult = verify(exp.pubkeyBase, exp.states, wpBytes)
186
+ verifyResult.chainValid = chainResult.valid
187
+ verifyResult.chainError = chainResult.error
188
+ }
189
+
190
+ // 5. Check if latest state is on-chain
191
+ if (hashMatch && verifyResult.chainValid) {
192
+ await new Promise(function(r) { setTimeout(r, 300) })
193
+ try {
194
+ var exp = trail.export()
195
+ var latestWp = exp.witnessPrograms[exp.witnessPrograms.length - 1]
196
+ var addr = wpToP2trAddress(latestWp, true)
197
+ var utxos = await fetchUtxos(addr)
198
+ verifyResult.onChain = utxos.length > 0
199
+ if (utxos.length > 0) {
200
+ verifyResult.txid = utxos[0].txid
201
+ verifyResult.confirmed = utxos[0].status && utxos[0].status.confirmed
202
+ }
203
+ } catch(e) {
204
+ verifyResult.onChain = null // couldn't check
205
+ }
206
+ }
207
+
208
+ verifyResult.running = false
209
+ verifyResult.status = hashMatch && verifyResult.chainValid ? 'valid' : hashMatch ? 'chain-broken' : 'modified'
210
+ } catch(e) {
211
+ verifyResult = { running: false, status: 'error', message: e.message }
212
+ }
213
+ verifying = false
214
+ renderApp()
215
+ }
216
+
217
+ // ── Save + Anchor ───────────────────────────────────
218
+
219
+ async function saveLedger(andAnchor) {
220
+ if (saving) return
221
+ saving = true
222
+ error = null
223
+ renderApp()
224
+
225
+ try {
226
+ // 1. Save ledger
227
+ var ledgerJson = JSON.stringify(ledger, null, 2)
228
+ await fetch(LEDGER_URL, {
229
+ method: 'PUT',
230
+ headers: { 'Content-Type': 'application/ld+json' },
231
+ body: ledgerJson
232
+ })
233
+
234
+ if (!andAnchor) {
235
+ toast('Ledger saved')
236
+ saving = false
237
+ renderApp()
238
+ return
239
+ }
240
+
241
+ // 2. Hash the ledger content
242
+ var contentHash = await hashContent(ledgerJson)
243
+
244
+ // 3. Snapshot to history
245
+ var snapshot = {
246
+ '@type': 'bt:Snapshot',
247
+ 'bt:contentHash': 'sha256:' + contentHash,
248
+ 'bt:timestamp': new Date().toISOString(),
249
+ 'bt:content': JSON.parse(ledgerJson)
250
+ }
251
+ history.push(snapshot)
252
+ await fetch(HISTORY_URL, {
253
+ method: 'PUT',
254
+ headers: { 'Content-Type': 'application/ld+json' },
255
+ body: JSON.stringify({
256
+ '@context': { 'schema': 'https://schema.org/', 'bt': 'https://blocktrails.org/ns/' },
257
+ '@id': '#this',
258
+ '@type': 'bt:History',
259
+ 'bt:snapshots': history
260
+ }, null, 2)
261
+ })
262
+
263
+ // 4. Advance blocktrail with the hash as state
264
+ if (!trail || !privkeyHex) {
265
+ toast('Saved + snapshot. No blocktrail configured \u2014 go to Blocktrails tab to initialize.')
266
+ saving = false
267
+ renderApp()
268
+ return
269
+ }
270
+
271
+ var stateValue = contentHash
272
+ if (trail.states.length === 0) {
273
+ trail.genesis(stateValue)
274
+ } else {
275
+ trail.advance(stateValue)
276
+ }
277
+
278
+ // 5. Try to broadcast on-chain
279
+ var exp = trail.export()
280
+ var currentIdx = trail.states.length - 1
281
+ var prevIdx = currentIdx - 1
282
+ var newWp = exp.witnessPrograms[currentIdx]
283
+ var newAddr = wpToP2trAddress(newWp, true)
284
+ var txid = null
285
+
286
+ if (prevIdx >= 0) {
287
+ // Spend from previous state
288
+ var prevAddr = wpToP2trAddress(exp.witnessPrograms[prevIdx], true)
289
+ try {
290
+ var utxos = await fetchUtxos(prevAddr)
291
+ if (utxos.length > 0) {
292
+ var utxo = utxos[0]
293
+ var prevStates = trail.states.slice(0, currentIdx)
294
+ var signingKeyHex = null
295
+ if (prevIdx === 0) {
296
+ var g = (await import('https://esm.sh/blocktrails@0.0.11')).genesis
297
+ signingKeyHex = g(hexToBytes(privkeyHex), trail.states[0]).derivedPrivkey
298
+ } else {
299
+ var tr = (await import('https://esm.sh/blocktrails@0.0.11')).transition
300
+ signingKeyHex = tr(hexToBytes(privkeyHex), trail.states.slice(0, prevIdx), trail.states[prevIdx]).signingPrivkey
301
+ }
302
+
303
+ var txDetails = await fetchTxDetails(utxo.txid)
304
+ var prevOut = txDetails.vout[utxo.vout]
305
+ var inputScript = hexToU8(prevOut.scriptpubkey)
306
+
307
+ var outputScript = new Uint8Array(34)
308
+ outputScript[0] = 0x51; outputScript[1] = 0x20
309
+ outputScript.set(hexToU8(newWp), 2)
310
+
311
+ var feeRate = await getFeeRate()
312
+ var fee = Math.ceil(estimateVsize(1, 1) * feeRate)
313
+ var outputAmount = utxo.value - fee
314
+ if (outputAmount > 546) {
315
+ var rawTx = await buildTransaction(
316
+ [{ txid: utxo.txid, vout: utxo.vout, amount: utxo.value, scriptPubKey: inputScript }],
317
+ [{ amount: outputAmount, scriptPubKey: outputScript }],
318
+ hexToU8(signingKeyHex)
319
+ )
320
+ txid = await broadcastTx(rawTx)
321
+ }
322
+ }
323
+ } catch(e) {
324
+ console.warn('On-chain anchor failed (trail still saved):', e)
325
+ }
326
+ }
327
+
328
+ // 6. Save trail
329
+ var trailJsonLd = {
330
+ '@context': { 'schema': 'https://schema.org/', 'bt': 'https://blocktrails.org/ns/' },
331
+ '@id': '#this',
332
+ '@type': 'bt:Trail',
333
+ 'bt:privkey': privkeyHex,
334
+ 'bt:pubkeyBase': exp.pubkeyBase,
335
+ 'bt:state': exp.states.map(function(s, i) {
336
+ var stateObj = {
337
+ '@type': 'bt:State',
338
+ 'bt:value': s,
339
+ 'bt:witnessProgram': exp.witnessPrograms[i],
340
+ 'bt:address': wpToP2trAddress(exp.witnessPrograms[i], true),
341
+ 'bt:source': 'webledger.jsonld',
342
+ 'bt:contentHash': 'sha256:' + s,
343
+ 'bt:timestamp': i === currentIdx ? new Date().toISOString() : undefined
344
+ }
345
+ if (i === currentIdx && txid) {
346
+ stateObj['bt:txid'] = txid
347
+ stateObj['bt:confirmed'] = false
348
+ }
349
+ return stateObj
350
+ })
351
+ }
352
+ await fetch(TRAIL_URL, {
353
+ method: 'PUT',
354
+ headers: { 'Content-Type': 'application/ld+json' },
355
+ body: JSON.stringify(trailJsonLd, null, 2)
356
+ })
357
+
358
+ snapshot['bt:txid'] = txid || null
359
+ // Update history with txid
360
+ await fetch(HISTORY_URL, {
361
+ method: 'PUT',
362
+ headers: { 'Content-Type': 'application/ld+json' },
363
+ body: JSON.stringify({
364
+ '@context': { 'schema': 'https://schema.org/', 'bt': 'https://blocktrails.org/ns/' },
365
+ '@id': '#this',
366
+ '@type': 'bt:History',
367
+ 'bt:snapshots': history
368
+ }, null, 2)
369
+ })
370
+
371
+ toast(txid ? 'Saved + anchored on Bitcoin! ' + truncate(txid) : 'Saved + trail advanced (off-chain)')
372
+ } catch(e) {
373
+ error = e.message
374
+ toast('Save failed: ' + e.message)
375
+ console.error(e)
376
+ }
377
+ saving = false
378
+ renderApp()
379
+ }
380
+
381
+ // ── Render ───────────────────────────────────────────
382
+
383
+ function renderApp() {
384
+ if (!ledger) return
385
+
386
+ var entries = ledger.entries || []
387
+ var totalBalance = entries.reduce(function(s, e) {
388
+ var amt = typeof e.amount === 'string' ? parseInt(e.amount) || 0 : 0
389
+ return s + amt
390
+ }, 0)
391
+ var hasTrail = trail && trail.states.length > 0
392
+ var stateCount = trail ? trail.states.length : 0
393
+
394
+ render(container, html`
395
+ <style>
396
+ .wl-wrap { padding: 0 16px 40px; }
397
+ .wl-hero { text-align: center; padding: 40px 0 32px; }
398
+ .wl-hero-icon { font-size: 3rem; margin-bottom: 8px; }
399
+ .wl-hero-title { font-size: 1.6rem; font-weight: 800; margin-bottom: 4px; }
400
+ .wl-hero-sub { font-size: 0.9rem; color: rgba(255,255,255,0.35); }
401
+ .wl-stats { display: flex; gap: 16px; justify-content: center; margin-bottom: 24px; flex-wrap: wrap; }
402
+ .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; }
403
+ .wl-stat-val { font-size: 1.5rem; font-weight: 700; color: #3b82f6; }
404
+ .wl-stat-label { font-size: 0.7rem; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
405
+ .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); }
406
+ .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; }
407
+ .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; }
408
+ .wl-input::placeholder { color: rgba(255,255,255,0.25); }
409
+ .wl-input:focus { border-color: rgba(59,130,246,0.5); }
410
+ .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; }
411
+ .wl-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
412
+ .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); }
413
+ .wl-btn-primary:hover { box-shadow: 0 6px 24px rgba(59,130,246,0.3); }
414
+ .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); }
415
+ .wl-btn-anchor:hover { box-shadow: 0 6px 24px rgba(247,147,26,0.3); }
416
+ .wl-btn-sm { padding: 5px 10px; font-size: 0.78rem; border-radius: 6px; }
417
+ .wl-btn-danger { color: #ef4444; }
418
+ .wl-btn-danger:hover { background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.3); }
419
+ .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; }
420
+ .wl-entry:hover { background: rgba(255,255,255,0.06); }
421
+ .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; }
422
+ .wl-entry-url:hover { color: rgba(255,255,255,0.9); }
423
+ .wl-entry-amount { font-size: 1.1rem; font-weight: 700; color: #3b82f6; min-width: 80px; text-align: right; }
424
+ .wl-entry-amount small { font-size: 0.65em; color: rgba(255,255,255,0.3); font-weight: 400; }
425
+ .wl-entry-actions { display: flex; gap: 4px; }
426
+ .wl-add-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
427
+ .wl-empty { text-align: center; padding: 32px; color: rgba(255,255,255,0.25); font-style: italic; }
428
+ .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; }
429
+ .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; }
430
+ .wl-history-hash { color: rgba(59,130,246,0.6); font-family: 'SF Mono', monospace; font-size: 0.75rem; }
431
+ .wl-history-time { color: rgba(255,255,255,0.25); font-size: 0.72rem; }
432
+ .wl-history-entries { color: rgba(255,255,255,0.4); font-size: 0.75rem; margin-top: 4px; }
433
+ .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; }
434
+ .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; }
435
+ @keyframes bt-slideIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
436
+ .wl-verify-header { display: flex; align-items: center; gap: 10px; font-size: 1.1rem; font-weight: 700; margin-bottom: 14px; }
437
+ .wl-verify-icon { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1rem; }
438
+ .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); }
439
+ .wl-vi-fail { background: rgba(239,68,68,0.15); color: #ef4444; border: 2px solid rgba(239,68,68,0.4); }
440
+ .wl-vi-warn { background: rgba(251,191,36,0.15); color: #fbbf24; border: 2px solid rgba(251,191,36,0.4); }
441
+ .wl-vi-info { background: rgba(59,130,246,0.15); color: #3b82f6; border: 2px solid rgba(59,130,246,0.4); }
442
+ .wl-verify-steps { display: flex; flex-direction: column; gap: 8px; }
443
+ .wl-verify-step { display: flex; align-items: center; gap: 10px; font-size: 0.85rem; }
444
+ .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; }
445
+ .wl-vdot-pass { background: rgba(16,185,129,0.15); color: #10b981; border: 1px solid rgba(16,185,129,0.3); }
446
+ .wl-vdot-fail { background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); }
447
+ .wl-verify-label { color: rgba(255,255,255,0.4); font-weight: 600; min-width: 110px; }
448
+ .wl-verify-val { color: rgba(255,255,255,0.6); font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.82rem; }
449
+ .wl-save-bar { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
450
+ @media (max-width: 600px) { .wl-add-row { flex-direction: column; } .wl-entry { flex-wrap: wrap; } }
451
+ </style>
452
+
453
+ <div class="wl-wrap">
454
+ <div class="wl-hero">
455
+ <div class="wl-hero-icon">\uD83D\uDCCA</div>
456
+ <div class="wl-hero-title">${ledger.name || 'Web Ledger'}</div>
457
+ <div class="wl-hero-sub">${ledger.description || 'URI-to-balance mappings anchored to Bitcoin'}</div>
458
+ </div>
459
+
460
+ ${error ? html`<div class="wl-error">${error}</div>` : null}
461
+
462
+ <div class="wl-stats">
463
+ <div class="wl-stat">
464
+ <div class="wl-stat-val">${String(entries.length)}</div>
465
+ <div class="wl-stat-label">Entries</div>
466
+ </div>
467
+ <div class="wl-stat">
468
+ <div class="wl-stat-val">${totalBalance.toLocaleString()}</div>
469
+ <div class="wl-stat-label">${ledger.defaultCurrency || 'units'}</div>
470
+ </div>
471
+ <div class="wl-stat">
472
+ <div class="wl-stat-val">${String(stateCount)}</div>
473
+ <div class="wl-stat-label">Anchored</div>
474
+ </div>
475
+ <div class="wl-stat">
476
+ <div class="wl-stat-val">${String(history.length)}</div>
477
+ <div class="wl-stat-label">Snapshots</div>
478
+ </div>
479
+ </div>
480
+
481
+ <div class="wl-card">
482
+ <h2>
483
+ Entries
484
+ <button class="wl-btn wl-btn-sm" onclick="${function() { editingEntry = editingEntry === 'new' ? null : 'new'; renderApp() }}">
485
+ ${editingEntry === 'new' ? '\u2716 Cancel' : '+ Add Entry'}
486
+ </button>
487
+ </h2>
488
+
489
+ ${editingEntry === 'new' ? html`
490
+ <div class="wl-add-row" style="margin-bottom:12px">
491
+ <input class="wl-input wl-new-url" placeholder="URI (did:nostr:..., https://...)" style="flex:2"
492
+ onkeydown="${function(e) { if (e.key === 'Enter') addEntry() }}" />
493
+ <input class="wl-input wl-new-amount" placeholder="Amount" style="flex:0.5;min-width:100px"
494
+ onkeydown="${function(e) { if (e.key === 'Enter') addEntry() }}" />
495
+ <button class="wl-btn wl-btn-primary wl-btn-sm" onclick="${addEntry}">Add</button>
496
+ </div>
497
+ ` : null}
498
+
499
+ ${entries.length === 0 ? html`
500
+ <div class="wl-empty">No entries. Add a URI and balance to get started.</div>
501
+ ` : null}
502
+
503
+ ${entries.map(function(entry, i) {
504
+ var amountStr = typeof entry.amount === 'string' ? entry.amount : JSON.stringify(entry.amount)
505
+ if (editingEntry === i) {
506
+ return html`
507
+ <div class="wl-add-row" style="margin-bottom:8px">
508
+ <input class="wl-input wl-edit-url" value="${entry.url}" style="flex:2" />
509
+ <input class="wl-input wl-edit-amount" value="${amountStr}" style="flex:0.5;min-width:100px" />
510
+ <button class="wl-btn wl-btn-primary wl-btn-sm" onclick="${function() { updateEntry(i) }}">\u2713</button>
511
+ <button class="wl-btn wl-btn-sm" onclick="${function() { editingEntry = null; renderApp() }}">\u2716</button>
512
+ </div>
513
+ `
514
+ }
515
+ return html`
516
+ <div class="wl-entry">
517
+ <div class="wl-entry-url" onclick="${function() { copyText(entry.url) }}" title="${entry.url}">
518
+ ${truncate(entry.url, 20, 12)}
519
+ </div>
520
+ <div class="wl-entry-amount">
521
+ ${parseInt(amountStr).toLocaleString()} ${html`<small>${ledger.defaultCurrency || ''}</small>`}
522
+ </div>
523
+ <div class="wl-entry-actions">
524
+ <button class="wl-btn wl-btn-sm" onclick="${function() { editingEntry = i; renderApp() }}">\u270E</button>
525
+ <button class="wl-btn wl-btn-sm wl-btn-danger" onclick="${function() { removeEntry(i); }}">\u2716</button>
526
+ </div>
527
+ </div>
528
+ `
529
+ })}
530
+
531
+ <div class="wl-save-bar">
532
+ <button class="wl-btn" onclick="${verifyLedger}" disabled="${verifying}">
533
+ ${verifying ? html`<span class="wl-spinner"></span>` : '\u2713'} Verify
534
+ </button>
535
+ <button class="wl-btn wl-btn-primary" onclick="${function() { saveLedger(false) }}" disabled="${saving}">
536
+ ${saving ? html`<span class="wl-spinner"></span>` : '\uD83D\uDCBE'} Save
537
+ </button>
538
+ <button class="wl-btn wl-btn-anchor" onclick="${function() { saveLedger(true) }}" disabled="${saving || !privkeyHex}">
539
+ ${saving ? html`<span class="wl-spinner"></span>` : '\u26D3'} Save + Anchor to Bitcoin
540
+ </button>
541
+ </div>
542
+
543
+ ${verifyResult && !verifyResult.running ? html`
544
+ <div class="wl-verify-card" style="animation: bt-slideIn 0.3s ease">
545
+ <div class="wl-verify-header">
546
+ ${verifyResult.status === 'valid' ? html`
547
+ <span class="wl-verify-icon wl-vi-pass">\u2713</span>
548
+ <span>Ledger Verified</span>
549
+ ` : verifyResult.status === 'modified' ? html`
550
+ <span class="wl-verify-icon wl-vi-warn">\u26A0</span>
551
+ <span>Ledger Modified</span>
552
+ ` : verifyResult.status === 'no-trail' ? html`
553
+ <span class="wl-verify-icon wl-vi-info">\u2139</span>
554
+ <span>Not Anchored</span>
555
+ ` : html`
556
+ <span class="wl-verify-icon wl-vi-fail">\u2716</span>
557
+ <span>${verifyResult.message || 'Verification Failed'}</span>
558
+ `}
559
+ </div>
560
+
561
+ ${verifyResult.currentHash ? html`
562
+ <div class="wl-verify-steps">
563
+ <div class="wl-verify-step">
564
+ <span class="${'wl-vdot ' + (verifyResult.hashMatch ? 'wl-vdot-pass' : 'wl-vdot-fail')}">
565
+ ${verifyResult.hashMatch ? '\u2713' : '\u2716'}
566
+ </span>
567
+ <span class="wl-verify-label">Content hash</span>
568
+ <span class="wl-verify-val" onclick="${function() { copyText(verifyResult.currentHash) }}" style="cursor:pointer">
569
+ ${truncate('sha256:' + verifyResult.currentHash, 14, 8)}
570
+ </span>
571
+ </div>
572
+
573
+ ${verifyResult.hashMatch === false ? html`
574
+ <div class="wl-verify-step">
575
+ <span class="wl-vdot wl-vdot-fail">\u2716</span>
576
+ <span class="wl-verify-label">Anchored hash</span>
577
+ <span class="wl-verify-val" style="color:#ef4444">${truncate('sha256:' + verifyResult.anchoredHash, 14, 8)}</span>
578
+ </div>
579
+ ` : null}
580
+
581
+ ${verifyResult.chainValid !== undefined ? html`
582
+ <div class="wl-verify-step">
583
+ <span class="${'wl-vdot ' + (verifyResult.chainValid ? 'wl-vdot-pass' : 'wl-vdot-fail')}">
584
+ ${verifyResult.chainValid ? '\u2713' : '\u2716'}
585
+ </span>
586
+ <span class="wl-verify-label">Blocktrail chain</span>
587
+ <span class="wl-verify-val">${verifyResult.chainValid ? 'All witness programs match' : verifyResult.chainError || 'Invalid'}</span>
588
+ </div>
589
+ ` : null}
590
+
591
+ ${verifyResult.onChain !== undefined && verifyResult.onChain !== null ? html`
592
+ <div class="wl-verify-step">
593
+ <span class="${'wl-vdot ' + (verifyResult.onChain ? 'wl-vdot-pass' : 'wl-vdot-fail')}">
594
+ ${verifyResult.onChain ? '\u2713' : '\u2716'}
595
+ </span>
596
+ <span class="wl-verify-label">Bitcoin</span>
597
+ <span class="wl-verify-val">
598
+ ${verifyResult.confirmed ? 'Confirmed' : verifyResult.onChain ? 'Pending' : 'Not found'}
599
+ ${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}
600
+ </span>
601
+ </div>
602
+ ` : null}
603
+ </div>
604
+ ` : null}
605
+
606
+ ${verifyResult.status === 'modified' ? html`
607
+ <div style="margin-top:12px;font-size:0.78rem;color:rgba(255,255,255,0.3)">
608
+ The ledger has been modified since last anchor. Click "Save + Anchor to Bitcoin" to record the new state.
609
+ </div>
610
+ ` : null}
611
+ </div>
612
+ ` : verifyResult && verifyResult.running ? html`
613
+ <div class="wl-verify-card" style="animation: bt-slideIn 0.3s ease">
614
+ <div class="wl-verify-header">
615
+ <span class="wl-spinner"></span>
616
+ <span>Verifying\u2026</span>
617
+ </div>
618
+ </div>
619
+ ` : null}
620
+ </div>
621
+
622
+ ${history.length > 0 ? html`
623
+ <div class="wl-card">
624
+ <h2>
625
+ History
626
+ <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>
627
+ </h2>
628
+ ${history.slice().reverse().map(function(snap) {
629
+ var hash = snap['bt:contentHash'] || ''
630
+ var time = snap['bt:timestamp'] || ''
631
+ var txid = snap['bt:txid'] || ''
632
+ var content = snap['bt:content']
633
+ var entryCount = content && content.entries ? content.entries.length : '?'
634
+ var totalAmt = 0
635
+ if (content && content.entries) {
636
+ content.entries.forEach(function(e) { totalAmt += parseInt(e.amount) || 0 })
637
+ }
638
+ return html`
639
+ <div class="wl-history-item">
640
+ <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:4px">
641
+ <span class="wl-history-hash" onclick="${function() { copyText(hash) }}" style="cursor:pointer" title="Click to copy hash">
642
+ ${truncate(hash, 12, 8)}
643
+ </span>
644
+ <span class="wl-history-time">${time ? new Date(time).toLocaleString() : ''}</span>
645
+ </div>
646
+ <div class="wl-history-entries">
647
+ ${String(entryCount)} entries \u00B7 ${totalAmt.toLocaleString()} total
648
+ ${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}
649
+ </div>
650
+ </div>
651
+ `
652
+ })}
653
+ </div>
654
+ ` : null}
655
+ </div>
656
+ `)
657
+ }
658
+
659
+ renderApp()
660
+ onUnmount(container, function() {})
661
+ }
662
+ }