@btc-vision/transaction 1.8.7-beta.0 → 1.8.8

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 (36) hide show
  1. package/browser/btc-vision-bitcoin.js +832 -828
  2. package/browser/index.js +1074 -947
  3. package/browser/noble-curves.js +1183 -2156
  4. package/browser/noble-hashes.js +88 -88
  5. package/browser/rolldown-runtime.js +26 -19
  6. package/browser/src/epoch/validator/EpochValidator.d.ts +28 -6
  7. package/browser/src/transaction/builders/TransactionBuilder.d.ts +1 -0
  8. package/browser/src/transaction/offline/OfflineTransactionManager.d.ts +50 -0
  9. package/browser/src/transaction/offline/interfaces/ISerializableState.d.ts +10 -2
  10. package/browser/vendors.js +3267 -3229
  11. package/build/epoch/validator/EpochValidator.d.ts +28 -6
  12. package/build/epoch/validator/EpochValidator.js +44 -10
  13. package/build/transaction/TransactionFactory.js +1 -1
  14. package/build/transaction/builders/FundingTransaction.js +17 -13
  15. package/build/transaction/builders/TransactionBuilder.d.ts +1 -0
  16. package/build/transaction/builders/TransactionBuilder.js +32 -7
  17. package/build/transaction/offline/OfflineTransactionManager.d.ts +50 -0
  18. package/build/transaction/offline/OfflineTransactionManager.js +142 -1
  19. package/build/transaction/offline/TransactionSerializer.js +9 -0
  20. package/build/transaction/offline/interfaces/ISerializableState.d.ts +10 -2
  21. package/build/transaction/offline/interfaces/ISerializableState.js +3 -2
  22. package/build/transaction/shared/TweakedTransaction.js +27 -5
  23. package/package.json +1 -1
  24. package/src/epoch/validator/EpochValidator.ts +67 -6
  25. package/src/transaction/TransactionFactory.ts +1 -1
  26. package/src/transaction/builders/FundingTransaction.ts +21 -16
  27. package/src/transaction/builders/TransactionBuilder.ts +46 -10
  28. package/src/transaction/offline/OfflineTransactionManager.ts +191 -1
  29. package/src/transaction/offline/TransactionSerializer.ts +11 -0
  30. package/src/transaction/offline/interfaces/ISerializableState.ts +10 -2
  31. package/src/transaction/shared/TweakedTransaction.ts +33 -5
  32. package/test/add-refund-output.test.ts +96 -11
  33. package/test/csv-multisig-offline-edges.test.ts +293 -0
  34. package/test/csv-multisig-offline.test.ts +202 -0
  35. package/test/transaction-builders.test.ts +69 -3
  36. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@btc-vision/transaction",
3
3
  "type": "module",
4
- "version": "1.8.7-beta.0",
4
+ "version": "1.8.8",
5
5
  "author": "BlobMaster41",
6
6
  "description": "OPNet transaction library allows you to create and sign transactions for the OPNet network.",
7
7
  "engines": {
@@ -14,18 +14,37 @@ export class EpochValidator {
14
14
  }
15
15
 
16
16
  /**
17
- * Calculate mining preimage
17
+ * Calculate mining preimage.
18
+ *
19
+ * When useConcatenatedPreimage is false the legacy XOR construction is used
20
+ * (checksumRoot ^ publicKey ^ salt). That construction is malleable: a
21
+ * miner can set salt == publicKey to force preimage == checksumRoot (max
22
+ * difficulty for free), or transform any solution to another key via
23
+ * salt' = publicKey' ^ publicKey ^ salt. When useConcatenatedPreimage is true
24
+ * the preimage is the 96-byte concatenation checksumRoot || publicKey || salt,
25
+ * which removes both (the SHA-1 proof-of-work over it is unchanged).
26
+ *
27
+ * The switch is a consensus rule: callers pass useConcatenatedPreimage =
28
+ * (epochBlockHeight >= activationHeight) using the activation height for the
29
+ * network they are on (opnet-node exposes it as
30
+ * consensusEpochPatches.PREIMAGE_CONCAT_PATCH_BLOCK_HEIGHT). The value MUST be
31
+ * identical across opnet-node, this library and the mining pool.
18
32
  */
19
33
  public static calculatePreimage(
20
34
  checksumRoot: Uint8Array,
21
35
  publicKey: Uint8Array,
22
36
  salt: Uint8Array,
37
+ useConcatenatedPreimage: boolean = false,
23
38
  ): Uint8Array {
24
39
  // Ensure all are 32 bytes
25
40
  if (checksumRoot.length !== 32 || publicKey.length !== 32 || salt.length !== 32) {
26
41
  throw new Error('All inputs must be 32 bytes');
27
42
  }
28
43
 
44
+ if (useConcatenatedPreimage) {
45
+ return this.concatenatePreimage(checksumRoot, publicKey, salt);
46
+ }
47
+
29
48
  const preimage = new Uint8Array(32);
30
49
  for (let i = 0; i < 32; i++) {
31
50
  preimage[i] =
@@ -35,6 +54,30 @@ export class EpochValidator {
35
54
  return preimage;
36
55
  }
37
56
 
57
+ /**
58
+ * Non-malleable mining preimage: the 96-byte concatenation
59
+ * checksumRoot || publicKey || salt. The public key sits in its own segment,
60
+ * so a miner-chosen salt can neither cancel it nor be transformed to
61
+ * re-attribute another miner's solution. The SHA-1 proof-of-work over it is
62
+ * unchanged.
63
+ */
64
+ public static concatenatePreimage(
65
+ checksumRoot: Uint8Array,
66
+ publicKey: Uint8Array,
67
+ salt: Uint8Array,
68
+ ): Uint8Array {
69
+ if (checksumRoot.length !== 32 || publicKey.length !== 32 || salt.length !== 32) {
70
+ throw new Error('All inputs must be 32 bytes');
71
+ }
72
+
73
+ const message = new Uint8Array(96);
74
+ message.set(checksumRoot, 0);
75
+ message.set(publicKey, 32);
76
+ message.set(salt, 64);
77
+
78
+ return message;
79
+ }
80
+
38
81
  /**
39
82
  * Count matching bits between two hashes
40
83
  */
@@ -69,13 +112,18 @@ export class EpochValidator {
69
112
  /**
70
113
  * Verify an epoch solution using IPreimage
71
114
  */
72
- public static verifySolution(challenge: IChallengeSolution, log: boolean = false): boolean {
115
+ public static verifySolution(
116
+ challenge: IChallengeSolution,
117
+ log: boolean = false,
118
+ useHashedPreimage: boolean = false,
119
+ ): boolean {
73
120
  try {
74
121
  const verification = challenge.verification;
75
122
  const calculatedPreimage = this.calculatePreimage(
76
123
  verification.targetChecksum,
77
124
  challenge.publicKey.toBuffer(),
78
125
  challenge.salt,
126
+ useHashedPreimage,
79
127
  );
80
128
 
81
129
  const computedSolution = this.sha1(calculatedPreimage);
@@ -118,7 +166,10 @@ export class EpochValidator {
118
166
  /**
119
167
  * Validate epoch winner from raw data
120
168
  */
121
- public static validateEpochWinner(epochData: RawChallenge): boolean {
169
+ public static validateEpochWinner(
170
+ epochData: RawChallenge,
171
+ useHashedPreimage: boolean = false,
172
+ ): boolean {
122
173
  try {
123
174
  const epochNumber = BigInt(epochData.epochNumber);
124
175
  const publicKey = Address.fromString(
@@ -143,6 +194,7 @@ export class EpochValidator {
143
194
  verification.targetChecksum,
144
195
  publicKey.toBuffer(),
145
196
  salt,
197
+ useHashedPreimage,
146
198
  );
147
199
 
148
200
  const computedSolution = this.sha1(calculatedPreimage);
@@ -172,8 +224,11 @@ export class EpochValidator {
172
224
  /**
173
225
  * Validate epoch winner from Preimage instance
174
226
  */
175
- public static validateChallengeSolution(challenge: IChallengeSolution): boolean {
176
- return this.verifySolution(challenge);
227
+ public static validateChallengeSolution(
228
+ challenge: IChallengeSolution,
229
+ useHashedPreimage: boolean = false,
230
+ ): boolean {
231
+ return this.verifySolution(challenge, false, useHashedPreimage);
177
232
  }
178
233
 
179
234
  /**
@@ -187,8 +242,14 @@ export class EpochValidator {
187
242
  targetChecksum: Uint8Array,
188
243
  publicKey: Uint8Array,
189
244
  salt: Uint8Array,
245
+ useHashedPreimage: boolean = false,
190
246
  ): Uint8Array {
191
- const preimage = this.calculatePreimage(targetChecksum, publicKey, salt);
247
+ const preimage = this.calculatePreimage(
248
+ targetChecksum,
249
+ publicKey,
250
+ salt,
251
+ useHashedPreimage,
252
+ );
192
253
  return this.sha1(preimage);
193
254
  }
194
255
 
@@ -556,7 +556,7 @@ export class TransactionFactory {
556
556
  estimatedFees: resp.estimatedFees,
557
557
  tx: resp.tx.toHex(),
558
558
  nextUTXOs: this.getAllNewUTXOs(resp.original, resp.tx, parameters.from),
559
- inputUtxos: parameters.utxos,
559
+ inputUtxos: [...parameters.utxos, ...(parameters.feeUtxos ?? [])],
560
560
  };
561
561
  }
562
562
 
@@ -31,9 +31,12 @@ export class FundingTransaction extends TransactionBuilder<TransactionType.FUNDI
31
31
 
32
32
  this.addInputsFromUTXO();
33
33
 
34
- // When autoAdjustAmount is enabled and the amount would leave no room for fees,
35
- // estimate the fee first and reduce the output amount accordingly.
36
- if (this.autoAdjustAmount && this.amount >= this.totalInputAmount) {
34
+ // When autoAdjustAmount is enabled, estimate the fee against the final
35
+ // transaction shape and reduce the output amount whenever amount + fee
36
+ // would exceed the total input. This covers both the send-max case
37
+ // (amount == totalInput) and the near-total case where amount alone fits
38
+ // but leaves no room for the requested feeRate.
39
+ if (this.autoAdjustAmount) {
37
40
  // Add temporary outputs matching the ACTUAL final transaction shape
38
41
  // so the fee estimate accounts for the real vsize.
39
42
  const numOutputs = this.splitInputsInto > 1 ? this.splitInputsInto : 1;
@@ -59,27 +62,32 @@ export class FundingTransaction extends TransactionBuilder<TransactionType.FUNDI
59
62
  }
60
63
  }
61
64
 
62
- // If a note is present, add a temporary OP_RETURN since it affects vsize.
63
65
  if (this.note) {
64
66
  this.addOPReturn(this.note);
65
67
  }
66
68
 
69
+ if (this.anchor) {
70
+ this.addAnchor();
71
+ }
72
+
67
73
  const estimatedFee = await this.estimateTransactionFees();
68
74
 
69
75
  // Remove all temporary outputs.
70
- const tempCount = numOutputs + (this.note ? 1 : 0);
76
+ const tempCount = numOutputs + (this.note ? 1 : 0) + (this.anchor ? 1 : 0);
71
77
  for (let i = 0; i < tempCount; i++) {
72
78
  this.outputs.pop();
73
79
  }
74
80
 
75
- const adjustedAmount = this.totalInputAmount - estimatedFee;
76
- if (adjustedAmount < TransactionBuilder.MINIMUM_DUST) {
77
- throw new Error(
78
- `Insufficient funds: after deducting fee of ${estimatedFee} sats, remaining amount ${adjustedAmount} sats is below minimum dust`,
79
- );
80
- }
81
+ if (this.amount + estimatedFee > this.totalInputAmount) {
82
+ const adjustedAmount = this.totalInputAmount - estimatedFee;
83
+ if (adjustedAmount < TransactionBuilder.MINIMUM_DUST) {
84
+ throw new Error(
85
+ `Insufficient funds: after deducting fee of ${estimatedFee} sats, remaining amount ${adjustedAmount} sats is below minimum dust`,
86
+ );
87
+ }
81
88
 
82
- this.amount = adjustedAmount;
89
+ this.amount = adjustedAmount;
90
+ }
83
91
  }
84
92
 
85
93
  // Add the primary output(s) first
@@ -87,10 +95,7 @@ export class FundingTransaction extends TransactionBuilder<TransactionType.FUNDI
87
95
  this.splitInputs(this.amount);
88
96
  } else if (this.isPubKeyDestination) {
89
97
  const toHexClean = this.to.startsWith('0x') ? this.to.slice(2) : this.to;
90
- const pubKeyScript: Script = script.compile([
91
- fromHex(toHexClean),
92
- opcodes.OP_CHECKSIG,
93
- ]);
98
+ const pubKeyScript: Script = script.compile([fromHex(toHexClean), opcodes.OP_CHECKSIG]);
94
99
 
95
100
  this.addOutput({
96
101
  value: toSatoshi(this.amount),
@@ -46,6 +46,11 @@ export const MINIMUM_AMOUNT_REWARD: bigint = 330n; //540n;
46
46
  export const MINIMUM_AMOUNT_CA: bigint = 297n;
47
47
  export const ANCHOR_SCRIPT = fromHex('51024e73');
48
48
 
49
+ // Tolerance for fees
50
+ // Less than 1 is percentage: 0.10 equals +- 10%
51
+ // 1 or more is satoshis, 5 equals +- 5 sat
52
+ export const TOLERANCE = 1;
53
+
49
54
  /**
50
55
  * Allows to build a transaction like you would on Ethereum.
51
56
  * @description The transaction builder class
@@ -387,11 +392,28 @@ export abstract class TransactionBuilder<T extends TransactionType> extends Twea
387
392
  * @throws {Error} - If something went wrong
388
393
  */
389
394
  public async signPSBT(): Promise<Psbt> {
390
- if (await this.signTransaction()) {
391
- return this.transaction;
395
+ if (!this.utxos.length) throw new Error('No UTXOs specified');
396
+
397
+ if (
398
+ this.to &&
399
+ !this.isPubKeyDestination &&
400
+ !EcKeyPair.verifyContractAddress(this.to, this.network)
401
+ ) {
402
+ throw new Error(
403
+ 'Invalid contract address. The contract address must be a taproot address.',
404
+ );
392
405
  }
393
406
 
394
- throw new Error('Could not sign transaction');
407
+ if (this.signed) throw new Error('Transaction is already signed');
408
+ this.signed = true;
409
+
410
+ await this.buildTransaction();
411
+
412
+ const built = await this.internalBuildTransaction(this.transaction);
413
+ if (!built) throw new Error('Could not sign transaction');
414
+ if (this.regenerated) throw new Error('Transaction was regenerated');
415
+
416
+ return this.transaction;
395
417
  }
396
418
 
397
419
  /**
@@ -804,19 +826,33 @@ export abstract class TransactionBuilder<T extends TransactionType> extends Twea
804
826
  );
805
827
  }
806
828
 
807
- if (this.totalInputAmount <= amountSpent) {
808
- throw new Error(
809
- `Insufficient funds: need ${amountSpent + feeWithoutChange} sats but only have ${this.totalInputAmount} sats`,
810
- );
811
- }
812
-
813
829
  if (expectRefund && sendBackAmount < 0n) {
814
830
  throw new Error(
815
- `Insufficient funds: need at least ${-sendBackAmount} more sats to cover fees.`,
831
+ `Insufficient funds for fee: need at least ${-sendBackAmount} more sats to cover fees.`,
816
832
  );
817
833
  }
818
834
  }
819
835
 
836
+ const amountNeeded = amountSpent + this.transactionFee;
837
+
838
+ const tolerance: bigint =
839
+ TOLERANCE < 1
840
+ ? BigInt(Math.ceil(Number(this.transactionFee) * TOLERANCE))
841
+ : BigInt(Math.ceil(TOLERANCE));
842
+
843
+ if (this.totalInputAmount < amountSpent) {
844
+ throw new Error(
845
+ `Insufficient funds: need ${amountNeeded} sats but only have ${this.totalInputAmount} sats`,
846
+ );
847
+ }
848
+
849
+ if (this.totalInputAmount < amountNeeded - tolerance) {
850
+ const missingAmount = amountNeeded - this.totalInputAmount - tolerance;
851
+ throw new Error(
852
+ `Insufficient funds for fee: need at least ${missingAmount} more sats to cover fees.`,
853
+ );
854
+ }
855
+
820
856
  if (this.debugFees) {
821
857
  this.log(
822
858
  `Final fee: ${this.transactionFee} sats, Change output: ${this.feeOutput ? `${this.feeOutput.value} sats` : 'none'}`,
@@ -1,4 +1,4 @@
1
- import { fromHex, Psbt, type PsbtInput, type Signer, toHex } from '@btc-vision/bitcoin';
1
+ import { fromHex, Psbt, type PsbtInput, type Signer, toHex, toXOnly } from '@btc-vision/bitcoin';
2
2
  import { type UniversalSigner } from '@btc-vision/ecpair';
3
3
  import { TransactionType } from '../enums/TransactionType.js';
4
4
  import { TransactionBuilder } from '../builders/TransactionBuilder.js';
@@ -14,6 +14,21 @@ import type {
14
14
  IInteractionParameters,
15
15
  ITransactionParameters,
16
16
  } from '../interfaces/ITransactionParameters.js';
17
+ import { CSVMultisigProvider } from '../mineable/CSVMultisigProvider.js';
18
+
19
+ /**
20
+ * Per-input CSV multisig signing status.
21
+ */
22
+ export interface CSVMultisigInputStatus {
23
+ /** PSBT input index */
24
+ readonly inputIndex: number;
25
+ /** Threshold required to spend this input */
26
+ readonly required: number;
27
+ /** Number of distinct cosigner signatures collected so far */
28
+ readonly collected: number;
29
+ /** Hex-encoded x-only pubkeys of the cosigners that have signed */
30
+ readonly signers: string[];
31
+ }
17
32
 
18
33
  /**
19
34
  * Export options for offline transaction signing
@@ -626,4 +641,179 @@ export class OfflineTransactionManager {
626
641
 
627
642
  return TransactionSerializer.toBase64(newState);
628
643
  }
644
+
645
+ /**
646
+ * Inspect collaborative signing progress on all CSV multisig inputs.
647
+ * Returns an empty array when no partial PSBT has been produced yet.
648
+ */
649
+ public static csvMultisigGetStatus(serializedState: string): CSVMultisigInputStatus[] {
650
+ const state = TransactionSerializer.fromBase64(serializedState);
651
+ if (!state.partialPsbtBase64) return [];
652
+
653
+ const network = TransactionReconstructor['nameToNetwork'](state.baseParams.networkName);
654
+ const psbt = Psbt.fromBase64(state.partialPsbtBase64, { network });
655
+ return this.computeCSVMultisigStatus(state, psbt, network);
656
+ }
657
+
658
+ /**
659
+ * Add the given signer's contribution to every CSV multisig input it can sign.
660
+ * No-op for inputs whose tapscript does not contain the signer's x-only pubkey.
661
+ *
662
+ * First call (no partial PSBT yet) builds the PSBT from the funding params
663
+ * using the provided signer as the builder's signer. Subsequent calls load
664
+ * the carried PSBT and add the signer's tapScriptSig where applicable.
665
+ */
666
+ public static async addCSVMultisigSignature(
667
+ serializedState: string,
668
+ signer: Signer | UniversalSigner,
669
+ ): Promise<{
670
+ state: string;
671
+ final: boolean;
672
+ perInput: CSVMultisigInputStatus[];
673
+ }> {
674
+ const state = TransactionSerializer.fromBase64(serializedState);
675
+ const network = TransactionReconstructor['nameToNetwork'](state.baseParams.networkName);
676
+
677
+ let psbt: Psbt;
678
+
679
+ if (state.partialPsbtBase64) {
680
+ psbt = Psbt.fromBase64(state.partialPsbtBase64, { network });
681
+ this.addSignerToCSVInputs(psbt, signer, network);
682
+ } else {
683
+ const builder = this.importForSigning(serializedState, { signer });
684
+ psbt = await builder.signPSBT();
685
+ // Best-effort: ensure the signer's contribution is recorded on every
686
+ // CSV input where they're a cosigner (the builder already attempts
687
+ // this, but this guards against builds that bailed early).
688
+ this.addSignerToCSVInputs(psbt, signer, network);
689
+ }
690
+
691
+ const perInput = this.computeCSVMultisigStatus(state, psbt, network);
692
+ const final = perInput.length > 0 && perInput.every((s) => s.collected >= s.required);
693
+
694
+ const newState: ISerializableTransactionState = {
695
+ ...state,
696
+ partialPsbtBase64: psbt.toBase64(),
697
+ };
698
+
699
+ return {
700
+ state: TransactionSerializer.toBase64(newState),
701
+ final,
702
+ perInput,
703
+ };
704
+ }
705
+
706
+ /**
707
+ * Finalize every CSV multisig input on the partial PSBT and extract the
708
+ * raw transaction hex. Throws if any input is still below its threshold.
709
+ */
710
+ public static csvMultisigFinalize(serializedState: string): string {
711
+ const state = TransactionSerializer.fromBase64(serializedState);
712
+ if (!state.partialPsbtBase64) {
713
+ throw new Error('No partial PSBT in state — call addCSVMultisigSignature first');
714
+ }
715
+
716
+ const network = TransactionReconstructor['nameToNetwork'](state.baseParams.networkName);
717
+ const psbt = Psbt.fromBase64(state.partialPsbtBase64, { network });
718
+
719
+ for (let i = 0; i < psbt.data.inputs.length; i++) {
720
+ const input = psbt.data.inputs[i] as PsbtInput;
721
+ if (!input.tapLeafScript || input.tapLeafScript.length === 0) continue;
722
+
723
+ const leaf = input.tapLeafScript[0];
724
+ if (!leaf) continue;
725
+
726
+ const addr = CSVMultisigProvider.deriveAddressFromTapscript(leaf.script, network);
727
+ if (!addr) continue;
728
+
729
+ CSVMultisigProvider.finalizePsbtInput(psbt, i, network, addr);
730
+ }
731
+
732
+ return psbt.extractTransaction(true, true).toHex();
733
+ }
734
+
735
+ /**
736
+ * Sign every CSV multisig input whose tapscript contains the signer's
737
+ * x-only pubkey and does not yet carry a signature from that pubkey.
738
+ */
739
+ private static addSignerToCSVInputs(
740
+ psbt: Psbt,
741
+ signer: Signer | UniversalSigner,
742
+ network: import('@btc-vision/bitcoin').Network,
743
+ ): void {
744
+ const signerXOnly = toXOnly(signer.publicKey);
745
+ const signerXOnlyHex = toHex(signerXOnly);
746
+
747
+ for (let i = 0; i < psbt.data.inputs.length; i++) {
748
+ const input = psbt.data.inputs[i] as PsbtInput;
749
+ if (!input.tapLeafScript || input.tapLeafScript.length === 0) continue;
750
+
751
+ const leaf = input.tapLeafScript[0];
752
+ if (!leaf) continue;
753
+
754
+ const addr = CSVMultisigProvider.deriveAddressFromTapscript(leaf.script, network);
755
+ if (!addr) continue;
756
+
757
+ const isCosigner = addr.config.pubkeys.some(
758
+ (pk) => toHex(pk as Uint8Array) === signerXOnlyHex,
759
+ );
760
+ if (!isCosigner) continue;
761
+
762
+ const alreadySigned = (input.tapScriptSig ?? []).some(
763
+ (s) => toHex(s.pubkey) === signerXOnlyHex,
764
+ );
765
+ if (alreadySigned) continue;
766
+
767
+ psbt.signTaprootInput(i, signer);
768
+ }
769
+ }
770
+
771
+ /**
772
+ * Build per-input status for every CSV multisig input.
773
+ *
774
+ * Walks state.utxos so we can detect CSV multisig inputs even after the
775
+ * builder has already finalized them (which clears tapLeafScript). For
776
+ * partially-signed inputs the signers list reflects collected tapScriptSig
777
+ * entries; for finalized inputs collected is set to the threshold.
778
+ */
779
+ private static computeCSVMultisigStatus(
780
+ state: ISerializableTransactionState,
781
+ psbt: Psbt,
782
+ network: import('@btc-vision/bitcoin').Network,
783
+ ): CSVMultisigInputStatus[] {
784
+ const result: CSVMultisigInputStatus[] = [];
785
+
786
+ for (let i = 0; i < state.utxos.length; i++) {
787
+ const utxo = state.utxos[i];
788
+ if (!utxo?.witnessScript) continue;
789
+
790
+ const tapscript = fromHex(utxo.witnessScript);
791
+ const addr = CSVMultisigProvider.deriveAddressFromTapscript(tapscript, network);
792
+ if (!addr) continue;
793
+
794
+ const input = psbt.data.inputs[i] as PsbtInput | undefined;
795
+ if (!input) continue;
796
+
797
+ if (input.finalScriptWitness) {
798
+ // Already finalized — by construction it carried >= threshold sigs.
799
+ result.push({
800
+ inputIndex: i,
801
+ required: addr.config.threshold,
802
+ collected: addr.config.threshold,
803
+ signers: [],
804
+ });
805
+ continue;
806
+ }
807
+
808
+ const tapSigs = input.tapScriptSig ?? [];
809
+ result.push({
810
+ inputIndex: i,
811
+ required: addr.config.threshold,
812
+ collected: tapSigs.length,
813
+ signers: tapSigs.map((s) => toHex(s.pubkey)),
814
+ });
815
+ }
816
+
817
+ return result;
818
+ }
629
819
  }
@@ -67,6 +67,9 @@ export class TransactionSerializer {
67
67
  // Write precomputed data
68
68
  this.writePrecomputedData(writer, state.precomputedData);
69
69
 
70
+ // v2: partial PSBT (empty string when absent)
71
+ writer.writeStringWithLength(state.partialPsbtBase64 ?? '');
72
+
70
73
  // Get buffer and calculate checksum
71
74
  const dataBuffer = writer.getBuffer();
72
75
  const checksum = this.calculateChecksum(dataBuffer);
@@ -128,6 +131,13 @@ export class TransactionSerializer {
128
131
  // Read precomputed data
129
132
  const precomputedData = this.readPrecomputedData(reader);
130
133
 
134
+ // v2+: partial PSBT (collaborative signing carryover)
135
+ let partialPsbtBase64: string | undefined;
136
+ if (header.formatVersion >= 2) {
137
+ const stored = reader.readStringWithLength();
138
+ partialPsbtBase64 = stored.length > 0 ? stored : undefined;
139
+ }
140
+
131
141
  return {
132
142
  header,
133
143
  baseParams,
@@ -138,6 +148,7 @@ export class TransactionSerializer {
138
148
  signerMappings,
139
149
  typeSpecificData,
140
150
  precomputedData,
151
+ ...(partialPsbtBase64 !== undefined ? { partialPsbtBase64 } : {}),
141
152
  };
142
153
  }
143
154
 
@@ -3,9 +3,10 @@ import { ChainId } from '../../../network/ChainId.js';
3
3
  import type { TypeSpecificData } from './ITypeSpecificData.js';
4
4
 
5
5
  /**
6
- * Format version for serialization compatibility
6
+ * Format version for serialization compatibility.
7
+ * v2 adds the optional `partialPsbtBase64` field on the top-level state.
7
8
  */
8
- export const SERIALIZATION_FORMAT_VERSION = 1;
9
+ export const SERIALIZATION_FORMAT_VERSION = 2;
9
10
 
10
11
  /**
11
12
  * Magic byte for identifying serialized transaction state
@@ -138,4 +139,11 @@ export interface ISerializableTransactionState {
138
139
  readonly typeSpecificData: TypeSpecificData;
139
140
  /** Pre-computed data for deterministic rebuild */
140
141
  readonly precomputedData: PrecomputedData;
142
+ /**
143
+ * Partial PSBT (base64) carrying signatures collected so far.
144
+ * Used by collaborative flows like CSV multisig where multiple cosigners
145
+ * accumulate signatures across hops before finalization.
146
+ * Only present once at least one signing hop has run.
147
+ */
148
+ readonly partialPsbtBase64?: string;
141
149
  }
@@ -769,11 +769,27 @@ export abstract class TweakedTransaction extends Logger implements Disposable {
769
769
  }
770
770
  }
771
771
 
772
+ let allFinalized = true;
772
773
  for (let i = 0; i < transaction.data.inputs.length; i++) {
773
- transaction.finalizeInput(i, this.customFinalizerP2SH.bind(this) as FinalScriptsFunc);
774
+ try {
775
+ transaction.finalizeInput(
776
+ i,
777
+ this.customFinalizerP2SH.bind(this) as FinalScriptsFunc,
778
+ );
779
+ } catch (e) {
780
+ // CSV multisig inputs may legitimately be below threshold during
781
+ // a collaborative signing hop. Leave them unfinalized so the
782
+ // partial PSBT can travel to the next cosigner.
783
+ if (this.csvMultisigInputs.has(i)) {
784
+ allFinalized = false;
785
+ continue;
786
+ }
787
+
788
+ throw e;
789
+ }
774
790
  }
775
791
 
776
- this.finalized = true;
792
+ this.finalized = allFinalized;
777
793
  }
778
794
 
779
795
  /**
@@ -1509,12 +1525,24 @@ export abstract class TweakedTransaction extends Logger implements Disposable {
1509
1525
  // then, we sign all the remaining inputs with the wallet signer.
1510
1526
  await signer.multiSignPsbt([transaction]);
1511
1527
 
1512
- // Then, we finalize every input.
1528
+ let allFinalized = true;
1513
1529
  for (let i = 0; i < transaction.data.inputs.length; i++) {
1514
- transaction.finalizeInput(i, this.customFinalizerP2SH.bind(this) as FinalScriptsFunc);
1530
+ try {
1531
+ transaction.finalizeInput(
1532
+ i,
1533
+ this.customFinalizerP2SH.bind(this) as FinalScriptsFunc,
1534
+ );
1535
+ } catch (e) {
1536
+ if (this.csvMultisigInputs.has(i)) {
1537
+ allFinalized = false;
1538
+ continue;
1539
+ }
1540
+
1541
+ throw e;
1542
+ }
1515
1543
  }
1516
1544
 
1517
- this.finalized = true;
1545
+ this.finalized = allFinalized;
1518
1546
  }
1519
1547
 
1520
1548
  protected isCSVScript(decompiled: (number | Uint8Array)[]): boolean {