@bsv/wallet-toolbox 2.4.22 → 2.5.0

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 (73) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +24 -1
  3. package/docs/authentication-observability.md +21 -9
  4. package/out/src/CWIStyleWalletManager.d.ts +31 -3
  5. package/out/src/CWIStyleWalletManager.d.ts.map +1 -1
  6. package/out/src/CWIStyleWalletManager.js +164 -56
  7. package/out/src/CWIStyleWalletManager.js.map +1 -1
  8. package/out/src/Wallet.d.ts +3 -3
  9. package/out/src/Wallet.d.ts.map +1 -1
  10. package/out/src/Wallet.js +45 -19
  11. package/out/src/Wallet.js.map +1 -1
  12. package/out/src/sdk/WalletStorage.interfaces.d.ts +1 -0
  13. package/out/src/sdk/WalletStorage.interfaces.d.ts.map +1 -1
  14. package/out/src/signer/methods/completeSignedTransaction.d.ts.map +1 -1
  15. package/out/src/signer/methods/completeSignedTransaction.js +54 -13
  16. package/out/src/signer/methods/completeSignedTransaction.js.map +1 -1
  17. package/out/src/signer/methods/createAction.d.ts.map +1 -1
  18. package/out/src/signer/methods/createAction.js +14 -10
  19. package/out/src/signer/methods/createAction.js.map +1 -1
  20. package/out/src/signer/methods/verifyUnlockScripts.d.ts.map +1 -1
  21. package/out/src/signer/methods/verifyUnlockScripts.js +257 -21
  22. package/out/src/signer/methods/verifyUnlockScripts.js.map +1 -1
  23. package/out/src/storage/StorageIdb.d.ts +2 -0
  24. package/out/src/storage/StorageIdb.d.ts.map +1 -1
  25. package/out/src/storage/StorageIdb.js +49 -3
  26. package/out/src/storage/StorageIdb.js.map +1 -1
  27. package/out/src/storage/StorageKnex.d.ts +6 -0
  28. package/out/src/storage/StorageKnex.d.ts.map +1 -1
  29. package/out/src/storage/StorageKnex.js +140 -5
  30. package/out/src/storage/StorageKnex.js.map +1 -1
  31. package/out/src/storage/StorageProvider.d.ts +23 -0
  32. package/out/src/storage/StorageProvider.d.ts.map +1 -1
  33. package/out/src/storage/StorageProvider.js +64 -1
  34. package/out/src/storage/StorageProvider.js.map +1 -1
  35. package/out/src/storage/methods/actionPlanning.d.ts +14 -0
  36. package/out/src/storage/methods/actionPlanning.d.ts.map +1 -1
  37. package/out/src/storage/methods/actionPlanning.js +55 -0
  38. package/out/src/storage/methods/actionPlanning.js.map +1 -1
  39. package/out/src/storage/methods/availableManagedChange.d.ts +1 -0
  40. package/out/src/storage/methods/availableManagedChange.d.ts.map +1 -1
  41. package/out/src/storage/methods/availableManagedChange.js.map +1 -1
  42. package/out/src/storage/methods/createAction.d.ts.map +1 -1
  43. package/out/src/storage/methods/createAction.js +225 -131
  44. package/out/src/storage/methods/createAction.js.map +1 -1
  45. package/out/src/storage/methods/generateChange.d.ts.map +1 -1
  46. package/out/src/storage/methods/generateChange.js +29 -14
  47. package/out/src/storage/methods/generateChange.js.map +1 -1
  48. package/out/src/storage/methods/getBeefForTransaction.d.ts +7 -0
  49. package/out/src/storage/methods/getBeefForTransaction.d.ts.map +1 -1
  50. package/out/src/storage/methods/getBeefForTransaction.js +196 -16
  51. package/out/src/storage/methods/getBeefForTransaction.js.map +1 -1
  52. package/out/src/storage/methods/processAction.d.ts.map +1 -1
  53. package/out/src/storage/methods/processAction.js +36 -15
  54. package/out/src/storage/methods/processAction.js.map +1 -1
  55. package/out/src/storage/remoting/KnexSessionManager.d.ts +20 -0
  56. package/out/src/storage/remoting/KnexSessionManager.d.ts.map +1 -1
  57. package/out/src/storage/remoting/KnexSessionManager.js +74 -16
  58. package/out/src/storage/remoting/KnexSessionManager.js.map +1 -1
  59. package/out/src/storage/schema/KnexMigrations.d.ts.map +1 -1
  60. package/out/src/storage/schema/KnexMigrations.js +25 -0
  61. package/out/src/storage/schema/KnexMigrations.js.map +1 -1
  62. package/out/src/storage/schema/entities/EntityProvenTx.d.ts +2 -1
  63. package/out/src/storage/schema/entities/EntityProvenTx.d.ts.map +1 -1
  64. package/out/src/storage/schema/entities/EntityProvenTx.js +8 -3
  65. package/out/src/storage/schema/entities/EntityProvenTx.js.map +1 -1
  66. package/out/src/storage/storageProviderHelpers.d.ts.map +1 -1
  67. package/out/src/storage/storageProviderHelpers.js +8 -2
  68. package/out/src/storage/storageProviderHelpers.js.map +1 -1
  69. package/out/src/utility/ScriptTemplateBRC29.d.ts +3 -2
  70. package/out/src/utility/ScriptTemplateBRC29.d.ts.map +1 -1
  71. package/out/src/utility/ScriptTemplateBRC29.js +13 -5
  72. package/out/src/utility/ScriptTemplateBRC29.js.map +1 -1
  73. package/package.json +4 -2
@@ -98,50 +98,66 @@ async function createActionCore(storage, auth, vargs, parent) {
98
98
  const { storageBeef, beef, xinputs, xoutputs, changeBasket, noSendChangeIn } = validated;
99
99
  const feeModel = (0, StorageProvider_1.validateStorageFeeModel)(storage.feeModel);
100
100
  logger?.log(`validated fee model ${JSON.stringify(feeModel)}`);
101
- const initialFundingPlan = await prepareFundingPlan(storage, { userId, vargs, xinputs, xoutputs, changeBasket, noSendChangeIn, feeModel, parent });
101
+ const initialFundingPlan = await prepareFundingPlan(storage, [userId, vargs, xinputs, xoutputs, changeBasket, noSendChangeIn, feeModel, parent]);
102
102
  logger?.log(`planned funding from ${initialFundingPlan.availableChangeCount} change inputs`);
103
+ // The selected source txids are known before the write transaction begins.
104
+ // Start their one batched proof read now so PXC/network wait and BEEF
105
+ // assembly overlap the independent transaction-record/funding/output writes.
106
+ // A funding-claim retry is handled below by fetching only any residual roots.
107
+ const allocatedBeefPrefetch = startAllocatedChangeBeefPrefetch(storage, vargs, initialFundingPlan.selected, beef, parent);
108
+ const storageBeefBytes = storageBeef.toBinary();
103
109
  let newTx;
110
+ let newTxCommitted = false;
104
111
  try {
105
- const storageBeefBytes = storageBeef.toBinary();
106
- newTx = await traceStorageStep(storage, 'wallet.storage.create_action.create_record', parent, {
107
- 'action.label_count': vargs.labels.length,
108
- 'action.storage_beef_bytes': storageBeefBytes.length
109
- }, async (span) => {
110
- const transaction = await createNewTxRecord(storage, userId, vargs, storageBeefBytes);
111
- span?.end({ attributes: { 'action.transaction_record_created': true } });
112
- return transaction;
113
- });
114
- logger?.log('created new transaction record');
115
- const ctx = {
116
- xinputs,
117
- xoutputs,
118
- changeBasket,
119
- noSendChangeIn,
120
- feeModel,
121
- transactionId: newTx.transactionId
122
- };
123
- const { allocatedChange, changeOutputs, derivationPrefix, maxPossibleSatoshisAdjustment } = await fundNewTransactionSdk(storage, userId, vargs, ctx, initialFundingPlan, parent);
124
- logger?.log('funded new transaction');
125
- if (maxPossibleSatoshisAdjustment != null) {
126
- const a = maxPossibleSatoshisAdjustment;
127
- if (ctx.xoutputs[a.fixedOutputIndex].satoshis !== generateChange_1.maxPossibleSatoshis)
128
- throw new WERR_errors_1.WERR_INTERNAL();
129
- ctx.xoutputs[a.fixedOutputIndex].satoshis = a.satoshis;
130
- logger?.log('adjusted change outputs to max possible');
131
- }
132
- // The satoshis of the transaction is the satoshis we get back in change minus the satoshis we spend.
133
- const satoshis = changeOutputs.reduce((a, e) => a + e.satoshis, 0) - allocatedChange.reduce((a, e) => a + e.satoshis, 0);
134
- const { outputs, changeVouts } = await traceStorageStep(storage, 'wallet.storage.create_action.persist_outputs', parent, {
135
- 'action.fixed_output_count': ctx.xoutputs.length,
136
- 'action.change_output_count': changeOutputs.length
137
- }, async (span) => {
138
- await storage.updateTransaction(newTx.transactionId, { satoshis });
139
- const persisted = await createNewOutputs(storage, userId, vargs, ctx, changeOutputs);
140
- span?.end({ attributes: { 'action.persisted_output_count': persisted.outputs.length } });
141
- return persisted;
112
+ const persisted = await storage.transaction(async (trx) => {
113
+ const initialSatoshis = fundingPlanSatoshis(initialFundingPlan);
114
+ newTx = await traceStorageStep(storage, 'wallet.storage.create_action.create_record', parent, {
115
+ 'action.label_count': vargs.labels.length,
116
+ 'action.storage_beef_bytes': storageBeefBytes.length
117
+ }, async (span) => {
118
+ const transaction = await createNewTxRecord(storage, userId, vargs, storageBeefBytes, initialSatoshis, trx);
119
+ span?.end({ attributes: { 'action.transaction_record_created': true } });
120
+ return transaction;
121
+ });
122
+ logger?.log('created new transaction record');
123
+ const ctx = {
124
+ xinputs,
125
+ xoutputs,
126
+ changeBasket,
127
+ noSendChangeIn,
128
+ feeModel,
129
+ transactionId: newTx.transactionId
130
+ };
131
+ const funded = await fundNewTransactionSdk(storage, userId, vargs, ctx, initialFundingPlan, parent, trx);
132
+ logger?.log('funded new transaction');
133
+ if (funded.maxPossibleSatoshisAdjustment != null) {
134
+ const adjustment = funded.maxPossibleSatoshisAdjustment;
135
+ if (ctx.xoutputs[adjustment.fixedOutputIndex].satoshis !== generateChange_1.maxPossibleSatoshis)
136
+ throw new WERR_errors_1.WERR_INTERNAL();
137
+ ctx.xoutputs[adjustment.fixedOutputIndex].satoshis = adjustment.satoshis;
138
+ logger?.log('adjusted change outputs to max possible');
139
+ }
140
+ const satoshis = funded.changeOutputs.reduce((sum, output) => sum + output.satoshis, 0) -
141
+ funded.allocatedChange.reduce((sum, output) => sum + output.satoshis, 0);
142
+ if (satoshis !== initialSatoshis) {
143
+ await storage.updateTransaction(newTx.transactionId, { satoshis }, trx);
144
+ newTx.satoshis = satoshis;
145
+ }
146
+ const storedOutputs = await traceStorageStep(storage, 'wallet.storage.create_action.persist_outputs', parent, {
147
+ 'action.fixed_output_count': ctx.xoutputs.length,
148
+ 'action.change_output_count': funded.changeOutputs.length
149
+ }, async (span) => {
150
+ const result = await createNewOutputs(storage, userId, vargs, ctx, funded.changeOutputs, trx);
151
+ span?.end({ attributes: { 'action.persisted_output_count': result.outputs.length } });
152
+ return result;
153
+ });
154
+ return { ...funded, ...storedOutputs, ctx };
142
155
  });
156
+ newTxCommitted = true;
157
+ const committedTx = (0, utilityHelpers_1.verifyTruthy)(newTx);
158
+ const { allocatedChange, derivationPrefix, outputs, changeVouts, ctx } = persisted;
143
159
  logger?.log('created new output records');
144
- const inputBeef = await mergeAllocatedChangeBeefs(storage, vargs, allocatedChange, beef, parent);
160
+ const inputBeef = await mergeAllocatedChangeBeefs(storage, vargs, allocatedChange, beef, allocatedBeefPrefetch, parent);
145
161
  logger?.log('merged allocated change beefs');
146
162
  const inputs = await traceStorageStep(storage, 'wallet.storage.create_action.assemble_inputs', parent, {
147
163
  'action.fixed_input_count': ctx.xinputs.length,
@@ -154,9 +170,9 @@ async function createActionCore(storage, auth, vargs, parent) {
154
170
  });
155
171
  logger?.log('created new inputs');
156
172
  const r = {
157
- reference: newTx.reference,
158
- version: newTx.version,
159
- lockTime: newTx.lockTime,
173
+ reference: committedTx.reference,
174
+ version: committedTx.version,
175
+ lockTime: committedTx.lockTime,
160
176
  inputs,
161
177
  outputs,
162
178
  derivationPrefix,
@@ -167,10 +183,20 @@ async function createActionCore(storage, auth, vargs, parent) {
167
183
  return r;
168
184
  }
169
185
  catch (error) {
186
+ // Always let an overlapped database read settle before cleanup destroys or
187
+ // reuses the provider. Its error is surfaced on the success path above;
188
+ // here the construction error remains authoritative.
189
+ await allocatedBeefPrefetch;
170
190
  if (newTx?.transactionId != null) {
171
191
  try {
172
- await storage.updateTransactionStatus('failed', newTx.transactionId);
173
- logger?.log(`marked failed createAction transaction ${newTx.transactionId} after construction error`);
192
+ if (newTxCommitted) {
193
+ await storage.updateTransactionStatus('failed', newTx.transactionId);
194
+ logger?.log(`marked failed createAction transaction ${newTx.transactionId} after construction error`);
195
+ }
196
+ else {
197
+ const failed = await createNewTxRecord(storage, userId, vargs, storageBeefBytes, 0, undefined, 'failed');
198
+ logger?.log(`recorded failed createAction transaction ${failed.transactionId} after rollback`);
199
+ }
174
200
  }
175
201
  catch (cleanupError) {
176
202
  logger?.log(`failed to clean up createAction transaction ${newTx.transactionId}: ${String(cleanupError)}`);
@@ -320,21 +346,9 @@ async function getCompetingBeefForReview(storage, txid) {
320
346
  throw e;
321
347
  }
322
348
  }
323
- /** Randomly reassign vout values across newOutputs using either the provided randomVals or crypto-random bytes. */
324
- /** Insert the output and attach its tags; return the SDK output descriptor. */
325
- async function persistNewOutput(storage, o, tags, txTags, txBaskets) {
326
- o.outputId = await storage.insertOutput(o);
349
+ /** Build the SDK descriptor for a persisted output. */
350
+ function describeNewOutput(o, tags, txBaskets) {
327
351
  const changeVout = o.change && o.purpose === 'change' && o.providedBy === 'storage' ? o.vout : undefined;
328
- for (const tagName of new Set(tags)) {
329
- const tag = txTags[tagName];
330
- await storage.insertOutputTagMap({
331
- outputId: (0, utilityHelpers_1.verifyId)(o.outputId),
332
- outputTagId: (0, utilityHelpers_1.verifyId)(tag.outputTagId),
333
- created_at: new Date(),
334
- updated_at: new Date(),
335
- isDeleted: false
336
- });
337
- }
338
352
  const ro = {
339
353
  vout: (0, utilityHelpers_1.verifyInteger)(o.vout),
340
354
  satoshis: sdk_1.Validation.validateSatoshis(o.satoshis, 'o.satoshis'),
@@ -349,13 +363,28 @@ async function persistNewOutput(storage, o, tags, txTags, txBaskets) {
349
363
  };
350
364
  return { changeVout, ro };
351
365
  }
352
- async function createNewOutputs(storage, userId, vargs, ctx, changeOutputs) {
366
+ /** Insert the output and attach its tags; return the SDK output descriptor. */
367
+ async function persistNewOutput(storage, o, tags, txTags, txBaskets, trx) {
368
+ o.outputId = await storage.insertOutput(o, trx);
369
+ for (const tagName of new Set(tags)) {
370
+ const tag = txTags[tagName];
371
+ await storage.insertOutputTagMap({
372
+ outputId: (0, utilityHelpers_1.verifyId)(o.outputId),
373
+ outputTagId: (0, utilityHelpers_1.verifyId)(tag.outputTagId),
374
+ created_at: new Date(),
375
+ updated_at: new Date(),
376
+ isDeleted: false
377
+ }, trx);
378
+ }
379
+ return describeNewOutput(o, tags, txBaskets);
380
+ }
381
+ async function createNewOutputs(storage, userId, vargs, ctx, changeOutputs, trx) {
353
382
  const txBaskets = {};
354
383
  const basketNames = [...new Set(ctx.xoutputs.map(x => x.basket).filter((v) => !!v))];
355
- Object.assign(txBaskets, await storage.findOrInsertOutputBasketsBulk(userId, basketNames));
384
+ Object.assign(txBaskets, await storage.findOrInsertOutputBasketsBulk(userId, basketNames, trx));
356
385
  const txTags = {};
357
386
  const tagNames = [...new Set(ctx.xoutputs.flatMap(x => x.tags))];
358
- Object.assign(txTags, await storage.findOrInsertOutputTagsBulk(userId, tagNames));
387
+ Object.assign(txTags, await storage.findOrInsertOutputTagsBulk(userId, tagNames, trx));
359
388
  const newOutputs = [];
360
389
  for (const xo of ctx.xoutputs) {
361
390
  const lockingScript = (0, utilityHelpers_noBuffer_1.asArray)(xo.lockingScript);
@@ -371,7 +400,7 @@ async function createNewOutputs(storage, userId, vargs, ctx, changeOutputs) {
371
400
  created_at: now,
372
401
  updated_at: now,
373
402
  commissionId: 0
374
- });
403
+ }, trx);
375
404
  const o = makeDefaultOutput(userId, ctx.transactionId, xo.satoshis, xo.vout);
376
405
  o.lockingScript = lockingScript;
377
406
  o.providedBy = 'storage';
@@ -398,17 +427,25 @@ async function createNewOutputs(storage, userId, vargs, ctx, changeOutputs) {
398
427
  }
399
428
  if (vargs.options.randomizeOutputs)
400
429
  (0, actionPlanning_1.randomizeOutputVouts)(newOutputs.map(output => output.o), vargs.randomVals);
430
+ // The overwhelmingly common path has no output tags. Those rows do not
431
+ // need generated outputIds, so Knex can persist fixed and change outputs in
432
+ // one multi-row statement. Tagged rows retain the established id-dependent
433
+ // insertion path below.
434
+ const untagged = newOutputs.filter(output => output.tags.length === 0);
435
+ await storage.insertOutputs(untagged.map(output => output.o), trx);
401
436
  const outputs = [];
402
437
  const changeVouts = [];
403
438
  for (const { o, tags } of newOutputs) {
404
- const { changeVout, ro } = await persistNewOutput(storage, o, tags, txTags, txBaskets);
439
+ const { changeVout, ro } = tags.length === 0
440
+ ? describeNewOutput(o, tags, txBaskets)
441
+ : await persistNewOutput(storage, o, tags, txTags, txBaskets, trx);
405
442
  if (changeVout !== undefined)
406
443
  changeVouts.push(changeVout);
407
444
  outputs.push(ro);
408
445
  }
409
446
  return { outputs, changeVouts };
410
447
  }
411
- async function createNewTxRecord(storage, userId, vargs, storageBeef) {
448
+ async function createNewTxRecord(storage, userId, vargs, storageBeef, satoshis = 0, trx, status = 'unsigned') {
412
449
  const now = new Date();
413
450
  const newTx = {
414
451
  created_at: now,
@@ -416,9 +453,9 @@ async function createNewTxRecord(storage, userId, vargs, storageBeef) {
416
453
  transactionId: 0,
417
454
  version: vargs.version,
418
455
  lockTime: vargs.lockTime,
419
- status: 'unsigned',
456
+ status,
420
457
  reference: (0, utilityHelpers_1.randomBytesBase64)(12),
421
- satoshis: 0, // updated after fundingTransaction
458
+ satoshis,
422
459
  userId,
423
460
  isOutgoing: true,
424
461
  inputBEEF: storageBeef,
@@ -426,12 +463,12 @@ async function createNewTxRecord(storage, userId, vargs, storageBeef) {
426
463
  txid: undefined,
427
464
  rawTx: undefined
428
465
  };
429
- newTx.transactionId = await storage.insertTransaction(newTx);
466
+ newTx.transactionId = await storage.insertTransaction(newTx, trx);
430
467
  const labelNames = [...new Set(vargs.labels)];
431
- const labels = await storage.findOrInsertTxLabelsBulk(userId, labelNames);
468
+ const labels = await storage.findOrInsertTxLabelsBulk(userId, labelNames, trx);
432
469
  for (const label of labelNames) {
433
470
  const txLabel = labels[label];
434
- await storage.findOrInsertTxLabelMap((0, utilityHelpers_1.verifyId)(newTx.transactionId), (0, utilityHelpers_1.verifyId)(txLabel.txLabelId));
471
+ await storage.findOrInsertTxLabelMap((0, utilityHelpers_1.verifyId)(newTx.transactionId), (0, utilityHelpers_1.verifyId)(txLabel.txLabelId), trx);
435
472
  }
436
473
  return newTx;
437
474
  }
@@ -659,6 +696,10 @@ async function validateNoSendChange(storage, userId, vargs, changeBasket) {
659
696
  }
660
697
  return r;
661
698
  }
699
+ function fundingPlanSatoshis(plan) {
700
+ return plan.result.changeOutputs.reduce((sum, output) => sum + output.satoshis, 0) -
701
+ plan.selected.reduce((sum, output) => sum + output.satoshis, 0);
702
+ }
662
703
  class FundingClaimConflict extends Error {
663
704
  conflict;
664
705
  constructor(conflict) {
@@ -691,10 +732,10 @@ function makeFundingParams(vargs, xinputs, xoutputs, changeBasket, feeModel, ava
691
732
  };
692
733
  }
693
734
  async function prepareFundingPlan(storage, context) {
694
- const { userId, vargs, xinputs, xoutputs, changeBasket, noSendChangeIn, feeModel, parent } = context;
735
+ const [userId, vargs, xinputs, xoutputs, changeBasket, noSendChangeIn, feeModel, parent, trx] = context;
695
736
  const excludeSending = !vargs.isDelayed;
696
737
  const candidates = await traceStorageStep(storage, 'wallet.storage.create_action.funding_candidates', parent, { 'funding.exclude_sending': excludeSending }, async (span) => {
697
- const outputs = await storage.findAvailableManagedChangeInputs(userId, changeBasket.basketId, excludeSending);
738
+ const outputs = await storage.findAvailableManagedChangeInputCandidates(userId, changeBasket.basketId, excludeSending, trx);
698
739
  span?.end({
699
740
  attributes: {
700
741
  'funding.candidate_count': outputs.length,
@@ -713,10 +754,12 @@ async function prepareFundingPlan(storage, context) {
713
754
  'funding.no_send_change_count': noSendChangeIn.length
714
755
  }, async (span) => {
715
756
  const allocated = new Map();
757
+ const availableSelector = new actionPlanning_1.CanonicalChangeSelector(available);
716
758
  const noSend = [...noSendChangeIn];
759
+ const noSendById = new Map(noSendChangeIn.map(output => [output.outputId, output]));
717
760
  const allocate = async (targetSatoshis, exactSatoshis) => {
718
761
  let output = noSend.pop();
719
- output ??= (0, actionPlanning_1.selectCanonicalChange)(available.filter(candidate => !allocated.has(candidate.outputId)), targetSatoshis, exactSatoshis);
762
+ output ??= availableSelector.take(targetSatoshis, exactSatoshis);
720
763
  if (output == null)
721
764
  return undefined;
722
765
  allocated.set(output.outputId, output);
@@ -727,8 +770,10 @@ async function prepareFundingPlan(storage, context) {
727
770
  if (output == null)
728
771
  return;
729
772
  allocated.delete(outputId);
730
- if (noSendIds.has(outputId))
731
- noSend.push(output);
773
+ availableSelector.release(outputId);
774
+ const noSendOutput = noSendById.get(outputId);
775
+ if (noSendOutput != null)
776
+ noSend.push(noSendOutput);
732
777
  };
733
778
  const result = await (0, generateChange_1.generateChangeSdk)(params, allocate, release, vargs.logger, storage.telemetry);
734
779
  const selected = result.allocatedChangeInputs.map(input => (0, utilityHelpers_1.verifyTruthy)(allocated.get(input.outputId)));
@@ -748,7 +793,8 @@ async function prepareFundingPlan(storage, context) {
748
793
  };
749
794
  });
750
795
  }
751
- async function claimFundingPlan(storage, userId, basketId, excludeSending, transactionId, noSendChangeIn, plan) {
796
+ async function claimFundingPlan(storage, request) {
797
+ const [userId, basketId, excludeSending, transactionId, noSendChangeIn, plan, trx] = request;
752
798
  if (plan.selected.length === 0) {
753
799
  return { outputs: [], sourceTransactionCount: 0, hydratedScriptCount: 0, scriptSourceTransactionCount: 0 };
754
800
  }
@@ -756,33 +802,23 @@ async function claimFundingPlan(storage, userId, basketId, excludeSending, trans
756
802
  const statuses = ['completed', 'unproven'];
757
803
  if (!excludeSending)
758
804
  statuses.push('sending');
759
- const claim = await storage.transaction(async (trx) => {
760
- const outpoints = plan.selected.map(output => {
761
- if (output.txid == null)
762
- throw new WERR_errors_1.WERR_INTERNAL('planned change input is missing txid');
763
- return { txid: output.txid, vout: output.vout };
764
- });
765
- const currentByOutpoint = await storage.findOutputsByOutpointsForUpdate(userId, outpoints, trx, true);
766
- const reserved = new Set(await storage.findReservedActionBatchOutputIds(plan.selected.map(output => output.outputId), trx));
767
- const transactionIds = [...new Set(Object.values(currentByOutpoint).map(output => output.transactionId))];
768
- const transactionStatuses = await storage.findTransactionStatusesByIds(userId, transactionIds, trx);
805
+ const claim = await storage.transaction(async (claimTrx) => {
806
+ const currentById = await storage.findFundingOutputsForUpdate(userId, plan.selected.map(output => output.outputId), statuses, claimTrx);
807
+ const transactionIds = [...new Set(Object.values(currentById).map(output => output.transactionId))];
769
808
  const claimed = [];
770
809
  for (const planned of plan.selected) {
771
- const key = `${String(planned.txid)}.${planned.vout}`;
772
- const current = currentByOutpoint[key];
773
- const currentStatus = current == null ? undefined : transactionStatuses.get(current.transactionId);
774
- const validTransaction = currentStatus != null && statuses.includes(currentStatus);
810
+ const current = currentById[planned.outputId];
775
811
  if (current?.outputId !== planned.outputId ||
776
812
  current?.satoshis !== planned.satoshis ||
777
813
  current?.basketId !== basketId ||
778
814
  !(0, managedChange_1.isAutoSpendableChangeOutput)(current) ||
779
- reserved.has(current?.outputId ?? -1) ||
780
- validTransaction !== true) {
815
+ current?.txid !== planned.txid ||
816
+ current?.vout !== planned.vout) {
781
817
  return { conflict: noSendIds.has(planned.outputId) ? 'noSendChange' : 'candidate' };
782
818
  }
783
819
  claimed.push(current);
784
820
  }
785
- const updated = await storage.markChangeInputsSpent(claimed.map(output => output.outputId), transactionId, trx);
821
+ const updated = await storage.markChangeInputsSpent(claimed.map(output => output.outputId), transactionId, claimTrx);
786
822
  if (updated !== claimed.length) {
787
823
  throw new FundingClaimConflict(claimed.some(output => noSendIds.has(output.outputId)) ? 'noSendChange' : 'candidate');
788
824
  }
@@ -791,21 +827,21 @@ async function claimFundingPlan(storage, userId, basketId, excludeSending, trans
791
827
  output.spentBy = transactionId;
792
828
  }
793
829
  return { outputs: claimed, sourceTransactionCount: transactionIds.length };
794
- }).catch(error => {
830
+ }, trx).catch(error => {
795
831
  if (error instanceof FundingClaimConflict)
796
832
  return { conflict: error.conflict };
797
833
  throw error;
798
834
  });
799
835
  if (claim.outputs == null)
800
836
  return claim;
801
- const hydration = await hydrateFundingInputScripts(storage, claim.outputs);
837
+ const hydration = await hydrateFundingInputScripts(storage, claim.outputs, trx);
802
838
  return {
803
839
  outputs: claim.outputs,
804
840
  sourceTransactionCount: claim.sourceTransactionCount,
805
841
  ...hydration
806
842
  };
807
843
  }
808
- async function hydrateFundingInputScripts(storage, outputs) {
844
+ async function hydrateFundingInputScripts(storage, outputs, trx) {
809
845
  const missing = outputs.filter(output => output.lockingScript?.length !== output.scriptLength &&
810
846
  output.scriptLength != null && output.scriptLength > 0 &&
811
847
  output.scriptOffset != null && output.scriptOffset > 0 &&
@@ -825,10 +861,10 @@ async function hydrateFundingInputScripts(storage, outputs) {
825
861
  while (cursor < groups.length) {
826
862
  const [txid, group] = groups[cursor++];
827
863
  if (group.length === 1) {
828
- await storage.validateOutputScript(group[0]);
864
+ await storage.validateOutputScript(group[0], trx);
829
865
  continue;
830
866
  }
831
- const rawTx = await storage.getRawTxOfKnownValidTransaction(txid);
867
+ const rawTx = await storage.getRawTxOfKnownValidTransaction(txid, undefined, undefined, trx);
832
868
  if (rawTx != null) {
833
869
  for (const output of group) {
834
870
  output.lockingScript = rawTx.slice(output.scriptOffset, output.scriptOffset + output.scriptLength);
@@ -836,7 +872,7 @@ async function hydrateFundingInputScripts(storage, outputs) {
836
872
  }
837
873
  else {
838
874
  for (const output of group)
839
- await storage.validateOutputScript(output);
875
+ await storage.validateOutputScript(output, trx);
840
876
  }
841
877
  }
842
878
  }));
@@ -845,13 +881,21 @@ async function hydrateFundingInputScripts(storage, outputs) {
845
881
  scriptSourceTransactionCount: groups.length
846
882
  };
847
883
  }
848
- async function fundNewTransactionSdk(storage, userId, vargs, ctx, initialPlan, parent) {
884
+ async function fundNewTransactionSdk(storage, userId, vargs, ctx, initialPlan, parent, trx) {
849
885
  let plan = initialPlan;
850
886
  let allocatedChange;
851
887
  let retryCount = 0;
852
888
  await traceStorageStep(storage, 'wallet.storage.create_action.funding_claim', parent, { 'funding.planned_input_count': initialPlan.selected.length }, async (span) => {
853
889
  for (let attempt = 0; attempt < 3; attempt++) {
854
- const claim = await claimFundingPlan(storage, userId, ctx.changeBasket.basketId, !vargs.isDelayed, ctx.transactionId, ctx.noSendChangeIn, plan);
890
+ const claim = await claimFundingPlan(storage, [
891
+ userId,
892
+ ctx.changeBasket.basketId,
893
+ !vargs.isDelayed,
894
+ ctx.transactionId,
895
+ ctx.noSendChangeIn,
896
+ plan,
897
+ trx
898
+ ]);
855
899
  if (claim.outputs != null) {
856
900
  allocatedChange = claim.outputs;
857
901
  span?.end({
@@ -868,16 +912,17 @@ async function fundNewTransactionSdk(storage, userId, vargs, ctx, initialPlan, p
868
912
  throw new WERR_errors_1.WERR_INVALID_PARAMETER('noSendChange', 'outputs that remain spendable during action planning');
869
913
  }
870
914
  retryCount++;
871
- plan = await prepareFundingPlan(storage, {
915
+ plan = await prepareFundingPlan(storage, [
872
916
  userId,
873
917
  vargs,
874
- xinputs: ctx.xinputs,
875
- xoutputs: ctx.xoutputs,
876
- changeBasket: ctx.changeBasket,
877
- noSendChangeIn: ctx.noSendChangeIn,
878
- feeModel: ctx.feeModel,
879
- parent
880
- });
918
+ ctx.xinputs,
919
+ ctx.xoutputs,
920
+ ctx.changeBasket,
921
+ ctx.noSendChangeIn,
922
+ ctx.feeModel,
923
+ parent,
924
+ trx
925
+ ]);
881
926
  }
882
927
  throw new WERR_errors_1.WERR_INVALID_OPERATION('wallet funding changed repeatedly during action planning; retry createAction');
883
928
  });
@@ -984,7 +1029,49 @@ function makeKnownTxidLookup(knownTxids) {
984
1029
  return knownTxids.includes(txid);
985
1030
  };
986
1031
  }
987
- async function mergeAllocatedChangeBeefs(storage, vargs, allocatedChange, beef, parent) {
1032
+ function missingAllocatedChangeTxids(allocatedChange, beef, knownTxids) {
1033
+ const hasKnownTxid = makeKnownTxidLookup(knownTxids);
1034
+ return Array.from(new Set(allocatedChange
1035
+ .map(output => (0, utilityHelpers_1.verifyTruthy)(output.txid))
1036
+ .filter(txid => beef.findTxid(txid) == null && !hasKnownTxid(txid))));
1037
+ }
1038
+ function startAllocatedChangeBeefPrefetch(storage, vargs, allocatedChange, beef, parent) {
1039
+ if (vargs.options.returnTXIDOnly)
1040
+ return Promise.resolve({ sourceCount: 0, txids: [] });
1041
+ const knownTxids = vargs.options.knownTxids ?? [];
1042
+ const missing = missingAllocatedChangeTxids(allocatedChange, beef, knownTxids);
1043
+ if (missing.length === 0)
1044
+ return Promise.resolve({ sourceCount: 0, txids: [] });
1045
+ const options = {
1046
+ trustSelf: undefined,
1047
+ knownTxids,
1048
+ ignoreStorage: false,
1049
+ ignoreServices: true,
1050
+ ignoreNewProven: false,
1051
+ minProofLevel: undefined
1052
+ };
1053
+ return traceStorageStep(storage, 'wallet.storage.create_action.beef_prefetch', parent, {
1054
+ 'beef.planned_source_count': allocatedChange.length,
1055
+ 'beef.missing_source_count': missing.length,
1056
+ 'beef.storage_batch_count': missing.length === 0 ? 0 : 1
1057
+ }, async (span) => {
1058
+ const fetched = await storage.getBeefForTransactions(missing, options);
1059
+ span?.end({
1060
+ attributes: {
1061
+ 'beef.fetched_tx_count': fetched.txs.length,
1062
+ 'beef.fetched_bump_count': fetched.bumps.length
1063
+ }
1064
+ });
1065
+ return fetched;
1066
+ }).then(prefetched => ({ beef: prefetched, sourceCount: missing.length, txids: missing }), error => ({ error, sourceCount: missing.length, txids: missing }));
1067
+ }
1068
+ function sameTxids(left, right) {
1069
+ if (left.length !== right.length)
1070
+ return false;
1071
+ const expected = new Set(left);
1072
+ return right.every(txid => expected.has(txid));
1073
+ }
1074
+ async function mergeAllocatedChangeBeefs(storage, vargs, allocatedChange, beef, prefetch, parent) {
988
1075
  const options = {
989
1076
  trustSelf: undefined,
990
1077
  knownTxids: vargs.options.knownTxids,
@@ -997,39 +1084,46 @@ async function mergeAllocatedChangeBeefs(storage, vargs, allocatedChange, beef,
997
1084
  if (vargs.options.returnTXIDOnly)
998
1085
  return undefined;
999
1086
  const knownTxids = vargs.options.knownTxids ?? [];
1000
- const hasKnownTxid = makeKnownTxidLookup(knownTxids);
1001
- const missing = Array.from(new Set(allocatedChange
1002
- .map(output => (0, utilityHelpers_1.verifyTruthy)(output.txid))
1003
- .filter(txid => beef.findTxid(txid) == null && !hasKnownTxid(txid))));
1004
- const fetched = Array.from({ length: missing.length });
1005
- const concurrency = Math.min(8, Math.max(1, missing.length));
1006
- let cursor = 0;
1087
+ const requiredBeforePrefetch = missingAllocatedChangeTxids(allocatedChange, beef, knownTxids);
1088
+ const prefetched = await traceStorageStep(storage, 'wallet.storage.create_action.beef_prefetch_join', parent, { 'beef.prefetch_source_count': 0 }, async (span) => {
1089
+ const result = await prefetch;
1090
+ span?.end({ attributes: { 'beef.prefetch_source_count': result.sourceCount } });
1091
+ return result;
1092
+ });
1093
+ // A claim retry may replace only part of the initial funding plan. Never
1094
+ // merge proofs for outputs the final transaction does not spend: apart from
1095
+ // unnecessary bytes, that would disclose unrelated wallet history.
1096
+ const usePrefetch = sameTxids(prefetched.txids, requiredBeforePrefetch);
1097
+ if (usePrefetch && prefetched.error != null)
1098
+ throw prefetched.error;
1099
+ if (usePrefetch && prefetched.beef != null)
1100
+ beef.mergeBeef(prefetched.beef);
1101
+ // If a concurrent spender forced the funding claim to be replanned, only
1102
+ // the newly selected roots remain. The normal uncontended path is empty.
1103
+ const missing = missingAllocatedChangeTxids(allocatedChange, beef, knownTxids);
1104
+ let fetched;
1007
1105
  await traceStorageStep(storage, 'wallet.storage.create_action.beef_fetch', parent, {
1008
1106
  'beef.allocated_change_count': allocatedChange.length,
1009
1107
  'beef.distinct_source_count': new Set(allocatedChange.map(output => output.txid)).size,
1010
1108
  'beef.known_txid_count': knownTxids.length,
1011
1109
  'beef.missing_source_count': missing.length,
1012
- 'beef.fetch_concurrency': concurrency
1110
+ 'beef.fetch_concurrency': 1,
1111
+ 'beef.prefetch_reused': usePrefetch
1013
1112
  }, async (span) => {
1014
- await Promise.all(Array.from({ length: concurrency }, async () => {
1015
- while (cursor < missing.length) {
1016
- const index = cursor++;
1017
- fetched[index] = await storage.getBeefForTransaction(missing[index], { ...options, mergeToBeef: undefined });
1018
- }
1019
- }));
1113
+ if (missing.length > 0) {
1114
+ fetched = await storage.getBeefForTransactions(missing, { ...options, mergeToBeef: undefined });
1115
+ }
1020
1116
  span?.end({
1021
1117
  attributes: {
1022
- 'beef.fetched_tx_count': fetched.reduce((sum, item) => sum + (item?.txs.length ?? 0), 0),
1023
- 'beef.fetched_bump_count': fetched.reduce((sum, item) => sum + (item?.bumps.length ?? 0), 0)
1118
+ 'beef.fetched_tx_count': fetched?.txs.length ?? 0,
1119
+ 'beef.fetched_bump_count': fetched?.bumps.length ?? 0,
1120
+ 'beef.storage_batch_count': missing.length === 0 ? 0 : 1
1024
1121
  }
1025
1122
  });
1026
1123
  });
1027
- await traceStorageStep(storage, 'wallet.storage.create_action.beef_merge', parent, { 'beef.fragment_count': fetched.length }, async (span) => {
1028
- for (const fetchedBeef of fetched) {
1029
- if (fetchedBeef == null)
1030
- continue;
1031
- beef.mergeBeef(fetchedBeef);
1032
- }
1124
+ await traceStorageStep(storage, 'wallet.storage.create_action.beef_merge', parent, { 'beef.fragment_count': (usePrefetch && prefetched.beef != null ? 1 : 0) + (fetched == null ? 0 : 1) }, async (span) => {
1125
+ if (fetched != null)
1126
+ beef.mergeBeef(fetched);
1033
1127
  span?.end({
1034
1128
  attributes: {
1035
1129
  'beef.merged_tx_count': beef.txs.length,