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.
- package/index.mjs +2 -2
- package/package.json +1 -1
- 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
|
|
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 ────────────────────────────────────────────────────
|
|
@@ -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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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 }
|