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.
Files changed (3) hide show
  1. package/index.mjs +2 -2
  2. package/package.json +1 -1
  3. 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 web proxy falls back to WhatsOnChain and
79
- // returns WoC's body untouched:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keychat-save",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Save sessions, files and whole git repositories to the BSV blockchain via KeyChat",
5
5
  "type": "module",
6
6
  "bin": {
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
- return (await res.json()).map(u => ({
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
- // But that same regex miss also skips its WhatsOnChain fallback, which is the only
107
- // second source we have once a tx is mined. So fall back to the plain path on
108
- // failure retrievability beats keeping the proxy cache tidy.
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, but with the proxy's WoC fallback behind it.
113
- // 3. WoC direct — last resort, and it earns its place: our node is PRUNED
114
- // with no -txindex, so getrawtransaction CANNOT return a confirmed tx at
115
- // all. Every mined snapshot is therefore unfetchable from us, and with
116
- // only the first two sources a rate-limited proxy made the whole delta
117
- // chain unreplayable — which breaks restore, not just new deltas.
118
- // 429 gets a backoff rather than being treated as absence; hammering a closed
119
- // limiter is what turned a transient failure into a hard one.
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://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`
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 WoC`)
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
- fee = roundToSubUnit(feeFor(10 + n * INPUT_BYTES + 9 + scriptBytes + OUTPUT_P2PKH_BYTES))
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 tx = new Transaction()
339
- for (const u of utxos.slice(0, n)) addP2PKHInput(tx, k, lockingScript, u)
340
- tx.addOutput({ lockingScript: opReturn, satoshis: 0 })
341
- if (sum - fee > 0) tx.addOutput({ lockingScript, satoshis: sum - fee })
342
- await tx.sign()
343
- console.log(`payload tx ${(tx.toHex().length / 2 / 1024).toFixed(1)} KB, ${n} inputs, ` +
344
- `fee ${fee} sats = ${fee / SATS_PER_COMPUTE} Compute (single tx)`)
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
- const payloadFee = roundToSubUnit(feeFor(10 + INPUT_BYTES + 9 + scriptBytes))
362
- const fundingValue = payloadFee
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
- console.log(`payload tx ${((10 + INPUT_BYTES + 9 + scriptBytes) / 1048576).toFixed(2)} MB, ` +
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
- const ftx = await broadcast(fundTx.toHex())
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 + 9 + scriptBytes
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
  }