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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/repo.mjs +90 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keychat-save",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
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
@@ -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
- fee = roundToSubUnit(feeFor(10 + n * INPUT_BYTES + 9 + scriptBytes + OUTPUT_P2PKH_BYTES))
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 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)`)
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
- const payloadFee = roundToSubUnit(feeFor(10 + INPUT_BYTES + 9 + scriptBytes))
362
- const fundingValue = payloadFee
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 ${((10 + INPUT_BYTES + 9 + scriptBytes) / 1048576).toFixed(2)} MB, ` +
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 + 9 + scriptBytes
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
  }