keychat-save 1.4.1 → 1.4.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.
- package/index.mjs +2 -2
- package/package.json +1 -1
- package/repo.mjs +179 -29
package/index.mjs
CHANGED
|
@@ -75,8 +75,8 @@ async function getUnspent (address) {
|
|
|
75
75
|
// The bridge answers in its own shape when our node can serve the tx:
|
|
76
76
|
// { outputs: [ { satoshis, script, index } ] }
|
|
77
77
|
// When it CANNOT — the node is pruned with no -txindex, so getrawtransaction fails for
|
|
78
|
-
// any confirmed tx in a discarded block — the
|
|
79
|
-
//
|
|
78
|
+
// any confirmed tx in a discarded block — the bridge falls through to GorillaPool and
|
|
79
|
+
// the body comes back in the other shape:
|
|
80
80
|
// { vout: [ { value, n, scriptPubKey: { hex } } ] }
|
|
81
81
|
//
|
|
82
82
|
// Callers looked only at `tx.outputs`, so on the WoC shape they iterated an empty array
|
package/package.json
CHANGED
package/repo.mjs
CHANGED
|
@@ -48,6 +48,14 @@ import { createHash } from 'node:crypto'
|
|
|
48
48
|
import fs from 'node:fs'
|
|
49
49
|
import os from 'node:os'
|
|
50
50
|
import path from 'node:path'
|
|
51
|
+
import { installFileLedger } from './spent-ledger-fs.js'
|
|
52
|
+
import { markSpentOutpoints, withoutSpent, pruneConfirmed, rebuildFromNode } from '../src/wallet/spent-ledger.js'
|
|
53
|
+
|
|
54
|
+
// Share one spent-ledger with keychat-save and every other Node writer on this
|
|
55
|
+
// machine. A repo snapshot and a session save draw from the SAME wallet; a
|
|
56
|
+
// snapshot can spend many seconds building a multi-MB tx, and without this a
|
|
57
|
+
// save started in that window picks the same inputs and dies as a double-spend.
|
|
58
|
+
installFileLedger()
|
|
51
59
|
|
|
52
60
|
const CONFIG_DIR = path.join(os.homedir(), '.keychat')
|
|
53
61
|
const WIF_PATH = path.join(CONFIG_DIR, 'identity.wif')
|
|
@@ -75,14 +83,36 @@ function key () {
|
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
// ── bridge ───────────────────────────────────────────────────────
|
|
78
|
-
async function getUnspent (address) {
|
|
86
|
+
async function getUnspent (address, { skipLedger = false } = {}) {
|
|
79
87
|
const res = await fetch(`${BRIDGE}/api/address/${address}/unspent`)
|
|
80
88
|
if (!res.ok) throw new Error(`unspent ${res.status}`)
|
|
81
|
-
|
|
89
|
+
const mapped = (await res.json()).map(u => ({
|
|
82
90
|
txid: u.tx_hash || u.txid,
|
|
83
91
|
vout: u.tx_pos ?? u.vout,
|
|
84
92
|
satoshis: u.value ?? u.satoshis
|
|
85
93
|
}))
|
|
94
|
+
if (skipLedger) return mapped
|
|
95
|
+
// The node says what exists; the ledger only subtracts coins another writer
|
|
96
|
+
// has already claimed. Prune each load so it stays the size of what is in
|
|
97
|
+
// flight rather than growing forever.
|
|
98
|
+
pruneConfirmed(mapped)
|
|
99
|
+
return withoutSpent(mapped)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A snapshot that never reached the network still holds its coins. Re-derive
|
|
104
|
+
* from a fresh node scan rather than un-marking by hand — the node is
|
|
105
|
+
* authoritative at this moment, and a hand-rolled release can be wrong in the
|
|
106
|
+
* direction that double-spends. Failure path only.
|
|
107
|
+
*/
|
|
108
|
+
async function releaseLedgerAfterFailure (address) {
|
|
109
|
+
try {
|
|
110
|
+
const fresh = await getUnspent(address, { skipLedger: true })
|
|
111
|
+
const r = rebuildFromNode(fresh)
|
|
112
|
+
console.warn(` ledger rebuilt from node: ${r.released} released, ${r.dropped} dropped`)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.warn(` could not rebuild ledger: ${err.message}`)
|
|
115
|
+
}
|
|
86
116
|
}
|
|
87
117
|
|
|
88
118
|
async function broadcast (rawHex) {
|
|
@@ -103,24 +133,36 @@ async function broadcast (rawHex) {
|
|
|
103
133
|
// 68 MB of hex in the live web server to satisfy a restore is not worth it. A query
|
|
104
134
|
// string makes its isTxBody regex miss, so the request passes straight through.
|
|
105
135
|
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
136
|
+
// The plain path is still tried on failure — retrievability beats keeping the
|
|
137
|
+
// proxy cache tidy. (It used to also carry the proxy's WhatsOnChain fallback;
|
|
138
|
+
// that was removed 2026-08-28, so the third source below is now the only
|
|
139
|
+
// second opinion once a tx is mined.)
|
|
109
140
|
async function getRawHex (txid) {
|
|
110
141
|
// Three sources, in order of sovereignty:
|
|
111
142
|
// 1. bridge ?raw=1 — our node, bypassing the web proxy's forever-cache.
|
|
112
|
-
// 2. bridge plain — same node
|
|
113
|
-
// 3.
|
|
114
|
-
// with no -txindex, so getrawtransaction CANNOT return a
|
|
115
|
-
// all. Every mined snapshot is
|
|
116
|
-
//
|
|
117
|
-
// chain unreplayable — which breaks restore, not just new
|
|
118
|
-
//
|
|
119
|
-
//
|
|
143
|
+
// 2. bridge plain — same node; the bridge itself falls through to GP.
|
|
144
|
+
// 3. GorillaPool raw — last resort, and it earns its place: our node is
|
|
145
|
+
// PRUNED with no -txindex, so getrawtransaction CANNOT return a
|
|
146
|
+
// confirmed tx at all. Every mined snapshot is unfetchable from us
|
|
147
|
+
// directly, and without a third source a rate-limited proxy made the
|
|
148
|
+
// whole delta chain unreplayable — which breaks restore, not just new
|
|
149
|
+
// deltas.
|
|
150
|
+
//
|
|
151
|
+
// WAS WhatsOnChain until 2026-08-28. WoC TRUNCATES LARGE SCRIPTS, and this
|
|
152
|
+
// function fetches repo snapshots — the multi-MB transactions most likely to
|
|
153
|
+
// be cut short. A truncated body does not error; it decrypts to garbage or
|
|
154
|
+
// fails a hash check, so the failure surfaces as a corrupt restore rather
|
|
155
|
+
// than a fetch error. It also throttled at ~3 req/s, and the responses that
|
|
156
|
+
// lost were the biggest ones. GP serves the same transactions whole and
|
|
157
|
+
// faster — measured: a 4.8 MB snapshot in 3.4s.
|
|
158
|
+
//
|
|
159
|
+
// GP returns raw BYTES, so convert rather than reading text as hex.
|
|
160
|
+
// 429 still gets a backoff rather than being treated as absence; hammering a
|
|
161
|
+
// closed limiter is what turned a transient failure into a hard one.
|
|
120
162
|
const urls = [
|
|
121
163
|
`${BRIDGE}/api/tx/${txid}/hex?raw=1`,
|
|
122
164
|
`${BRIDGE}/api/tx/${txid}/hex`,
|
|
123
|
-
`https://
|
|
165
|
+
`https://ordinals.gorillapool.io/api/tx/${txid}/raw`
|
|
124
166
|
]
|
|
125
167
|
let wait = 500
|
|
126
168
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
@@ -129,6 +171,15 @@ async function getRawHex (txid) {
|
|
|
129
171
|
try { res = await fetch(url) } catch { continue }
|
|
130
172
|
if (res.status === 429) { await new Promise(r => setTimeout(r, wait)); continue }
|
|
131
173
|
if (!res.ok) continue
|
|
174
|
+
// GP's /raw returns BINARY tx bytes, not hex text. Reading it with
|
|
175
|
+
// .text() yields mojibake that fails the hex test below, so the source
|
|
176
|
+
// would silently never work — a fallback that is present but dead is
|
|
177
|
+
// worse than no fallback, because nothing tells you.
|
|
178
|
+
if (url.endsWith('/raw')) {
|
|
179
|
+
const h = Buffer.from(await res.arrayBuffer()).toString('hex')
|
|
180
|
+
if (h && /^[0-9a-f]+$/i.test(h)) return h
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
132
183
|
const ct = res.headers.get('content-type') || ''
|
|
133
184
|
const data = ct.includes('json') ? await res.json() : await res.text()
|
|
134
185
|
const h = (typeof data === 'string' ? data.replace(/"/g, '').trim() : data.hex)
|
|
@@ -137,7 +188,7 @@ async function getRawHex (txid) {
|
|
|
137
188
|
wait = Math.min(wait * 4, 8000)
|
|
138
189
|
await new Promise(r => setTimeout(r, wait))
|
|
139
190
|
}
|
|
140
|
-
throw new Error(`cannot fetch raw tx ${txid} from node, bridge or
|
|
191
|
+
throw new Error(`cannot fetch raw tx ${txid} from node, bridge or GorillaPool`)
|
|
141
192
|
}
|
|
142
193
|
|
|
143
194
|
// ── OP_RETURN ────────────────────────────────────────────────────
|
|
@@ -286,6 +337,39 @@ function addP2PKHInput (tx, k, lockingScript, utxo) {
|
|
|
286
337
|
const INPUT_BYTES = 148 // 36 outpoint + 1 len + ~107 script + 4 sequence
|
|
287
338
|
const OUTPUT_P2PKH_BYTES = 34
|
|
288
339
|
|
|
340
|
+
// Every KeyChat tx leaves a 3-sat output at the sender's own address, so a wallet
|
|
341
|
+
// rebuilds its history by scanning its own dust rather than trusting a server's
|
|
342
|
+
// watch list. RepoChain was the worst offender: the funding path built a payload
|
|
343
|
+
// tx whose ONLY output was the 0-sat OP_RETURN — verified on chain, b8f8b337 has
|
|
344
|
+
// exactly one output — so every full snapshot and every large delta was invisible
|
|
345
|
+
// to output-keyed indexing. That is the same defect that hid 301 of this wallet's
|
|
346
|
+
// transactions, sitting in the tool that writes our backups.
|
|
347
|
+
const SELF_MARKER_SATS = 3
|
|
348
|
+
|
|
349
|
+
const varIntSize = n => n < 0xfd ? 1 : n <= 0xffff ? 3 : n <= 0xffffffff ? 5 : 9
|
|
350
|
+
|
|
351
|
+
// An output is 8 satoshi bytes + the script's length prefix + the script. That prefix
|
|
352
|
+
// is 3 bytes past 252 and 5 past 65,535; this was estimated at a flat 9 (i.e. a 1-byte
|
|
353
|
+
// prefix), so EVERY repo tx was sized 2-4 bytes short of what it actually serialises to.
|
|
354
|
+
const outputBytes = scriptLen => 8 + varIntSize(scriptLen) + scriptLen
|
|
355
|
+
|
|
356
|
+
// The charge is always rounded up to the 0.25-Compute sub-unit — that is deliberate and
|
|
357
|
+
// unchanged. What matters is that the ROUNDING INPUT be the real byte count: when the
|
|
358
|
+
// estimate ran short and the raw fee happened to land just above a 125-sat boundary, the
|
|
359
|
+
// round-up added ~nothing and the tx went out below 100 sats/KB. Under that floor a tx is
|
|
360
|
+
// accepted, given a txid, never mined, and gone hours later (pt31 killed saves pt27/pt28;
|
|
361
|
+
// RepoChain link 7294a683 died the same way and took the keychat-save chain with it).
|
|
362
|
+
function assertFeeRate (tx, fee, label) {
|
|
363
|
+
const bytes = tx.toHex().length / 2
|
|
364
|
+
const rate = fee / (bytes / 1000)
|
|
365
|
+
if (rate < FEE_PER_KB) {
|
|
366
|
+
throw new Error(`${label} fee too low: ${fee} sats for ${bytes} bytes = ` +
|
|
367
|
+
`${rate.toFixed(2)} sats/KB, floor is ${FEE_PER_KB}. Refusing to broadcast — ` +
|
|
368
|
+
`under the floor a tx is accepted, never mined, and silently lost.`)
|
|
369
|
+
}
|
|
370
|
+
return { bytes, rate }
|
|
371
|
+
}
|
|
372
|
+
|
|
289
373
|
/**
|
|
290
374
|
* Consolidate small Compute UTXOs into one output worth exactly `target` sats.
|
|
291
375
|
* Its own miner fee grows with each input added, so the pick converges rather
|
|
@@ -295,7 +379,7 @@ function planFunding (utxos, target) {
|
|
|
295
379
|
let n = 0
|
|
296
380
|
let sum = 0
|
|
297
381
|
for (;;) {
|
|
298
|
-
const bytes = 10 + n * INPUT_BYTES + OUTPUT_P2PKH_BYTES * 2
|
|
382
|
+
const bytes = 10 + varIntSize(n) - 1 + n * INPUT_BYTES + OUTPUT_P2PKH_BYTES * 2
|
|
299
383
|
const need = target + roundToSubUnit(feeFor(bytes))
|
|
300
384
|
if (sum >= need && n > 0) return { picked: utxos.slice(0, n), sum, need }
|
|
301
385
|
if (n >= utxos.length) {
|
|
@@ -327,7 +411,13 @@ async function publish (scriptHex, k, address, lockingScript, { dry = false } =
|
|
|
327
411
|
let sum = 0
|
|
328
412
|
let fee = 0
|
|
329
413
|
while (n < DIRECT_MAX_INPUTS) {
|
|
330
|
-
|
|
414
|
+
// Two P2PKH outputs: change and the self-marker. `spend` is everything that
|
|
415
|
+
// leaves as fee-or-marker; the marker's 3 sats are INSIDE the 125-sat
|
|
416
|
+
// round-up, not added on top of it — adding on top would leave change at
|
|
417
|
+
// -3 mod 125, which is not a Compute denomination and so unspendable.
|
|
418
|
+
// Miner still clears the rate: round-up >= feeFor + 3, so spend - 3 >= feeFor.
|
|
419
|
+
fee = roundToSubUnit(feeFor(10 + varIntSize(n) - 1 + n * INPUT_BYTES +
|
|
420
|
+
outputBytes(scriptBytes) + OUTPUT_P2PKH_BYTES * 2) + SELF_MARKER_SATS)
|
|
331
421
|
if (sum >= fee && n > 0) break
|
|
332
422
|
if (n >= utxos.length) throw new Error('insufficient Compute')
|
|
333
423
|
sum += utxos[n].satoshis
|
|
@@ -335,13 +425,34 @@ async function publish (scriptHex, k, address, lockingScript, { dry = false } =
|
|
|
335
425
|
}
|
|
336
426
|
|
|
337
427
|
if (sum >= fee && n > 0 && n < DIRECT_MAX_INPUTS) {
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
428
|
+
const build = async () => {
|
|
429
|
+
const t = new Transaction()
|
|
430
|
+
for (const u of utxos.slice(0, n)) addP2PKHInput(t, k, lockingScript, u)
|
|
431
|
+
t.addOutput({ lockingScript: opReturn, satoshis: 0 })
|
|
432
|
+
// SELF-MARKER, UNCONDITIONAL — the change output below is conditional and
|
|
433
|
+
// a snapshot that lands exactly would otherwise leave nothing at our address.
|
|
434
|
+
t.addOutput({ lockingScript, satoshis: SELF_MARKER_SATS })
|
|
435
|
+
if (sum - fee > 0) t.addOutput({ lockingScript, satoshis: sum - fee })
|
|
436
|
+
await t.sign()
|
|
437
|
+
return t
|
|
438
|
+
}
|
|
439
|
+
// Price off the SIGNED bytes, not the estimate, then round up to 0.25 Compute as
|
|
440
|
+
// usual. Converges in one pass in practice; the loop is there because raising the
|
|
441
|
+
// fee shrinks change, which can drop the change output and change the size again.
|
|
442
|
+
let tx = await build()
|
|
443
|
+
for (let i = 0; i < 4; i++) {
|
|
444
|
+
const want = roundToSubUnit(feeFor(tx.toHex().length / 2) + SELF_MARKER_SATS)
|
|
445
|
+
if (want <= fee) break
|
|
446
|
+
while (sum < want && n < utxos.length && n < DIRECT_MAX_INPUTS) sum += utxos[n++].satoshis
|
|
447
|
+
if (sum < want) throw new Error(`insufficient Compute for corrected fee ${want} sats`)
|
|
448
|
+
fee = want
|
|
449
|
+
tx = await build()
|
|
450
|
+
}
|
|
451
|
+
// The marker is an OUTPUT, not fee — the miner receives fee minus the marker,
|
|
452
|
+
// and that is the number the floor must be checked against.
|
|
453
|
+
const { bytes, rate } = assertFeeRate(tx, fee - SELF_MARKER_SATS, 'payload tx')
|
|
454
|
+
console.log(`payload tx ${(bytes / 1024).toFixed(1)} KB, ${n} inputs, ` +
|
|
455
|
+
`fee ${fee} sats = ${fee / SATS_PER_COMPUTE} Compute (single tx, ${rate.toFixed(2)} sats/KB)`)
|
|
345
456
|
if (dry) return { txid: tx.id('hex'), funding: null, cost: fee / SATS_PER_COMPUTE, dry: true }
|
|
346
457
|
process.stdout.write('broadcasting ... ')
|
|
347
458
|
const txid = await broadcast(tx.toHex())
|
|
@@ -358,11 +469,25 @@ async function publish (scriptHex, k, address, lockingScript, { dry = false } =
|
|
|
358
469
|
// bought nothing and it ate two repo snapshots anyway. What actually protects this output
|
|
359
470
|
// is the splitter being deposits-only (src/messages/split.js) — it reads the parent tx,
|
|
360
471
|
// sees we signed it, and leaves our change alone.
|
|
361
|
-
|
|
362
|
-
|
|
472
|
+
// The payload tx is single-input with no change, so its fee IS this funding output —
|
|
473
|
+
// it cannot be topped up after the fact without rebuilding both txs. So the estimate
|
|
474
|
+
// has to be right the first time: real output framing, and the input sized at its
|
|
475
|
+
// maximum (a low-S DER signature caps the unlocking script at 107 bytes).
|
|
476
|
+
const payloadBytes = 10 + INPUT_BYTES + outputBytes(scriptBytes) + OUTPUT_P2PKH_BYTES
|
|
477
|
+
const payloadFee = roundToSubUnit(feeFor(payloadBytes))
|
|
478
|
+
// The funding output is the payload tx's ENTIRE input, so it must cover the
|
|
479
|
+
// miner fee AND the self-marker. Without the marker the payload tx had exactly
|
|
480
|
+
// one output — a 0-sat OP_RETURN — and nothing at our address, which made every
|
|
481
|
+
// large snapshot invisible to output-keyed indexing.
|
|
482
|
+
const fundingValue = payloadFee + SELF_MARKER_SATS
|
|
363
483
|
const { picked, sum: fsum, need } = planFunding(utxos, fundingValue)
|
|
364
484
|
const fundFee = need - fundingValue
|
|
365
|
-
|
|
485
|
+
// Claim the inputs before signing. A multi-MB payload can spend tens of
|
|
486
|
+
// seconds in sign(), and any save started in that window would otherwise
|
|
487
|
+
// pick the very same coins. Released below on every path that does not
|
|
488
|
+
// broadcast the funding tx.
|
|
489
|
+
markSpentOutpoints(picked)
|
|
490
|
+
console.log(`payload tx ${(payloadBytes / 1048576).toFixed(2)} MB, ` +
|
|
366
491
|
`fee ${payloadFee} sats = ${payloadFee / SATS_PER_COMPUTE} Compute`)
|
|
367
492
|
console.log(`funding tx ${picked.length} inputs, fee ${fundFee} sats`)
|
|
368
493
|
console.log(`total cost ${(fundingValue + fundFee) / SATS_PER_COMPUTE} Compute\n`)
|
|
@@ -381,18 +506,43 @@ async function publish (scriptHex, k, address, lockingScript, { dry = false } =
|
|
|
381
506
|
const payTx = new Transaction()
|
|
382
507
|
addP2PKHInput(payTx, k, lockingScript, { txid: fundTx.id('hex'), vout: 0, satoshis: fundingValue })
|
|
383
508
|
payTx.addOutput({ lockingScript: opReturn, satoshis: 0 })
|
|
509
|
+
payTx.addOutput({ lockingScript, satoshis: SELF_MARKER_SATS })
|
|
384
510
|
process.stdout.write('signing payload tx ... ')
|
|
385
511
|
t = Date.now()
|
|
386
512
|
await payTx.sign()
|
|
387
513
|
console.log(`${((Date.now() - t) / 1000).toFixed(1)}s`)
|
|
388
514
|
|
|
515
|
+
// Both must clear the floor BEFORE either goes out. Broadcasting the funding tx and
|
|
516
|
+
// then discovering the payload is underpaid strands the fee in a spent output.
|
|
517
|
+
try {
|
|
518
|
+
assertFeeRate(fundTx, fundFee, 'funding tx')
|
|
519
|
+
// Miner receives the funding value MINUS the marker — check the floor against
|
|
520
|
+
// what the miner actually gets, not against what the tx consumes.
|
|
521
|
+
assertFeeRate(payTx, fundingValue - SELF_MARKER_SATS, 'payload tx')
|
|
522
|
+
} catch (err) {
|
|
523
|
+
await releaseLedgerAfterFailure(address)
|
|
524
|
+
throw err
|
|
525
|
+
}
|
|
526
|
+
|
|
389
527
|
if (dry) {
|
|
528
|
+
// Nothing goes to the network on a dry run, so the coins are still ours.
|
|
529
|
+
await releaseLedgerAfterFailure(address)
|
|
390
530
|
return { txid: payTx.id('hex'), funding: fundTx.id('hex'), cost: (fundingValue + fundFee) / SATS_PER_COMPUTE, dry: true }
|
|
391
531
|
}
|
|
392
532
|
process.stdout.write('broadcasting funding tx ... ')
|
|
393
|
-
|
|
533
|
+
let ftx
|
|
534
|
+
try {
|
|
535
|
+
ftx = await broadcast(fundTx.toHex())
|
|
536
|
+
} catch (err) {
|
|
537
|
+
// Funding never left, so `picked` is still ours.
|
|
538
|
+
await releaseLedgerAfterFailure(address)
|
|
539
|
+
throw err
|
|
540
|
+
}
|
|
394
541
|
console.log(ftx)
|
|
395
542
|
process.stdout.write('broadcasting payload tx ... ')
|
|
543
|
+
// Deliberately NO release if the payload fails: the funding tx is already on
|
|
544
|
+
// the network, so `picked` is genuinely spent. Releasing here would hand the
|
|
545
|
+
// picker coins that are gone — the one direction that double-spends.
|
|
396
546
|
const ptx = await broadcast(payTx.toHex())
|
|
397
547
|
console.log(ptx)
|
|
398
548
|
return { txid: ptx, funding: ftx, cost: (fundingValue + fundFee) / SATS_PER_COMPUTE }
|
|
@@ -414,7 +564,7 @@ function measure (repoPath) {
|
|
|
414
564
|
// ECIES adds an ephemeral pubkey, IV, PKCS#7 padding and an HMAC.
|
|
415
565
|
const cipherBytes = tar.length + 200
|
|
416
566
|
const scriptBytes = cipherBytes + 60
|
|
417
|
-
const repoTxBytes = 10 + INPUT_BYTES +
|
|
567
|
+
const repoTxBytes = 10 + INPUT_BYTES + outputBytes(scriptBytes)
|
|
418
568
|
const repoFee = roundToSubUnit(feeFor(repoTxBytes))
|
|
419
569
|
return { name, abs, tar, repoTxBytes, repoFee }
|
|
420
570
|
}
|