txo_parser 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +13 -17
- package/README.md +3 -1
- package/demo/blocktrail.jsonld +10 -0
- package/demo/index.html +82 -0
- package/demo/ledger-history.jsonld +9 -0
- package/demo/lib/bitcoin.js +378 -0
- package/demo/panes/blocktrails-pane.js +673 -0
- package/demo/panes/builder-pane.js +190 -0
- package/demo/panes/faucet-pane.js +137 -0
- package/demo/panes/ledger-pane.js +662 -0
- package/demo/panes/parser-pane.js +171 -0
- package/demo/panes/spec-pane.js +124 -0
- package/demo/panes/voucher-pane.js +693 -0
- package/demo/voucher-data.jsonld +31 -0
- package/demo/webledger.jsonld +9 -0
- package/index.js +29 -8
- package/package.json +3 -3
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
import { html, render, keyed, onUnmount } from 'https://losos.org/losos/html.js'
|
|
2
|
+
import {
|
|
3
|
+
decodeKey, privkeyToAddress, isHexKey,
|
|
4
|
+
buildTransaction, estimateVsize,
|
|
5
|
+
fetchUtxos, checkOutspend, fetchTxDetails, broadcastTx, getFeeRate,
|
|
6
|
+
bytesToHex, hexToU8,
|
|
7
|
+
parseTxoUri, isValidTxoUri,
|
|
8
|
+
toSats, buildTxoUri, parseVoucherFromItem
|
|
9
|
+
} from '../lib/bitcoin.js'
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
label: 'Vouchers',
|
|
13
|
+
icon: '\uD83C\uDFAB',
|
|
14
|
+
|
|
15
|
+
canHandle(subject, store) {
|
|
16
|
+
var node = store.get(subject.value)
|
|
17
|
+
if (!node) return false
|
|
18
|
+
var type = store.type(node)
|
|
19
|
+
return type && type.includes('VoucherPool')
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
render(subject, lionStore, container, rawData) {
|
|
23
|
+
|
|
24
|
+
// ── Load state ──────────────────────────────────────
|
|
25
|
+
var DATA_URL = new URL('voucher-data.jsonld', location.href).href
|
|
26
|
+
var data = rawData || {}
|
|
27
|
+
var items = data['schema:itemListElement'] || []
|
|
28
|
+
var vouchers = items.map(parseVoucherFromItem)
|
|
29
|
+
|
|
30
|
+
var importing = false
|
|
31
|
+
var refreshing = false
|
|
32
|
+
var mergeMode = false
|
|
33
|
+
var mergeSelected = new Set()
|
|
34
|
+
var splitTarget = null
|
|
35
|
+
var addKeyTarget = null
|
|
36
|
+
var revealedKeys = new Set()
|
|
37
|
+
|
|
38
|
+
// ── Persistence ─────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function saveToStorage() {
|
|
41
|
+
var jsonLd = {
|
|
42
|
+
'@context': { 'schema': 'https://schema.org/', 'bt': 'https://blocktrails.org/ns/' },
|
|
43
|
+
'@id': '#this',
|
|
44
|
+
'@type': 'VoucherPool',
|
|
45
|
+
'schema:name': 'Voucher Pool',
|
|
46
|
+
'schema:description': 'Testnet4 voucher management and faucet',
|
|
47
|
+
'schema:hasPart': [
|
|
48
|
+
{ '@id': 'blocktrail.jsonld#this' },
|
|
49
|
+
{ '@id': 'webledger.jsonld#this' },
|
|
50
|
+
{ '@id': 'ledger-history.jsonld#this' }
|
|
51
|
+
],
|
|
52
|
+
'schema:itemListElement': vouchers.map(function(v) {
|
|
53
|
+
return {
|
|
54
|
+
'@type': 'schema:ListItem',
|
|
55
|
+
'@id': v.id,
|
|
56
|
+
'schema:identifier': buildTxoUri(v),
|
|
57
|
+
'schema:address': v.address,
|
|
58
|
+
'schema:status': v.status,
|
|
59
|
+
'schema:dateCreated': v.dateAdded
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
// PUT to Solid server
|
|
64
|
+
fetch(DATA_URL, {
|
|
65
|
+
method: 'PUT',
|
|
66
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
67
|
+
body: JSON.stringify(jsonLd, null, 2)
|
|
68
|
+
}).catch(function(e) { console.warn('PUT failed:', e) })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Helpers ─────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function truncate(s, start, end) {
|
|
74
|
+
start = start || 8; end = end || 6
|
|
75
|
+
if (!s || s.length <= start + end + 3) return s
|
|
76
|
+
return s.slice(0, start) + '\u2026' + s.slice(-end)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function maskKey(wif) {
|
|
80
|
+
if (!wif || wif.length < 6) return '***'
|
|
81
|
+
return wif.slice(0, 3) + '\u2026' + wif.slice(-3)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function satsBtc(sats) { return (sats / 1e8).toFixed(8) }
|
|
85
|
+
|
|
86
|
+
function toast(msg) {
|
|
87
|
+
var t = document.createElement('div')
|
|
88
|
+
t.className = 'v-toast'
|
|
89
|
+
t.textContent = msg
|
|
90
|
+
document.body.appendChild(t)
|
|
91
|
+
setTimeout(function() { t.remove() }, 2000)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function copyText(text) {
|
|
95
|
+
navigator.clipboard.writeText(text).then(function() { toast('Copied') })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Import ──────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
async function importKey(input) {
|
|
101
|
+
input = input.trim()
|
|
102
|
+
if (!input) return
|
|
103
|
+
|
|
104
|
+
if (input.startsWith('txo:') || isValidTxoUri(input)) {
|
|
105
|
+
try {
|
|
106
|
+
var parsed = parseTxoUri(input)
|
|
107
|
+
var exists = vouchers.some(function(v) { return v.txid === parsed.txid && v.vout === parsed.output })
|
|
108
|
+
if (exists) { toast('Already in pool'); return }
|
|
109
|
+
var v = {
|
|
110
|
+
id: Date.now().toString(36),
|
|
111
|
+
txid: parsed.txid, vout: parsed.output,
|
|
112
|
+
amount: toSats(parsed.amount),
|
|
113
|
+
privkey: parsed.privkey || parsed.key || '',
|
|
114
|
+
address: '', network: 'btc',
|
|
115
|
+
status: 'unknown',
|
|
116
|
+
dateAdded: new Date().toISOString()
|
|
117
|
+
}
|
|
118
|
+
if (v.privkey) {
|
|
119
|
+
try {
|
|
120
|
+
var decoded = await decodeKey(v.privkey)
|
|
121
|
+
v.address = privkeyToAddress(decoded.privkey, decoded.testnet)
|
|
122
|
+
} catch {}
|
|
123
|
+
}
|
|
124
|
+
vouchers.unshift(v)
|
|
125
|
+
saveToStorage()
|
|
126
|
+
toast('Voucher imported')
|
|
127
|
+
renderApp()
|
|
128
|
+
refreshStatus(vouchers.indexOf(v))
|
|
129
|
+
return
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.warn('TXO parse failed, trying as WIF:', e)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
var decoded = await decodeKey(input)
|
|
137
|
+
var address = privkeyToAddress(decoded.privkey, decoded.testnet)
|
|
138
|
+
var keyless = vouchers.filter(function(v) { return !v.privkey })
|
|
139
|
+
var matched = false
|
|
140
|
+
|
|
141
|
+
if (keyless.length > 0) {
|
|
142
|
+
var utxos = await fetchUtxos(address)
|
|
143
|
+
for (var kv of keyless) {
|
|
144
|
+
var matchesUtxo = utxos.some(function(u) { return u.txid === kv.txid && u.vout === kv.vout })
|
|
145
|
+
if (matchesUtxo) {
|
|
146
|
+
kv.privkey = input
|
|
147
|
+
kv.address = address
|
|
148
|
+
kv.status = 'unspent'
|
|
149
|
+
var u = utxos.find(function(u) { return u.txid === kv.txid && u.vout === kv.vout })
|
|
150
|
+
if (u && u.value) kv.amount = u.value
|
|
151
|
+
matched = true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (matched) {
|
|
155
|
+
saveToStorage()
|
|
156
|
+
toast('Key matched to voucher')
|
|
157
|
+
renderApp()
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
toast('Looking up UTXOs\u2026')
|
|
163
|
+
var utxos = await fetchUtxos(address)
|
|
164
|
+
if (utxos.length === 0) { toast('No UTXOs found'); return }
|
|
165
|
+
|
|
166
|
+
var added = 0
|
|
167
|
+
for (var ux of utxos) {
|
|
168
|
+
var exists = vouchers.some(function(v) { return v.txid === ux.txid && v.vout === ux.vout })
|
|
169
|
+
if (exists) continue
|
|
170
|
+
vouchers.unshift({
|
|
171
|
+
id: Date.now().toString(36) + added,
|
|
172
|
+
txid: ux.txid, vout: ux.vout,
|
|
173
|
+
amount: ux.value,
|
|
174
|
+
privkey: input, address: address,
|
|
175
|
+
network: 'btc',
|
|
176
|
+
status: ux.status && ux.status.confirmed ? 'unspent' : 'unconfirmed',
|
|
177
|
+
dateAdded: new Date().toISOString()
|
|
178
|
+
})
|
|
179
|
+
added++
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (added === 0) toast('All UTXOs already in pool')
|
|
183
|
+
else { saveToStorage(); toast('Imported ' + added + ' voucher' + (added > 1 ? 's' : '')) }
|
|
184
|
+
renderApp()
|
|
185
|
+
} catch (e) {
|
|
186
|
+
toast('Import failed: ' + e.message)
|
|
187
|
+
console.error(e)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Refresh status ──────────────────────────────────
|
|
192
|
+
|
|
193
|
+
async function refreshStatus(index) {
|
|
194
|
+
if (index !== undefined) {
|
|
195
|
+
var v = vouchers[index]
|
|
196
|
+
if (!v) return
|
|
197
|
+
var result = await checkOutspend(v.txid, v.vout)
|
|
198
|
+
if (result) {
|
|
199
|
+
var newStatus = result.spent ? 'spent' : 'unspent'
|
|
200
|
+
if (v.status !== newStatus) {
|
|
201
|
+
v.status = newStatus
|
|
202
|
+
saveToStorage()
|
|
203
|
+
}
|
|
204
|
+
renderApp()
|
|
205
|
+
}
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
refreshing = true
|
|
210
|
+
renderApp()
|
|
211
|
+
var changed = false
|
|
212
|
+
for (var i = 0; i < vouchers.length; i++) {
|
|
213
|
+
try {
|
|
214
|
+
var result = await checkOutspend(vouchers[i].txid, vouchers[i].vout)
|
|
215
|
+
if (result) {
|
|
216
|
+
var newStatus = result.spent ? 'spent' : 'unspent'
|
|
217
|
+
if (vouchers[i].status !== newStatus) {
|
|
218
|
+
vouchers[i].status = newStatus
|
|
219
|
+
changed = true
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch {}
|
|
223
|
+
}
|
|
224
|
+
if (changed) saveToStorage()
|
|
225
|
+
refreshing = false
|
|
226
|
+
renderApp()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Split ───────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
async function confirmSplit(voucherId) {
|
|
232
|
+
var v = vouchers.find(function(x) { return x.id === voucherId })
|
|
233
|
+
if (!v || !v.privkey || v.status !== 'unspent') {
|
|
234
|
+
toast('Voucher must be unspent with a key'); return
|
|
235
|
+
}
|
|
236
|
+
var countEl = container.querySelector('.v-split-input')
|
|
237
|
+
var numSplits = parseInt(countEl && countEl.value) || 2
|
|
238
|
+
if (numSplits < 2 || numSplits > 100) { toast('Split into 2\u2013100 outputs'); return }
|
|
239
|
+
|
|
240
|
+
var decoded = await decodeKey(v.privkey)
|
|
241
|
+
var txDetails = await fetchTxDetails(v.txid)
|
|
242
|
+
var prevOut = txDetails.vout[v.vout]
|
|
243
|
+
if (!prevOut) { toast('Could not find output'); return }
|
|
244
|
+
var scriptPubKey = hexToU8(prevOut.scriptpubkey)
|
|
245
|
+
|
|
246
|
+
var feeRate = await getFeeRate()
|
|
247
|
+
var vsize = estimateVsize(1, numSplits)
|
|
248
|
+
var fee = Math.ceil(vsize * feeRate)
|
|
249
|
+
var available = v.amount - fee
|
|
250
|
+
if (available <= 0) { toast('Not enough for fee'); return }
|
|
251
|
+
|
|
252
|
+
var perOutput = Math.floor(available / numSplits)
|
|
253
|
+
if (perOutput <= 546) { toast('Split amounts too small (dust)'); return }
|
|
254
|
+
|
|
255
|
+
if (!confirm('Split ' + v.amount.toLocaleString() + ' sats into ' + numSplits + ' outputs of ~' + perOutput.toLocaleString() + ' sats?\nFee: ' + fee + ' sats (' + feeRate + ' sat/vB)')) return
|
|
256
|
+
|
|
257
|
+
toast('Building transaction\u2026')
|
|
258
|
+
var outputs = []
|
|
259
|
+
var remaining = available
|
|
260
|
+
for (var i = 0; i < numSplits; i++) {
|
|
261
|
+
var amt = i === numSplits - 1 ? remaining : perOutput
|
|
262
|
+
outputs.push({ amount: amt, scriptPubKey: scriptPubKey })
|
|
263
|
+
remaining -= amt
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
var rawTx = await buildTransaction(
|
|
267
|
+
[{ txid: v.txid, vout: v.vout, amount: v.amount, scriptPubKey: scriptPubKey }],
|
|
268
|
+
outputs, decoded.privkey
|
|
269
|
+
)
|
|
270
|
+
var newTxid = await broadcastTx(rawTx)
|
|
271
|
+
toast('Split broadcast! ' + truncate(newTxid))
|
|
272
|
+
|
|
273
|
+
v.status = 'spent'
|
|
274
|
+
for (var i = 0; i < outputs.length; i++) {
|
|
275
|
+
vouchers.unshift({
|
|
276
|
+
id: Date.now().toString(36) + i,
|
|
277
|
+
txid: newTxid, vout: i,
|
|
278
|
+
amount: outputs[i].amount,
|
|
279
|
+
privkey: v.privkey, address: v.address,
|
|
280
|
+
network: 'btc', status: 'unspent',
|
|
281
|
+
dateAdded: new Date().toISOString()
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
splitTarget = null
|
|
285
|
+
saveToStorage()
|
|
286
|
+
renderApp()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Merge ───────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
async function mergeVouchers() {
|
|
292
|
+
var selected = []
|
|
293
|
+
mergeSelected.forEach(function(id) {
|
|
294
|
+
var v = vouchers.find(function(x) { return x.id === id })
|
|
295
|
+
if (v) selected.push(v)
|
|
296
|
+
})
|
|
297
|
+
if (selected.length < 2) { toast('Select at least 2'); return }
|
|
298
|
+
if (selected.some(function(v) { return !v.privkey || v.status !== 'unspent' })) {
|
|
299
|
+
toast('All must be unspent with keys'); return
|
|
300
|
+
}
|
|
301
|
+
var key = selected[0].privkey
|
|
302
|
+
if (!selected.every(function(v) { return v.privkey === key })) {
|
|
303
|
+
toast('All must use the same key'); return
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
var decoded = await decodeKey(key)
|
|
307
|
+
var inputs = []
|
|
308
|
+
for (var sv of selected) {
|
|
309
|
+
var details = await fetchTxDetails(sv.txid)
|
|
310
|
+
var out = details.vout[sv.vout]
|
|
311
|
+
if (!out) { toast('Could not find output for ' + truncate(sv.txid)); return }
|
|
312
|
+
inputs.push({ txid: sv.txid, vout: sv.vout, amount: sv.amount, scriptPubKey: hexToU8(out.scriptpubkey) })
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
var feeRate = await getFeeRate()
|
|
316
|
+
var vsize = estimateVsize(selected.length, 1)
|
|
317
|
+
var fee = Math.ceil(vsize * feeRate)
|
|
318
|
+
var totalInput = selected.reduce(function(s, v) { return s + v.amount }, 0)
|
|
319
|
+
var outputAmount = totalInput - fee
|
|
320
|
+
if (outputAmount <= 546) { toast('Not enough for fee'); return }
|
|
321
|
+
|
|
322
|
+
if (!confirm('Merge ' + selected.length + ' vouchers (' + totalInput.toLocaleString() + ' sats) into one?\nOutput: ' + outputAmount.toLocaleString() + ' sats\nFee: ' + fee + ' sats (' + feeRate + ' sat/vB)')) return
|
|
323
|
+
|
|
324
|
+
toast('Building transaction\u2026')
|
|
325
|
+
var rawTx = await buildTransaction(inputs, [{ amount: outputAmount, scriptPubKey: inputs[0].scriptPubKey }], decoded.privkey)
|
|
326
|
+
var newTxid = await broadcastTx(rawTx)
|
|
327
|
+
toast('Merge broadcast! ' + truncate(newTxid))
|
|
328
|
+
|
|
329
|
+
for (var sv of selected) sv.status = 'spent'
|
|
330
|
+
vouchers.unshift({
|
|
331
|
+
id: Date.now().toString(36),
|
|
332
|
+
txid: newTxid, vout: 0,
|
|
333
|
+
amount: outputAmount,
|
|
334
|
+
privkey: key, address: selected[0].address,
|
|
335
|
+
network: 'btc', status: 'unspent',
|
|
336
|
+
dateAdded: new Date().toISOString()
|
|
337
|
+
})
|
|
338
|
+
mergeSelected.clear()
|
|
339
|
+
mergeMode = false
|
|
340
|
+
saveToStorage()
|
|
341
|
+
renderApp()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Add key to voucher ──────────────────────────────
|
|
345
|
+
|
|
346
|
+
async function submitKey(voucherId) {
|
|
347
|
+
var input = container.querySelector('.v-key-input')
|
|
348
|
+
var wif = input && input.value.trim()
|
|
349
|
+
if (!wif) { toast('Paste a key'); return }
|
|
350
|
+
var v = vouchers.find(function(x) { return x.id === voucherId })
|
|
351
|
+
if (!v) return
|
|
352
|
+
try {
|
|
353
|
+
var decoded = await decodeKey(wif)
|
|
354
|
+
var address = privkeyToAddress(decoded.privkey, decoded.testnet)
|
|
355
|
+
var utxos = await fetchUtxos(address)
|
|
356
|
+
var match = utxos.find(function(u) { return u.txid === v.txid && u.vout === v.vout })
|
|
357
|
+
if (!match) { toast('Key does not match this UTXO'); return }
|
|
358
|
+
v.privkey = wif
|
|
359
|
+
v.address = address
|
|
360
|
+
v.status = 'unspent'
|
|
361
|
+
if (match.value) v.amount = match.value
|
|
362
|
+
addKeyTarget = null
|
|
363
|
+
saveToStorage()
|
|
364
|
+
toast('Key added!')
|
|
365
|
+
renderApp()
|
|
366
|
+
} catch (e) {
|
|
367
|
+
toast('Invalid key: ' + e.message)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Render ───────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
function renderApp() {
|
|
374
|
+
var unspent = vouchers.filter(function(v) { return v.status === 'unspent' })
|
|
375
|
+
var totalSats = unspent.reduce(function(s, v) { return s + (v.amount || 0) }, 0)
|
|
376
|
+
var spentCount = vouchers.filter(function(v) { return v.status === 'spent' }).length
|
|
377
|
+
var canMerge = vouchers.filter(function(v) { return v.privkey && v.status === 'unspent' }).length >= 2
|
|
378
|
+
|
|
379
|
+
var mergeSats = 0
|
|
380
|
+
mergeSelected.forEach(function(id) {
|
|
381
|
+
var v = vouchers.find(function(x) { return x.id === id })
|
|
382
|
+
if (v) mergeSats += v.amount || 0
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
render(container, html`
|
|
386
|
+
<style>
|
|
387
|
+
.v-wrap { padding: 0 16px 40px; }
|
|
388
|
+
.v-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
|
389
|
+
.v-title { font-size: 1.5em; font-weight: 800; display: flex; align-items: center; gap: 10px; }
|
|
390
|
+
.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; }
|
|
391
|
+
.v-hero { text-align: center; padding: 32px 0 28px; }
|
|
392
|
+
.v-hero-label { font-size: 0.78rem; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px; }
|
|
393
|
+
.v-hero-sats { font-size: 2.8rem; font-weight: 800; letter-spacing: -0.02em; }
|
|
394
|
+
.v-hero-btc { font-size: 0.95rem; color: rgba(255,255,255,0.35); margin-top: 2px; }
|
|
395
|
+
.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); }
|
|
396
|
+
.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; }
|
|
397
|
+
.v-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
|
|
398
|
+
.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; }
|
|
399
|
+
.v-stat-val { font-size: 1.5rem; font-weight: 700; }
|
|
400
|
+
.v-stat-label { font-size: 0.7rem; color: rgba(255,255,255,0.35); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
|
|
401
|
+
.v-import-row { display: flex; gap: 8px; }
|
|
402
|
+
.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; }
|
|
403
|
+
.v-input::placeholder { color: rgba(255,255,255,0.25); }
|
|
404
|
+
.v-input:focus { border-color: rgba(124,58,237,0.5); }
|
|
405
|
+
.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; }
|
|
406
|
+
.v-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.15); color: #fff; }
|
|
407
|
+
.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); }
|
|
408
|
+
.v-btn-primary:hover { box-shadow: 0 6px 24px rgba(124,58,237,0.3); }
|
|
409
|
+
.v-btn-sm { padding: 5px 10px; font-size: 0.78rem; border-radius: 6px; }
|
|
410
|
+
.v-btn-icon { padding: 5px 8px; font-size: 0.82rem; border-radius: 6px; min-width: 30px; justify-content: center; }
|
|
411
|
+
.v-btn-danger { color: #ef4444; }
|
|
412
|
+
.v-btn-danger:hover { background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.3); }
|
|
413
|
+
.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; }
|
|
414
|
+
.v-item:hover { background: rgba(255,255,255,0.06); }
|
|
415
|
+
.v-item-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
|
416
|
+
.v-item-amount { font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
|
417
|
+
.v-item-amount small { font-size: 0.7em; font-weight: 400; color: rgba(255,255,255,0.35); }
|
|
418
|
+
.v-item-actions { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
419
|
+
.v-item-details { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 0.82rem; }
|
|
420
|
+
.v-item-label { color: rgba(255,255,255,0.3); }
|
|
421
|
+
.v-item-val { color: rgba(255,255,255,0.6); font-family: 'SF Mono', 'Fira Code', monospace; word-break: break-all; cursor: pointer; }
|
|
422
|
+
.v-item-val:hover { color: rgba(255,255,255,0.9); }
|
|
423
|
+
.v-badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 6px; font-size: 0.72rem; font-weight: 600; }
|
|
424
|
+
.v-badge-unspent { background: rgba(16,185,129,0.12); border: 1px solid rgba(16,185,129,0.3); color: #10b981; }
|
|
425
|
+
.v-badge-spent { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: #ef4444; }
|
|
426
|
+
.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); }
|
|
427
|
+
.v-badge-locked { background: rgba(251,191,36,0.1); border: 1px solid rgba(251,191,36,0.3); color: #fbbf24; }
|
|
428
|
+
.v-badge-dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
429
|
+
.v-empty { text-align: center; padding: 32px; color: rgba(255,255,255,0.25); font-style: italic; }
|
|
430
|
+
.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; }
|
|
431
|
+
@keyframes v-fade { from { opacity: 0; transform: translateX(-50%) translateY(8px); } }
|
|
432
|
+
.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; }
|
|
433
|
+
@keyframes v-spin { to { transform: rotate(360deg); } }
|
|
434
|
+
.v-help { font-size: 0.78rem; color: rgba(255,255,255,0.25); margin-top: 10px; line-height: 1.5; }
|
|
435
|
+
.v-help code { background: rgba(255,255,255,0.06); padding: 1px 5px; border-radius: 4px; font-size: 0.92em; }
|
|
436
|
+
.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; }
|
|
437
|
+
.v-expand { margin-top: 10px; }
|
|
438
|
+
@media (max-width: 600px) {
|
|
439
|
+
.v-stats { grid-template-columns: 1fr; }
|
|
440
|
+
.v-hero-sats { font-size: 2rem; }
|
|
441
|
+
.v-import-row { flex-direction: column; }
|
|
442
|
+
}
|
|
443
|
+
</style>
|
|
444
|
+
|
|
445
|
+
<div class="v-wrap">
|
|
446
|
+
<div class="v-header">
|
|
447
|
+
<div class="v-title">\uD83C\uDFAB Voucher Pool <span class="v-net">TESTNET4</span></div>
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
<div class="v-hero">
|
|
451
|
+
<div class="v-hero-label">Available Balance</div>
|
|
452
|
+
<div class="v-hero-sats">${totalSats.toLocaleString()} <span style="font-size:0.4em;color:rgba(255,255,255,0.35)">sats</span></div>
|
|
453
|
+
<div class="v-hero-btc">${satsBtc(totalSats)} tBTC</div>
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
<div class="v-stats">
|
|
457
|
+
<div class="v-stat">
|
|
458
|
+
<div class="v-stat-val">${String(vouchers.length)}</div>
|
|
459
|
+
<div class="v-stat-label">Total Vouchers</div>
|
|
460
|
+
</div>
|
|
461
|
+
<div class="v-stat">
|
|
462
|
+
<div class="v-stat-val" style="color:#10b981">${String(unspent.length)}</div>
|
|
463
|
+
<div class="v-stat-label">Unspent</div>
|
|
464
|
+
</div>
|
|
465
|
+
<div class="v-stat">
|
|
466
|
+
<div class="v-stat-val" style="color:#ef4444">${String(spentCount)}</div>
|
|
467
|
+
<div class="v-stat-label">Spent</div>
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
|
|
471
|
+
<div class="v-card">
|
|
472
|
+
<h2>Import Voucher</h2>
|
|
473
|
+
<div class="v-import-row">
|
|
474
|
+
<input class="v-input v-import-input" placeholder="Paste hex key, WIF, or TXO URI\u2026"
|
|
475
|
+
onkeydown="${function(e) { if (e.key === 'Enter') doImport() }}"
|
|
476
|
+
oninput="${function(e) { if (isHexKey(e.target.value.trim())) doImport() }}" />
|
|
477
|
+
<button class="v-btn v-btn-primary" onclick="${doImport}">
|
|
478
|
+
${importing ? html`<span class="v-spinner"></span>` : 'Import'}
|
|
479
|
+
</button>
|
|
480
|
+
</div>
|
|
481
|
+
<div class="v-help">
|
|
482
|
+
Accepts 64-char hex keys, WIF private keys (testnet4), or TXO URIs.
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
<div class="v-card">
|
|
487
|
+
<h2>
|
|
488
|
+
Vouchers
|
|
489
|
+
<div style="display:flex;gap:6px">
|
|
490
|
+
${canMerge ? html`
|
|
491
|
+
<button class="${'v-btn v-btn-sm' + (mergeMode ? ' v-btn-merge-active' : '')}"
|
|
492
|
+
style="${mergeMode ? 'color:#a78bfa;border-color:rgba(167,139,250,0.4)' : ''}"
|
|
493
|
+
onclick="${function() { mergeMode = !mergeMode; mergeSelected.clear(); renderApp() }}">
|
|
494
|
+
${mergeMode ? '\u2716 Cancel' : '\uD83D\uDD00 Merge'}
|
|
495
|
+
</button>
|
|
496
|
+
` : null}
|
|
497
|
+
<button class="v-btn v-btn-sm" onclick="${function() { refreshStatus() }}"
|
|
498
|
+
disabled="${refreshing}">
|
|
499
|
+
${refreshing ? html`<span class="v-spinner"></span> Checking\u2026` : '\u21BB Refresh'}
|
|
500
|
+
</button>
|
|
501
|
+
${vouchers.length > 0 ? html`
|
|
502
|
+
<button class="v-btn v-btn-sm v-btn-danger" onclick="${clearAll}">\u2716 Clear All</button>
|
|
503
|
+
` : null}
|
|
504
|
+
</div>
|
|
505
|
+
</h2>
|
|
506
|
+
|
|
507
|
+
${vouchers.length === 0 ? html`
|
|
508
|
+
<div class="v-empty">No vouchers yet. Import a key or TXO URI to get started.</div>
|
|
509
|
+
` : null}
|
|
510
|
+
|
|
511
|
+
${keyed(vouchers, function(v) { return v.id }, function(v) {
|
|
512
|
+
return renderVoucherItem(v)
|
|
513
|
+
})}
|
|
514
|
+
|
|
515
|
+
${mergeMode && mergeSelected.size >= 2 ? html`
|
|
516
|
+
<div class="v-merge-bar">
|
|
517
|
+
<span style="font-size:0.88rem">
|
|
518
|
+
Merge <strong>${String(mergeSelected.size)}</strong> vouchers (${mergeSats.toLocaleString()} sats)
|
|
519
|
+
</span>
|
|
520
|
+
<button class="v-btn v-btn-primary" onclick="${function() { mergeVouchers().catch(function(e) { toast('Merge failed: ' + e.message) }) }}">
|
|
521
|
+
\uD83D\uDD00 Merge Now
|
|
522
|
+
</button>
|
|
523
|
+
</div>
|
|
524
|
+
` : null}
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
`)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ── Voucher item template ───────────────────────────
|
|
531
|
+
|
|
532
|
+
function renderVoucherItem(v) {
|
|
533
|
+
var hasKey = !!v.privkey
|
|
534
|
+
var badgeClass = v.status === 'unspent' ? 'v-badge v-badge-unspent'
|
|
535
|
+
: v.status === 'spent' ? 'v-badge v-badge-spent'
|
|
536
|
+
: 'v-badge v-badge-unknown'
|
|
537
|
+
var badgeLabel = v.status === 'unspent' ? 'Unspent' : v.status === 'spent' ? 'Spent' : 'Unknown'
|
|
538
|
+
var badgeDotStyle = v.status === 'unspent' ? 'background:#10b981'
|
|
539
|
+
: v.status === 'spent' ? 'background:#ef4444'
|
|
540
|
+
: 'background:rgba(255,255,255,0.3)'
|
|
541
|
+
|
|
542
|
+
var itemStyle = !hasKey ? 'border-color:rgba(251,191,36,0.2)'
|
|
543
|
+
: mergeSelected.has(v.id) ? 'border-color:rgba(167,139,250,0.4)'
|
|
544
|
+
: ''
|
|
545
|
+
|
|
546
|
+
var isRevealed = revealedKeys.has(v.id)
|
|
547
|
+
|
|
548
|
+
return html`
|
|
549
|
+
<div class="v-item" style="${itemStyle}">
|
|
550
|
+
<div class="v-item-top">
|
|
551
|
+
<div class="v-item-amount">
|
|
552
|
+
${mergeMode && hasKey && v.status === 'unspent' ? html`
|
|
553
|
+
<input type="checkbox" checked="${mergeSelected.has(v.id) ? true : false}"
|
|
554
|
+
style="width:16px;height:16px;accent-color:#7c3aed;cursor:pointer"
|
|
555
|
+
onchange="${function(e) {
|
|
556
|
+
if (e.target.checked) mergeSelected.add(v.id)
|
|
557
|
+
else mergeSelected.delete(v.id)
|
|
558
|
+
renderApp()
|
|
559
|
+
}}" />
|
|
560
|
+
` : null}
|
|
561
|
+
${(v.amount || 0).toLocaleString()} ${html`<small>sats</small>`}
|
|
562
|
+
<span class="${badgeClass}"><span class="v-badge-dot" style="${badgeDotStyle}"></span>${badgeLabel}</span>
|
|
563
|
+
${!hasKey ? html`<span class="v-badge v-badge-locked"><span class="v-badge-dot" style="background:#fbbf24"></span>No Key</span>` : null}
|
|
564
|
+
</div>
|
|
565
|
+
<div class="v-item-actions">
|
|
566
|
+
${!hasKey ? html`
|
|
567
|
+
<button class="v-btn v-btn-sm" style="color:#fbbf24;border-color:rgba(251,191,36,0.3)"
|
|
568
|
+
onclick="${function() { addKeyTarget = addKeyTarget === v.id ? null : v.id; renderApp() }}">
|
|
569
|
+
\uD83D\uDD11 Add Key
|
|
570
|
+
</button>
|
|
571
|
+
` : null}
|
|
572
|
+
${hasKey && v.status === 'unspent' && !mergeMode ? html`
|
|
573
|
+
<button class="v-btn v-btn-sm"
|
|
574
|
+
onclick="${function() { splitTarget = splitTarget === v.id ? null : v.id; renderApp() }}">
|
|
575
|
+
\u2702 Split
|
|
576
|
+
</button>
|
|
577
|
+
` : null}
|
|
578
|
+
${hasKey ? html`
|
|
579
|
+
<button class="v-btn v-btn-icon" title="Copy TXO URI"
|
|
580
|
+
onclick="${function() { copyText(buildTxoUri(v)) }}">\u2398</button>
|
|
581
|
+
` : null}
|
|
582
|
+
${hasKey ? html`
|
|
583
|
+
<button class="v-btn v-btn-icon" title="Copy share link"
|
|
584
|
+
onclick="${function() { copyText(location.origin + location.pathname + '?key=' + v.privkey) }}">\uD83D\uDD17</button>
|
|
585
|
+
` : null}
|
|
586
|
+
${hasKey ? html`
|
|
587
|
+
<button class="v-btn v-btn-icon" title="Copy private key"
|
|
588
|
+
onclick="${function() { copyText(v.privkey) }}">\uD83D\uDD11</button>
|
|
589
|
+
` : null}
|
|
590
|
+
<button class="v-btn v-btn-icon v-btn-danger" title="Delete"
|
|
591
|
+
onclick="${function() { vouchers = vouchers.filter(function(x) { return x.id !== v.id }); saveToStorage(); renderApp() }}">\u2716</button>
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
|
|
595
|
+
<div class="v-item-details">
|
|
596
|
+
<span class="v-item-label">TXID</span>
|
|
597
|
+
<a class="v-item-val" href="${'https://mempool.space/testnet4/tx/' + v.txid}" target="_blank" rel="noopener"
|
|
598
|
+
style="color:rgba(167,139,250,0.8);text-decoration:none">
|
|
599
|
+
${truncate(v.txid) + ':' + v.vout}
|
|
600
|
+
</a>
|
|
601
|
+
${v.address ? html`
|
|
602
|
+
<span class="v-item-label">Address</span>
|
|
603
|
+
<span class="v-item-val" onclick="${function() { copyText(v.address) }}">${truncate(v.address, 10, 6)}</span>
|
|
604
|
+
` : null}
|
|
605
|
+
${hasKey ? html`
|
|
606
|
+
<span class="v-item-label">Key</span>
|
|
607
|
+
<span class="v-item-val" style="cursor:pointer;user-select:none"
|
|
608
|
+
onclick="${function() {
|
|
609
|
+
if (isRevealed) revealedKeys.delete(v.id)
|
|
610
|
+
else revealedKeys.add(v.id)
|
|
611
|
+
renderApp()
|
|
612
|
+
}}">
|
|
613
|
+
${isRevealed ? v.privkey : maskKey(v.privkey)}
|
|
614
|
+
${!isRevealed ? html`<span style="font-size:0.8em;color:rgba(255,255,255,0.2)"> (click)</span>` : null}
|
|
615
|
+
</span>
|
|
616
|
+
` : null}
|
|
617
|
+
</div>
|
|
618
|
+
|
|
619
|
+
${v.id === addKeyTarget ? html`
|
|
620
|
+
<div class="v-expand">
|
|
621
|
+
<div class="v-import-row">
|
|
622
|
+
<input class="v-input v-key-input" placeholder="Paste hex or WIF private key\u2026"
|
|
623
|
+
onkeydown="${function(e) { if (e.key === 'Enter') submitKey(v.id) }}"
|
|
624
|
+
oninput="${function(e) { if (isHexKey(e.target.value.trim())) submitKey(v.id) }}" />
|
|
625
|
+
<button class="v-btn v-btn-primary v-btn-sm" onclick="${function() { submitKey(v.id) }}">\uD83D\uDD11 Unlock</button>
|
|
626
|
+
</div>
|
|
627
|
+
</div>
|
|
628
|
+
` : null}
|
|
629
|
+
|
|
630
|
+
${v.id === splitTarget ? html`
|
|
631
|
+
<div class="v-expand">
|
|
632
|
+
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
|
633
|
+
<span style="color:rgba(255,255,255,0.5);font-size:0.82rem">Split into</span>
|
|
634
|
+
<input type="number" class="v-input v-split-input" value="2" min="2" max="100"
|
|
635
|
+
style="width:70px;text-align:center" />
|
|
636
|
+
<span style="color:rgba(255,255,255,0.5);font-size:0.82rem">outputs</span>
|
|
637
|
+
<button class="v-btn v-btn-primary v-btn-sm"
|
|
638
|
+
onclick="${function() { confirmSplit(v.id).catch(function(e) { toast('Split failed: ' + e.message) }) }}">
|
|
639
|
+
\u2702 Confirm
|
|
640
|
+
</button>
|
|
641
|
+
<button class="v-btn v-btn-sm"
|
|
642
|
+
onclick="${function() { splitTarget = null; renderApp() }}">\u2716</button>
|
|
643
|
+
</div>
|
|
644
|
+
</div>
|
|
645
|
+
` : null}
|
|
646
|
+
</div>
|
|
647
|
+
`
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ── Actions ─────────────────────────────────────────
|
|
651
|
+
|
|
652
|
+
async function doImport() {
|
|
653
|
+
if (importing) return
|
|
654
|
+
var input = container.querySelector('.v-import-input')
|
|
655
|
+
if (!input || !input.value.trim()) return
|
|
656
|
+
importing = true
|
|
657
|
+
renderApp()
|
|
658
|
+
await importKey(input.value)
|
|
659
|
+
importing = false
|
|
660
|
+
renderApp()
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function clearAll() {
|
|
664
|
+
if (!confirm('Delete all vouchers?')) return
|
|
665
|
+
vouchers = []
|
|
666
|
+
saveToStorage()
|
|
667
|
+
renderApp()
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ── Init ────────────────────────────────────────────
|
|
671
|
+
|
|
672
|
+
renderApp()
|
|
673
|
+
|
|
674
|
+
// Auto-import from ?key= parameter
|
|
675
|
+
if (window.__pendingImport) {
|
|
676
|
+
var keyToImport = window.__pendingImport
|
|
677
|
+
delete window.__pendingImport
|
|
678
|
+
importing = true
|
|
679
|
+
renderApp()
|
|
680
|
+
importKey(keyToImport).then(function() {
|
|
681
|
+
importing = false
|
|
682
|
+
renderApp()
|
|
683
|
+
})
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// No auto-refresh on load — it triggers PUT which causes live-reload loop.
|
|
687
|
+
// User clicks "Refresh" manually.
|
|
688
|
+
|
|
689
|
+
onUnmount(container, function() {
|
|
690
|
+
// Cleanup if needed
|
|
691
|
+
})
|
|
692
|
+
}
|
|
693
|
+
}
|