@bsv/wallet-toolbox 2.4.20 → 2.4.22

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 (46) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +32 -0
  3. package/out/src/Wallet.d.ts.map +1 -1
  4. package/out/src/Wallet.js +14 -1
  5. package/out/src/Wallet.js.map +1 -1
  6. package/out/src/storage/StorageIdb.d.ts +12 -1
  7. package/out/src/storage/StorageIdb.d.ts.map +1 -1
  8. package/out/src/storage/StorageIdb.js +79 -14
  9. package/out/src/storage/StorageIdb.js.map +1 -1
  10. package/out/src/storage/StorageKnex.d.ts +5 -2
  11. package/out/src/storage/StorageKnex.d.ts.map +1 -1
  12. package/out/src/storage/StorageKnex.js +58 -2
  13. package/out/src/storage/StorageKnex.js.map +1 -1
  14. package/out/src/storage/StorageProvider.d.ts +7 -1
  15. package/out/src/storage/StorageProvider.d.ts.map +1 -1
  16. package/out/src/storage/StorageProvider.js +30 -1
  17. package/out/src/storage/StorageProvider.js.map +1 -1
  18. package/out/src/storage/idbHelpers.d.ts.map +1 -1
  19. package/out/src/storage/idbHelpers.js +2 -0
  20. package/out/src/storage/idbHelpers.js.map +1 -1
  21. package/out/src/storage/methods/actionBatch.d.ts.map +1 -1
  22. package/out/src/storage/methods/actionBatch.js +2 -14
  23. package/out/src/storage/methods/actionBatch.js.map +1 -1
  24. package/out/src/storage/methods/availableManagedChange.d.ts +10 -0
  25. package/out/src/storage/methods/availableManagedChange.d.ts.map +1 -0
  26. package/out/src/storage/methods/availableManagedChange.js +25 -0
  27. package/out/src/storage/methods/availableManagedChange.js.map +1 -0
  28. package/out/src/storage/methods/createAction.d.ts.map +1 -1
  29. package/out/src/storage/methods/createAction.js +374 -120
  30. package/out/src/storage/methods/createAction.js.map +1 -1
  31. package/out/src/storage/methods/getBeefForTransaction.d.ts.map +1 -1
  32. package/out/src/storage/methods/getBeefForTransaction.js +23 -4
  33. package/out/src/storage/methods/getBeefForTransaction.js.map +1 -1
  34. package/out/src/storage/remoting/StorageClient.d.ts.map +1 -1
  35. package/out/src/storage/remoting/StorageClient.js +2 -6
  36. package/out/src/storage/remoting/StorageClient.js.map +1 -1
  37. package/out/src/storage/remoting/StorageMobile.d.ts.map +1 -1
  38. package/out/src/storage/remoting/StorageMobile.js +2 -6
  39. package/out/src/storage/remoting/StorageMobile.js.map +1 -1
  40. package/out/src/storage/schema/KnexMigrations.d.ts +1 -0
  41. package/out/src/storage/schema/KnexMigrations.d.ts.map +1 -1
  42. package/out/src/storage/schema/KnexMigrations.js +14 -1
  43. package/out/src/storage/schema/KnexMigrations.js.map +1 -1
  44. package/out/src/storage/schema/StorageIdbSchema.d.ts +2 -0
  45. package/out/src/storage/schema/StorageIdbSchema.d.ts.map +1 -1
  46. package/package.json +5 -4
@@ -11,7 +11,6 @@ const utilityHelpers_noBuffer_1 = require("../../utility/utilityHelpers.noBuffer
11
11
  const EntityProvenTx_1 = require("../schema/entities/EntityProvenTx");
12
12
  const Wallet_1 = require("../../Wallet");
13
13
  const offsetKey_1 = require("./offsetKey");
14
- const utils_1 = require("./utils");
15
14
  const sdk_2 = require("../../sdk");
16
15
  const managedChange_1 = require("./managedChange");
17
16
  const actionPlanning_1 = require("./actionPlanning");
@@ -20,6 +19,31 @@ function setDisableDoubleSpendCheckForTest(v) {
20
19
  disableDoubleSpendCheckForTest = v;
21
20
  }
22
21
  async function createAction(storage, auth, vargs, _originator) {
22
+ if (!storage.telemetry.enabled)
23
+ return await createActionCore(storage, auth, vargs);
24
+ return await storage.telemetry.withSpan('wallet.storage.create_action', {
25
+ component: 'wallet-storage',
26
+ carrier: vargs,
27
+ attributes: {
28
+ 'action.fixed_input_count': vargs.inputs.length,
29
+ 'action.fixed_output_count': vargs.outputs.length,
30
+ 'action.known_txid_count': vargs.options.knownTxids?.length ?? 0,
31
+ 'action.is_delayed': vargs.isDelayed,
32
+ 'action.is_no_send': vargs.isNoSend
33
+ }
34
+ }, async (span) => {
35
+ const result = await createActionCore(storage, auth, vargs, span);
36
+ span.end({
37
+ attributes: {
38
+ 'action.result_input_count': result.inputs.length,
39
+ 'action.result_output_count': result.outputs.length,
40
+ 'action.input_beef_bytes': result.inputBeef?.length ?? 0
41
+ }
42
+ });
43
+ return result;
44
+ });
45
+ }
46
+ async function createActionCore(storage, auth, vargs, parent) {
23
47
  const logger = vargs.logger;
24
48
  logger?.group('storage createAction');
25
49
  // stampLog(vargs, `start storage createTransactionSdk`)
@@ -44,39 +68,59 @@ async function createAction(storage, auth, vargs, _originator) {
44
68
  * - Create result inputs with source locking scripts
45
69
  * - Create result outputs with new locking scripts.
46
70
  * - Create and return result.
47
- */
71
+ */
48
72
  const userId = auth.userId;
49
- const { storageBeef, beef, xinputs } = await validateRequiredInputs(storage, userId, vargs);
50
- logger?.log('validated required inputs');
51
- const xoutputs = validateRequiredOutputs(storage, userId, vargs);
52
- logger?.log('validated required outputs');
53
- const changeBasketName = 'default';
54
- const changeBasket = (0, utilityHelpers_1.verifyOne)(await storage.findOutputBaskets({
55
- partial: { userId, name: changeBasketName }
56
- }), `Invalid outputGeneration basket "${changeBasketName}"`);
57
- logger?.log('found change basket');
58
- const noSendChangeIn = await validateNoSendChange(storage, userId, vargs, changeBasket);
59
- logger?.log('validated noSendChange');
60
- const availableChangeCount = await storage.countChangeInputs(userId, changeBasket.basketId, !vargs.isDelayed);
61
- logger?.log(`counted change inputs ${availableChangeCount}`);
73
+ const validated = await traceStorageStep(storage, 'wallet.storage.create_action.validate', parent, {
74
+ 'action.fixed_input_count': vargs.inputs.length,
75
+ 'action.fixed_output_count': vargs.outputs.length
76
+ }, async (span) => {
77
+ const requiredInputs = await validateRequiredInputs(storage, userId, vargs);
78
+ logger?.log('validated required inputs');
79
+ const xoutputs = validateRequiredOutputs(storage, userId, vargs);
80
+ logger?.log('validated required outputs');
81
+ const changeBasketName = 'default';
82
+ const changeBasket = (0, utilityHelpers_1.verifyOne)(await storage.findOutputBaskets({
83
+ partial: { userId, name: changeBasketName }
84
+ }), `Invalid outputGeneration basket "${changeBasketName}"`);
85
+ logger?.log('found change basket');
86
+ const noSendChangeIn = await validateNoSendChange(storage, userId, vargs, changeBasket);
87
+ logger?.log('validated noSendChange');
88
+ span?.end({
89
+ attributes: {
90
+ 'action.validated_input_count': requiredInputs.xinputs.length,
91
+ 'action.validated_output_count': xoutputs.length,
92
+ 'action.no_send_change_input_count': noSendChangeIn.length,
93
+ 'action.validated_beef_tx_count': requiredInputs.beef.txs.length
94
+ }
95
+ });
96
+ return { ...requiredInputs, xoutputs, changeBasket, noSendChangeIn };
97
+ });
98
+ const { storageBeef, beef, xinputs, xoutputs, changeBasket, noSendChangeIn } = validated;
62
99
  const feeModel = (0, StorageProvider_1.validateStorageFeeModel)(storage.feeModel);
63
100
  logger?.log(`validated fee model ${JSON.stringify(feeModel)}`);
64
- await preflightInsufficientFundsFastPath(vargs, xinputs, xoutputs, noSendChangeIn, availableChangeCount, feeModel);
65
- logger?.log('passed insufficient-funds preflight');
101
+ const initialFundingPlan = await prepareFundingPlan(storage, { userId, vargs, xinputs, xoutputs, changeBasket, noSendChangeIn, feeModel, parent });
102
+ logger?.log(`planned funding from ${initialFundingPlan.availableChangeCount} change inputs`);
66
103
  let newTx;
67
104
  try {
68
- newTx = await createNewTxRecord(storage, userId, vargs, storageBeef);
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
+ });
69
114
  logger?.log('created new transaction record');
70
115
  const ctx = {
71
116
  xinputs,
72
117
  xoutputs,
73
118
  changeBasket,
74
119
  noSendChangeIn,
75
- availableChangeCount,
76
120
  feeModel,
77
121
  transactionId: newTx.transactionId
78
122
  };
79
- const { allocatedChange, changeOutputs, derivationPrefix, maxPossibleSatoshisAdjustment } = await fundNewTransactionSdk(storage, userId, vargs, ctx);
123
+ const { allocatedChange, changeOutputs, derivationPrefix, maxPossibleSatoshisAdjustment } = await fundNewTransactionSdk(storage, userId, vargs, ctx, initialFundingPlan, parent);
80
124
  logger?.log('funded new transaction');
81
125
  if (maxPossibleSatoshisAdjustment != null) {
82
126
  const a = maxPossibleSatoshisAdjustment;
@@ -87,12 +131,27 @@ async function createAction(storage, auth, vargs, _originator) {
87
131
  }
88
132
  // The satoshis of the transaction is the satoshis we get back in change minus the satoshis we spend.
89
133
  const satoshis = changeOutputs.reduce((a, e) => a + e.satoshis, 0) - allocatedChange.reduce((a, e) => a + e.satoshis, 0);
90
- await storage.updateTransaction(newTx.transactionId, { satoshis });
91
- const { outputs, changeVouts } = await createNewOutputs(storage, userId, vargs, ctx, changeOutputs);
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;
142
+ });
92
143
  logger?.log('created new output records');
93
- const inputBeef = await mergeAllocatedChangeBeefs(storage, userId, vargs, allocatedChange, beef);
144
+ const inputBeef = await mergeAllocatedChangeBeefs(storage, vargs, allocatedChange, beef, parent);
94
145
  logger?.log('merged allocated change beefs');
95
- const inputs = await createNewInputs(storage, userId, vargs, ctx, allocatedChange);
146
+ const inputs = await traceStorageStep(storage, 'wallet.storage.create_action.assemble_inputs', parent, {
147
+ 'action.fixed_input_count': ctx.xinputs.length,
148
+ 'action.funding_input_count': allocatedChange.length,
149
+ 'action.include_source_transactions': vargs.includeAllSourceTransactions
150
+ }, async (span) => {
151
+ const assembled = await createNewInputs(storage, userId, vargs, ctx, allocatedChange);
152
+ span?.end({ attributes: { 'action.result_input_count': assembled.length } });
153
+ return assembled;
154
+ });
96
155
  logger?.log('created new inputs');
97
156
  const r = {
98
157
  reference: newTx.reference,
@@ -362,14 +421,16 @@ async function createNewTxRecord(storage, userId, vargs, storageBeef) {
362
421
  satoshis: 0, // updated after fundingTransaction
363
422
  userId,
364
423
  isOutgoing: true,
365
- inputBEEF: storageBeef.toBinary(),
424
+ inputBEEF: storageBeef,
366
425
  description: vargs.description,
367
426
  txid: undefined,
368
427
  rawTx: undefined
369
428
  };
370
429
  newTx.transactionId = await storage.insertTransaction(newTx);
371
- for (const label of vargs.labels) {
372
- const txLabel = await storage.findOrInsertTxLabel(userId, label);
430
+ const labelNames = [...new Set(vargs.labels)];
431
+ const labels = await storage.findOrInsertTxLabelsBulk(userId, labelNames);
432
+ for (const label of labelNames) {
433
+ const txLabel = labels[label];
373
434
  await storage.findOrInsertTxLabelMap((0, utilityHelpers_1.verifyId)(newTx.transactionId), (0, utilityHelpers_1.verifyId)(txLabel.txLabelId));
374
435
  }
375
436
  return newTx;
@@ -574,10 +635,9 @@ async function validateNoSendChange(storage, userId, vargs, changeBasket) {
574
635
  return [];
575
636
  const noSendChange = vargs.options.noSendChange;
576
637
  if (noSendChange && noSendChange.length > 0) {
638
+ const byOutpoint = await storage.findOutputsByOutpoints(userId, noSendChange);
577
639
  for (const op of noSendChange) {
578
- const output = (0, utilityHelpers_1.verifyOneOrNone)(await storage.findOutputs({
579
- partial: { userId, txid: op.txid, vout: op.vout }
580
- }));
640
+ const output = byOutpoint[`${op.txid}.${op.vout}`];
581
641
  // noSendChange is signed through the same BRC-29 path as allocated change.
582
642
  // It must satisfy the full managed-change policy, not merely share the
583
643
  // default basket or have a P2PKH-shaped locking script.
@@ -599,87 +659,232 @@ async function validateNoSendChange(storage, userId, vargs, changeBasket) {
599
659
  }
600
660
  return r;
601
661
  }
602
- async function preflightInsufficientFundsFastPath(vargs, xinputs, xoutputs, noSendChangeIn, availableChangeCount, feeModel) {
603
- if (feeModel.model !== 'sat/kb' || !feeModel.value)
604
- return;
605
- const fixedInputSatoshis = xinputs.reduce((a, e) => a + e.satoshis, 0);
606
- const noSendSatoshis = noSendChangeIn.reduce((a, e) => a + Number(e.satoshis || 0), 0);
607
- const spending = xoutputs.reduce((a, e) => a + e.satoshis, 0);
608
- const minSize = (0, utils_1.transactionSize)(xinputs.map(i => i.unlockingScriptLength || 0), xoutputs.map(o => Math.floor(o.lockingScript.length / 2)));
609
- const minFee = Math.ceil((minSize / 1000) * feeModel.value);
610
- const minRequired = spending + minFee;
611
- const fixedAvailable = fixedInputSatoshis + noSendSatoshis;
612
- if (fixedAvailable >= minRequired)
613
- return;
614
- // Keep common successful path cheap:
615
- // - If there are zero change candidates, failure is certain.
616
- // Otherwise, defer to the main funding allocator.
617
- const deficit = minRequired - fixedAvailable;
618
- if (availableChangeCount <= 0) {
619
- throw new WERR_errors_1.WERR_INSUFFICIENT_FUNDS(minRequired, deficit);
662
+ class FundingClaimConflict extends Error {
663
+ conflict;
664
+ constructor(conflict) {
665
+ super('createAction funding claim changed concurrently');
666
+ this.conflict = conflict;
620
667
  }
621
668
  }
622
- async function fundNewTransactionSdk(storage, userId, vargs, ctx) {
623
- const params = {
624
- fixedInputs: ctx.xinputs.map(xi => ({
625
- satoshis: xi.satoshis,
626
- unlockingScriptLength: xi.unlockingScriptLength
669
+ async function traceStorageStep(storage, name, parent, attributes, callback) {
670
+ if (!storage.telemetry.enabled)
671
+ return await callback();
672
+ return await storage.telemetry.withSpan(name, { component: 'wallet-storage', parent: parent?.context, attributes }, async (span) => await callback(span));
673
+ }
674
+ function makeFundingParams(vargs, xinputs, xoutputs, changeBasket, feeModel, availableChangeCount) {
675
+ return {
676
+ fixedInputs: xinputs.map(input => ({
677
+ satoshis: input.satoshis,
678
+ unlockingScriptLength: input.unlockingScriptLength
627
679
  })),
628
- fixedOutputs: ctx.xoutputs.map(xo => ({
629
- satoshis: xo.satoshis,
630
- lockingScriptLength: xo.lockingScript.length / 2
680
+ fixedOutputs: xoutputs.map(output => ({
681
+ satoshis: output.satoshis,
682
+ lockingScriptLength: output.lockingScript.length / 2
631
683
  })),
632
- feeModel: ctx.feeModel,
633
- changeInitialSatoshis: Math.max(1, ctx.changeBasket.minimumDesiredUTXOValue),
634
- changeFirstSatoshis: Math.max(1, Math.round(ctx.changeBasket.minimumDesiredUTXOValue / 4)),
684
+ feeModel,
685
+ changeInitialSatoshis: Math.max(1, changeBasket.minimumDesiredUTXOValue),
686
+ changeFirstSatoshis: Math.max(1, Math.round(changeBasket.minimumDesiredUTXOValue / 4)),
635
687
  changeLockingScriptLength: 25,
636
688
  changeUnlockingScriptLength: 107,
637
- targetNetCount: ctx.changeBasket.numberOfDesiredUTXOs - ctx.availableChangeCount,
689
+ targetNetCount: changeBasket.numberOfDesiredUTXOs - availableChangeCount,
638
690
  randomVals: vargs.randomVals
639
691
  };
640
- const noSendChange = [...ctx.noSendChangeIn];
641
- const outputs = {};
642
- const allocateChangeInput = async (targetSatoshis, exactSatoshis) => {
643
- // noSendChange gets allocated first...typically only one input...just allocate in order...
644
- if (noSendChange.length > 0) {
645
- const o = noSendChange.pop();
646
- outputs[o.outputId] = o;
647
- // allocate the output in storage, noSendChange is by definition spendable false and part of noSpend transaction batch.
648
- await storage.updateOutput(o.outputId, {
649
- spendable: false,
650
- spentBy: ctx.transactionId
651
- });
652
- o.spendable = false;
653
- o.spentBy = ctx.transactionId;
654
- const r = {
655
- outputId: o.outputId,
656
- satoshis: o.satoshis
657
- };
658
- return r;
659
- }
660
- const basketId = ctx.changeBasket.basketId;
661
- const o = await storage.allocateChangeInput(userId, basketId, targetSatoshis, exactSatoshis, !vargs.isDelayed, ctx.transactionId);
662
- if (o == null)
663
- return undefined;
664
- outputs[o.outputId] = o;
665
- const r = {
666
- outputId: o.outputId,
667
- satoshis: o.satoshis
692
+ }
693
+ async function prepareFundingPlan(storage, context) {
694
+ const { userId, vargs, xinputs, xoutputs, changeBasket, noSendChangeIn, feeModel, parent } = context;
695
+ const excludeSending = !vargs.isDelayed;
696
+ 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);
698
+ span?.end({
699
+ attributes: {
700
+ 'funding.candidate_count': outputs.length,
701
+ 'funding.candidate_satoshis': outputs.reduce((sum, output) => sum + output.satoshis, 0)
702
+ }
703
+ });
704
+ return outputs;
705
+ });
706
+ const noSendIds = new Set(noSendChangeIn.map(output => output.outputId));
707
+ const available = candidates.filter(output => !noSendIds.has(output.outputId));
708
+ // Preserve the legacy target-net-count input: noSendChange was included in
709
+ // countChangeInputs before it was consumed by the allocator.
710
+ const params = makeFundingParams(vargs, xinputs, xoutputs, changeBasket, feeModel, candidates.length);
711
+ return await traceStorageStep(storage, 'wallet.storage.create_action.funding_plan', parent, {
712
+ 'funding.candidate_count': available.length,
713
+ 'funding.no_send_change_count': noSendChangeIn.length
714
+ }, async (span) => {
715
+ const allocated = new Map();
716
+ const noSend = [...noSendChangeIn];
717
+ const allocate = async (targetSatoshis, exactSatoshis) => {
718
+ let output = noSend.pop();
719
+ output ??= (0, actionPlanning_1.selectCanonicalChange)(available.filter(candidate => !allocated.has(candidate.outputId)), targetSatoshis, exactSatoshis);
720
+ if (output == null)
721
+ return undefined;
722
+ allocated.set(output.outputId, output);
723
+ return { outputId: output.outputId, satoshis: output.satoshis };
668
724
  };
669
- return r;
725
+ const release = async (outputId) => {
726
+ const output = allocated.get(outputId);
727
+ if (output == null)
728
+ return;
729
+ allocated.delete(outputId);
730
+ if (noSendIds.has(outputId))
731
+ noSend.push(output);
732
+ };
733
+ const result = await (0, generateChange_1.generateChangeSdk)(params, allocate, release, vargs.logger, storage.telemetry);
734
+ const selected = result.allocatedChangeInputs.map(input => (0, utilityHelpers_1.verifyTruthy)(allocated.get(input.outputId)));
735
+ span?.end({
736
+ attributes: {
737
+ 'funding.allocated_input_count': selected.length,
738
+ 'funding.change_output_count': result.changeOutputs.length,
739
+ 'funding.fee_satoshis': result.fee,
740
+ 'funding.transaction_size_bytes': result.size
741
+ }
742
+ });
743
+ return {
744
+ params,
745
+ result,
746
+ selected,
747
+ availableChangeCount: candidates.length
748
+ };
749
+ });
750
+ }
751
+ async function claimFundingPlan(storage, userId, basketId, excludeSending, transactionId, noSendChangeIn, plan) {
752
+ if (plan.selected.length === 0) {
753
+ return { outputs: [], sourceTransactionCount: 0, hydratedScriptCount: 0, scriptSourceTransactionCount: 0 };
754
+ }
755
+ const noSendIds = new Set(noSendChangeIn.map(output => output.outputId));
756
+ const statuses = ['completed', 'unproven'];
757
+ if (!excludeSending)
758
+ 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);
769
+ const claimed = [];
770
+ 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);
775
+ if (current?.outputId !== planned.outputId ||
776
+ current?.satoshis !== planned.satoshis ||
777
+ current?.basketId !== basketId ||
778
+ !(0, managedChange_1.isAutoSpendableChangeOutput)(current) ||
779
+ reserved.has(current?.outputId ?? -1) ||
780
+ validTransaction !== true) {
781
+ return { conflict: noSendIds.has(planned.outputId) ? 'noSendChange' : 'candidate' };
782
+ }
783
+ claimed.push(current);
784
+ }
785
+ const updated = await storage.markChangeInputsSpent(claimed.map(output => output.outputId), transactionId, trx);
786
+ if (updated !== claimed.length) {
787
+ throw new FundingClaimConflict(claimed.some(output => noSendIds.has(output.outputId)) ? 'noSendChange' : 'candidate');
788
+ }
789
+ for (const output of claimed) {
790
+ output.spendable = false;
791
+ output.spentBy = transactionId;
792
+ }
793
+ return { outputs: claimed, sourceTransactionCount: transactionIds.length };
794
+ }).catch(error => {
795
+ if (error instanceof FundingClaimConflict)
796
+ return { conflict: error.conflict };
797
+ throw error;
798
+ });
799
+ if (claim.outputs == null)
800
+ return claim;
801
+ const hydration = await hydrateFundingInputScripts(storage, claim.outputs);
802
+ return {
803
+ outputs: claim.outputs,
804
+ sourceTransactionCount: claim.sourceTransactionCount,
805
+ ...hydration
670
806
  };
671
- const releaseChangeInput = async (outputId) => {
672
- const nsco = ctx.noSendChangeIn.find(o => o.outputId === outputId);
673
- if (nsco != null) {
674
- noSendChange.push(nsco);
675
- return;
807
+ }
808
+ async function hydrateFundingInputScripts(storage, outputs) {
809
+ const missing = outputs.filter(output => output.lockingScript?.length !== output.scriptLength &&
810
+ output.scriptLength != null && output.scriptLength > 0 &&
811
+ output.scriptOffset != null && output.scriptOffset > 0 &&
812
+ output.txid != null && output.txid !== '');
813
+ if (missing.length === 0)
814
+ return { hydratedScriptCount: 0, scriptSourceTransactionCount: 0 };
815
+ const byTxid = new Map();
816
+ for (const output of missing) {
817
+ const txid = (0, utilityHelpers_1.verifyTruthy)(output.txid);
818
+ const group = byTxid.get(txid) ?? [];
819
+ group.push(output);
820
+ byTxid.set(txid, group);
821
+ }
822
+ const groups = [...byTxid.entries()];
823
+ let cursor = 0;
824
+ await Promise.all(Array.from({ length: Math.min(8, groups.length) }, async () => {
825
+ while (cursor < groups.length) {
826
+ const [txid, group] = groups[cursor++];
827
+ if (group.length === 1) {
828
+ await storage.validateOutputScript(group[0]);
829
+ continue;
830
+ }
831
+ const rawTx = await storage.getRawTxOfKnownValidTransaction(txid);
832
+ if (rawTx != null) {
833
+ for (const output of group) {
834
+ output.lockingScript = rawTx.slice(output.scriptOffset, output.scriptOffset + output.scriptLength);
835
+ }
836
+ }
837
+ else {
838
+ for (const output of group)
839
+ await storage.validateOutputScript(output);
840
+ }
676
841
  }
677
- await storage.updateOutput(outputId, {
678
- spendable: true,
679
- spentBy: undefined
680
- });
842
+ }));
843
+ return {
844
+ hydratedScriptCount: missing.filter(output => output.lockingScript?.length === output.scriptLength).length,
845
+ scriptSourceTransactionCount: groups.length
681
846
  };
682
- const gcr = await (0, generateChange_1.generateChangeSdk)(params, allocateChangeInput, releaseChangeInput, vargs.logger, storage.telemetry);
847
+ }
848
+ async function fundNewTransactionSdk(storage, userId, vargs, ctx, initialPlan, parent) {
849
+ let plan = initialPlan;
850
+ let allocatedChange;
851
+ let retryCount = 0;
852
+ await traceStorageStep(storage, 'wallet.storage.create_action.funding_claim', parent, { 'funding.planned_input_count': initialPlan.selected.length }, async (span) => {
853
+ for (let attempt = 0; attempt < 3; attempt++) {
854
+ const claim = await claimFundingPlan(storage, userId, ctx.changeBasket.basketId, !vargs.isDelayed, ctx.transactionId, ctx.noSendChangeIn, plan);
855
+ if (claim.outputs != null) {
856
+ allocatedChange = claim.outputs;
857
+ span?.end({
858
+ attributes: {
859
+ 'funding.claim_retry_count': retryCount,
860
+ 'funding.source_transaction_count': claim.sourceTransactionCount,
861
+ 'funding.hydrated_script_count': claim.hydratedScriptCount,
862
+ 'funding.script_source_transaction_count': claim.scriptSourceTransactionCount
863
+ }
864
+ });
865
+ return;
866
+ }
867
+ if (claim.conflict === 'noSendChange') {
868
+ throw new WERR_errors_1.WERR_INVALID_PARAMETER('noSendChange', 'outputs that remain spendable during action planning');
869
+ }
870
+ retryCount++;
871
+ plan = await prepareFundingPlan(storage, {
872
+ userId,
873
+ vargs,
874
+ xinputs: ctx.xinputs,
875
+ xoutputs: ctx.xoutputs,
876
+ changeBasket: ctx.changeBasket,
877
+ noSendChangeIn: ctx.noSendChangeIn,
878
+ feeModel: ctx.feeModel,
879
+ parent
880
+ });
881
+ }
882
+ throw new WERR_errors_1.WERR_INVALID_OPERATION('wallet funding changed repeatedly during action planning; retry createAction');
883
+ });
884
+ if (allocatedChange == null)
885
+ throw new WERR_errors_1.WERR_INTERNAL('funding plan was not claimed');
886
+ const params = plan.params;
887
+ const gcr = plan.result;
683
888
  const nextRandomVal = () => {
684
889
  let val = 0;
685
890
  if (vargs.randomVals == null || vargs.randomVals.length === 0) {
@@ -715,7 +920,7 @@ async function fundNewTransactionSdk(storage, userId, vargs, ctx) {
715
920
  const derivationPrefix = randomDerivation(16);
716
921
  const r = {
717
922
  maxPossibleSatoshisAdjustment: gcr.maxPossibleSatoshisAdjustment,
718
- allocatedChange: gcr.allocatedChangeInputs.map(i => outputs[i.outputId]),
923
+ allocatedChange,
719
924
  changeOutputs: gcr.changeOutputs.map((o, i) => ({
720
925
  // what we knnow now and can insert into the database for this new transaction's change output
721
926
  created_at: new Date(),
@@ -755,15 +960,31 @@ async function fundNewTransactionSdk(storage, userId, vargs, ctx) {
755
960
  function trimInputBeef(beef, vargs) {
756
961
  if (vargs.options.returnTXIDOnly)
757
962
  return undefined;
758
- const knownTxids = {};
759
- for (const txid of vargs.options.knownTxids || [])
760
- knownTxids[txid] = true;
761
- for (const txid of beef.txs.map(btx => btx.txid))
762
- if (knownTxids[txid])
763
- beef.makeTxidOnly(txid);
963
+ const knownTxids = vargs.options.knownTxids ?? [];
964
+ const hasKnownTxid = makeKnownTxidLookup(knownTxids);
965
+ // The returned BEEF normally contains only a handful of transactions. A
966
+ // direct scan avoids materializing a second full index of a potentially
967
+ // very large wallet history on every successful action.
968
+ for (const btx of beef.txs)
969
+ if (hasKnownTxid(btx.txid))
970
+ beef.makeTxidOnly(btx.txid);
764
971
  return beef.toUint8Array();
765
972
  }
766
- async function mergeAllocatedChangeBeefs(storage, userId, vargs, allocatedChange, beef) {
973
+ function makeKnownTxidLookup(knownTxids) {
974
+ let lookups = 0;
975
+ let indexed;
976
+ return txid => {
977
+ lookups++;
978
+ if (indexed != null)
979
+ return indexed.has(txid);
980
+ if (knownTxids.length > 64 && lookups > 4) {
981
+ indexed = new Set(knownTxids);
982
+ return indexed.has(txid);
983
+ }
984
+ return knownTxids.includes(txid);
985
+ };
986
+ }
987
+ async function mergeAllocatedChangeBeefs(storage, vargs, allocatedChange, beef, parent) {
767
988
  const options = {
768
989
  trustSelf: undefined,
769
990
  knownTxids: vargs.options.knownTxids,
@@ -775,22 +996,55 @@ async function mergeAllocatedChangeBeefs(storage, userId, vargs, allocatedChange
775
996
  };
776
997
  if (vargs.options.returnTXIDOnly)
777
998
  return undefined;
778
- const known = new Set(vargs.options.knownTxids ?? []);
779
- const missing = Array.from(new Set(allocatedChange.map(o => o.txid).filter(txid => beef.findTxid(txid) == null && !known.has(txid))));
999
+ 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))));
780
1004
  const fetched = Array.from({ length: missing.length });
781
1005
  const concurrency = Math.min(8, Math.max(1, missing.length));
782
1006
  let cursor = 0;
783
- await Promise.all(Array.from({ length: concurrency }, async () => {
784
- while (cursor < missing.length) {
785
- const index = cursor++;
786
- fetched[index] = await storage.getBeefForTransaction(missing[index], { ...options, mergeToBeef: undefined });
1007
+ await traceStorageStep(storage, 'wallet.storage.create_action.beef_fetch', parent, {
1008
+ 'beef.allocated_change_count': allocatedChange.length,
1009
+ 'beef.distinct_source_count': new Set(allocatedChange.map(output => output.txid)).size,
1010
+ 'beef.known_txid_count': knownTxids.length,
1011
+ 'beef.missing_source_count': missing.length,
1012
+ 'beef.fetch_concurrency': concurrency
1013
+ }, 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
+ }));
1020
+ span?.end({
1021
+ 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)
1024
+ }
1025
+ });
1026
+ });
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);
787
1032
  }
788
- }));
789
- for (const fetchedBeef of fetched) {
790
- if (fetchedBeef == null)
791
- continue;
792
- beef.mergeBeef(fetchedBeef);
793
- }
794
- return trimInputBeef(beef, vargs);
1033
+ span?.end({
1034
+ attributes: {
1035
+ 'beef.merged_tx_count': beef.txs.length,
1036
+ 'beef.merged_bump_count': beef.bumps.length
1037
+ }
1038
+ });
1039
+ });
1040
+ return await traceStorageStep(storage, 'wallet.storage.create_action.beef_trim_serialize', parent, {
1041
+ 'beef.tx_count': beef.txs.length,
1042
+ 'beef.bump_count': beef.bumps.length,
1043
+ 'beef.known_txid_count': knownTxids.length
1044
+ }, async (span) => {
1045
+ const result = trimInputBeef(beef, vargs);
1046
+ span?.end({ attributes: { 'beef.result_bytes': result?.length ?? 0 } });
1047
+ return result;
1048
+ });
795
1049
  }
796
1050
  //# sourceMappingURL=createAction.js.map