keychat-save 1.4.1 → 1.4.2
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/package.json +1 -1
- package/repo.mjs +90 -13
package/package.json
CHANGED
package/repo.mjs
CHANGED
|
@@ -286,6 +286,39 @@ function addP2PKHInput (tx, k, lockingScript, utxo) {
|
|
|
286
286
|
const INPUT_BYTES = 148 // 36 outpoint + 1 len + ~107 script + 4 sequence
|
|
287
287
|
const OUTPUT_P2PKH_BYTES = 34
|
|
288
288
|
|
|
289
|
+
// Every KeyChat tx leaves a 3-sat output at the sender's own address, so a wallet
|
|
290
|
+
// rebuilds its history by scanning its own dust rather than trusting a server's
|
|
291
|
+
// watch list. RepoChain was the worst offender: the funding path built a payload
|
|
292
|
+
// tx whose ONLY output was the 0-sat OP_RETURN — verified on chain, b8f8b337 has
|
|
293
|
+
// exactly one output — so every full snapshot and every large delta was invisible
|
|
294
|
+
// to output-keyed indexing. That is the same defect that hid 301 of this wallet's
|
|
295
|
+
// transactions, sitting in the tool that writes our backups.
|
|
296
|
+
const SELF_MARKER_SATS = 3
|
|
297
|
+
|
|
298
|
+
const varIntSize = n => n < 0xfd ? 1 : n <= 0xffff ? 3 : n <= 0xffffffff ? 5 : 9
|
|
299
|
+
|
|
300
|
+
// An output is 8 satoshi bytes + the script's length prefix + the script. That prefix
|
|
301
|
+
// is 3 bytes past 252 and 5 past 65,535; this was estimated at a flat 9 (i.e. a 1-byte
|
|
302
|
+
// prefix), so EVERY repo tx was sized 2-4 bytes short of what it actually serialises to.
|
|
303
|
+
const outputBytes = scriptLen => 8 + varIntSize(scriptLen) + scriptLen
|
|
304
|
+
|
|
305
|
+
// The charge is always rounded up to the 0.25-Compute sub-unit — that is deliberate and
|
|
306
|
+
// unchanged. What matters is that the ROUNDING INPUT be the real byte count: when the
|
|
307
|
+
// estimate ran short and the raw fee happened to land just above a 125-sat boundary, the
|
|
308
|
+
// round-up added ~nothing and the tx went out below 100 sats/KB. Under that floor a tx is
|
|
309
|
+
// accepted, given a txid, never mined, and gone hours later (pt31 killed saves pt27/pt28;
|
|
310
|
+
// RepoChain link 7294a683 died the same way and took the keychat-save chain with it).
|
|
311
|
+
function assertFeeRate (tx, fee, label) {
|
|
312
|
+
const bytes = tx.toHex().length / 2
|
|
313
|
+
const rate = fee / (bytes / 1000)
|
|
314
|
+
if (rate < FEE_PER_KB) {
|
|
315
|
+
throw new Error(`${label} fee too low: ${fee} sats for ${bytes} bytes = ` +
|
|
316
|
+
`${rate.toFixed(2)} sats/KB, floor is ${FEE_PER_KB}. Refusing to broadcast — ` +
|
|
317
|
+
`under the floor a tx is accepted, never mined, and silently lost.`)
|
|
318
|
+
}
|
|
319
|
+
return { bytes, rate }
|
|
320
|
+
}
|
|
321
|
+
|
|
289
322
|
/**
|
|
290
323
|
* Consolidate small Compute UTXOs into one output worth exactly `target` sats.
|
|
291
324
|
* Its own miner fee grows with each input added, so the pick converges rather
|
|
@@ -295,7 +328,7 @@ function planFunding (utxos, target) {
|
|
|
295
328
|
let n = 0
|
|
296
329
|
let sum = 0
|
|
297
330
|
for (;;) {
|
|
298
|
-
const bytes = 10 + n * INPUT_BYTES + OUTPUT_P2PKH_BYTES * 2
|
|
331
|
+
const bytes = 10 + varIntSize(n) - 1 + n * INPUT_BYTES + OUTPUT_P2PKH_BYTES * 2
|
|
299
332
|
const need = target + roundToSubUnit(feeFor(bytes))
|
|
300
333
|
if (sum >= need && n > 0) return { picked: utxos.slice(0, n), sum, need }
|
|
301
334
|
if (n >= utxos.length) {
|
|
@@ -327,7 +360,13 @@ async function publish (scriptHex, k, address, lockingScript, { dry = false } =
|
|
|
327
360
|
let sum = 0
|
|
328
361
|
let fee = 0
|
|
329
362
|
while (n < DIRECT_MAX_INPUTS) {
|
|
330
|
-
|
|
363
|
+
// Two P2PKH outputs: change and the self-marker. `spend` is everything that
|
|
364
|
+
// leaves as fee-or-marker; the marker's 3 sats are INSIDE the 125-sat
|
|
365
|
+
// round-up, not added on top of it — adding on top would leave change at
|
|
366
|
+
// -3 mod 125, which is not a Compute denomination and so unspendable.
|
|
367
|
+
// Miner still clears the rate: round-up >= feeFor + 3, so spend - 3 >= feeFor.
|
|
368
|
+
fee = roundToSubUnit(feeFor(10 + varIntSize(n) - 1 + n * INPUT_BYTES +
|
|
369
|
+
outputBytes(scriptBytes) + OUTPUT_P2PKH_BYTES * 2) + SELF_MARKER_SATS)
|
|
331
370
|
if (sum >= fee && n > 0) break
|
|
332
371
|
if (n >= utxos.length) throw new Error('insufficient Compute')
|
|
333
372
|
sum += utxos[n].satoshis
|
|
@@ -335,13 +374,34 @@ async function publish (scriptHex, k, address, lockingScript, { dry = false } =
|
|
|
335
374
|
}
|
|
336
375
|
|
|
337
376
|
if (sum >= fee && n > 0 && n < DIRECT_MAX_INPUTS) {
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
377
|
+
const build = async () => {
|
|
378
|
+
const t = new Transaction()
|
|
379
|
+
for (const u of utxos.slice(0, n)) addP2PKHInput(t, k, lockingScript, u)
|
|
380
|
+
t.addOutput({ lockingScript: opReturn, satoshis: 0 })
|
|
381
|
+
// SELF-MARKER, UNCONDITIONAL — the change output below is conditional and
|
|
382
|
+
// a snapshot that lands exactly would otherwise leave nothing at our address.
|
|
383
|
+
t.addOutput({ lockingScript, satoshis: SELF_MARKER_SATS })
|
|
384
|
+
if (sum - fee > 0) t.addOutput({ lockingScript, satoshis: sum - fee })
|
|
385
|
+
await t.sign()
|
|
386
|
+
return t
|
|
387
|
+
}
|
|
388
|
+
// Price off the SIGNED bytes, not the estimate, then round up to 0.25 Compute as
|
|
389
|
+
// usual. Converges in one pass in practice; the loop is there because raising the
|
|
390
|
+
// fee shrinks change, which can drop the change output and change the size again.
|
|
391
|
+
let tx = await build()
|
|
392
|
+
for (let i = 0; i < 4; i++) {
|
|
393
|
+
const want = roundToSubUnit(feeFor(tx.toHex().length / 2) + SELF_MARKER_SATS)
|
|
394
|
+
if (want <= fee) break
|
|
395
|
+
while (sum < want && n < utxos.length && n < DIRECT_MAX_INPUTS) sum += utxos[n++].satoshis
|
|
396
|
+
if (sum < want) throw new Error(`insufficient Compute for corrected fee ${want} sats`)
|
|
397
|
+
fee = want
|
|
398
|
+
tx = await build()
|
|
399
|
+
}
|
|
400
|
+
// The marker is an OUTPUT, not fee — the miner receives fee minus the marker,
|
|
401
|
+
// and that is the number the floor must be checked against.
|
|
402
|
+
const { bytes, rate } = assertFeeRate(tx, fee - SELF_MARKER_SATS, 'payload tx')
|
|
403
|
+
console.log(`payload tx ${(bytes / 1024).toFixed(1)} KB, ${n} inputs, ` +
|
|
404
|
+
`fee ${fee} sats = ${fee / SATS_PER_COMPUTE} Compute (single tx, ${rate.toFixed(2)} sats/KB)`)
|
|
345
405
|
if (dry) return { txid: tx.id('hex'), funding: null, cost: fee / SATS_PER_COMPUTE, dry: true }
|
|
346
406
|
process.stdout.write('broadcasting ... ')
|
|
347
407
|
const txid = await broadcast(tx.toHex())
|
|
@@ -358,11 +418,20 @@ async function publish (scriptHex, k, address, lockingScript, { dry = false } =
|
|
|
358
418
|
// bought nothing and it ate two repo snapshots anyway. What actually protects this output
|
|
359
419
|
// is the splitter being deposits-only (src/messages/split.js) — it reads the parent tx,
|
|
360
420
|
// sees we signed it, and leaves our change alone.
|
|
361
|
-
|
|
362
|
-
|
|
421
|
+
// The payload tx is single-input with no change, so its fee IS this funding output —
|
|
422
|
+
// it cannot be topped up after the fact without rebuilding both txs. So the estimate
|
|
423
|
+
// has to be right the first time: real output framing, and the input sized at its
|
|
424
|
+
// maximum (a low-S DER signature caps the unlocking script at 107 bytes).
|
|
425
|
+
const payloadBytes = 10 + INPUT_BYTES + outputBytes(scriptBytes) + OUTPUT_P2PKH_BYTES
|
|
426
|
+
const payloadFee = roundToSubUnit(feeFor(payloadBytes))
|
|
427
|
+
// The funding output is the payload tx's ENTIRE input, so it must cover the
|
|
428
|
+
// miner fee AND the self-marker. Without the marker the payload tx had exactly
|
|
429
|
+
// one output — a 0-sat OP_RETURN — and nothing at our address, which made every
|
|
430
|
+
// large snapshot invisible to output-keyed indexing.
|
|
431
|
+
const fundingValue = payloadFee + SELF_MARKER_SATS
|
|
363
432
|
const { picked, sum: fsum, need } = planFunding(utxos, fundingValue)
|
|
364
433
|
const fundFee = need - fundingValue
|
|
365
|
-
console.log(`payload tx ${(
|
|
434
|
+
console.log(`payload tx ${(payloadBytes / 1048576).toFixed(2)} MB, ` +
|
|
366
435
|
`fee ${payloadFee} sats = ${payloadFee / SATS_PER_COMPUTE} Compute`)
|
|
367
436
|
console.log(`funding tx ${picked.length} inputs, fee ${fundFee} sats`)
|
|
368
437
|
console.log(`total cost ${(fundingValue + fundFee) / SATS_PER_COMPUTE} Compute\n`)
|
|
@@ -381,11 +450,19 @@ async function publish (scriptHex, k, address, lockingScript, { dry = false } =
|
|
|
381
450
|
const payTx = new Transaction()
|
|
382
451
|
addP2PKHInput(payTx, k, lockingScript, { txid: fundTx.id('hex'), vout: 0, satoshis: fundingValue })
|
|
383
452
|
payTx.addOutput({ lockingScript: opReturn, satoshis: 0 })
|
|
453
|
+
payTx.addOutput({ lockingScript, satoshis: SELF_MARKER_SATS })
|
|
384
454
|
process.stdout.write('signing payload tx ... ')
|
|
385
455
|
t = Date.now()
|
|
386
456
|
await payTx.sign()
|
|
387
457
|
console.log(`${((Date.now() - t) / 1000).toFixed(1)}s`)
|
|
388
458
|
|
|
459
|
+
// Both must clear the floor BEFORE either goes out. Broadcasting the funding tx and
|
|
460
|
+
// 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')
|
|
465
|
+
|
|
389
466
|
if (dry) {
|
|
390
467
|
return { txid: payTx.id('hex'), funding: fundTx.id('hex'), cost: (fundingValue + fundFee) / SATS_PER_COMPUTE, dry: true }
|
|
391
468
|
}
|
|
@@ -414,7 +491,7 @@ function measure (repoPath) {
|
|
|
414
491
|
// ECIES adds an ephemeral pubkey, IV, PKCS#7 padding and an HMAC.
|
|
415
492
|
const cipherBytes = tar.length + 200
|
|
416
493
|
const scriptBytes = cipherBytes + 60
|
|
417
|
-
const repoTxBytes = 10 + INPUT_BYTES +
|
|
494
|
+
const repoTxBytes = 10 + INPUT_BYTES + outputBytes(scriptBytes)
|
|
418
495
|
const repoFee = roundToSubUnit(feeFor(repoTxBytes))
|
|
419
496
|
return { name, abs, tar, repoTxBytes, repoFee }
|
|
420
497
|
}
|