keychat-save 1.4.2 → 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 +93 -20
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.2",
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 ────────────────────────────────────────────────────
@@ -431,6 +482,11 @@ async function publish (scriptHex, k, address, lockingScript, { dry = false } =
431
482
  const fundingValue = payloadFee + SELF_MARKER_SATS
432
483
  const { picked, sum: fsum, need } = planFunding(utxos, fundingValue)
433
484
  const fundFee = need - fundingValue
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)
434
490
  console.log(`payload tx ${(payloadBytes / 1048576).toFixed(2)} MB, ` +
435
491
  `fee ${payloadFee} sats = ${payloadFee / SATS_PER_COMPUTE} Compute`)
436
492
  console.log(`funding tx ${picked.length} inputs, fee ${fundFee} sats`)
@@ -458,18 +514,35 @@ async function publish (scriptHex, k, address, lockingScript, { dry = false } =
458
514
 
459
515
  // Both must clear the floor BEFORE either goes out. Broadcasting the funding tx and
460
516
  // then discovering the payload is underpaid strands the fee in a spent output.
461
- assertFeeRate(fundTx, fundFee, 'funding tx')
462
- // Miner receives the funding value MINUS the marker — check the floor against
463
- // what the miner actually gets, not against what the tx consumes.
464
- assertFeeRate(payTx, fundingValue - SELF_MARKER_SATS, 'payload tx')
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
+ }
465
526
 
466
527
  if (dry) {
528
+ // Nothing goes to the network on a dry run, so the coins are still ours.
529
+ await releaseLedgerAfterFailure(address)
467
530
  return { txid: payTx.id('hex'), funding: fundTx.id('hex'), cost: (fundingValue + fundFee) / SATS_PER_COMPUTE, dry: true }
468
531
  }
469
532
  process.stdout.write('broadcasting funding tx ... ')
470
- 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
+ }
471
541
  console.log(ftx)
472
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.
473
546
  const ptx = await broadcast(payTx.toHex())
474
547
  console.log(ptx)
475
548
  return { txid: ptx, funding: ftx, cost: (fundingValue + fundFee) / SATS_PER_COMPUTE }