@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
@@ -52,6 +52,7 @@ describe('addRefundOutput , deterministic fee estimation', () => {
52
52
  feeRate?: number;
53
53
  feeUtxos?: UTXO[];
54
54
  autoAdjustAmount?: boolean;
55
+ debugFees?: boolean;
55
56
  }) {
56
57
  const utxo = createTaprootUtxo(taprootAddress, opts.utxoValue);
57
58
  return new FundingTransaction({
@@ -64,6 +65,7 @@ describe('addRefundOutput , deterministic fee estimation', () => {
64
65
  priorityFee: 0n,
65
66
  gasSatFee: 0n,
66
67
  mldsaSigner: null,
68
+ debugFees: !!opts.debugFees,
67
69
  ...(opts.feeUtxos !== undefined && { feeUtxos: opts.feeUtxos }),
68
70
  ...(opts.autoAdjustAmount !== undefined && { autoAdjustAmount: opts.autoAdjustAmount }),
69
71
  });
@@ -204,15 +206,38 @@ describe('addRefundOutput , deterministic fee estimation', () => {
204
206
  });
205
207
  });
206
208
 
209
+ // ================================================================
210
+ // Fee underpayment guard: leftover absorbed into fee must still meet feeRate
211
+ // ================================================================
212
+ describe('fee underpayment guard', () => {
213
+ it('should throw when leftover would underpay feeRate (200 sats at feeRate=10)', async () => {
214
+ const utxoValue = 100_000n;
215
+ const amount = utxoValue - 200n;
216
+
217
+ const tx = buildFunding({ utxoValue, amount, feeRate: 10 });
218
+
219
+ await expect(tx.signTransaction()).rejects.toThrow(/Insufficient funds for fee/);
220
+ });
221
+
222
+ it('should throw when leftover is a token amount (1 sat) far below feeRate', async () => {
223
+ const utxoValue = 100_000n;
224
+ const amount = utxoValue - 1n;
225
+
226
+ const tx = buildFunding({ utxoValue, amount, feeRate: 5 });
227
+
228
+ await expect(tx.signTransaction()).rejects.toThrow(/Insufficient funds for fee/);
229
+ });
230
+ });
231
+
207
232
  // ================================================================
208
233
  // Tolerance: sendBack < 0 but totalInput > amountSpent
209
234
  // ================================================================
210
- describe('tolerance , effective fee is positive but less than estimated', () => {
211
- it('should succeed when amount is close to totalInput leaving a small effective fee', async () => {
235
+ describe('tolerance , effective fee is positive but is a little bit less than estimated UTXO available', () => {
236
+ it('should succeed when amount+fees is close to totalInput', async () => {
212
237
  const utxoValue = 100_000n;
213
238
  // Leave 200 sats for fees , less than the estimated fee at high rate
214
239
  // but totalInput > amountSpent so it's valid
215
- const amount = utxoValue - 200n;
240
+ const amount = utxoValue - 1109n;
216
241
 
217
242
  const tx = buildFunding({ utxoValue, amount, feeRate: 10 });
218
243
 
@@ -223,20 +248,80 @@ describe('addRefundOutput , deterministic fee estimation', () => {
223
248
 
224
249
  // Verify fee is exactly the 200 sats leftover
225
250
  const totalOut = signed.outs.reduce((sum, o) => sum + BigInt(o.value), 0n);
226
- expect(utxoValue - totalOut).toBe(200n);
251
+ expect(utxoValue - totalOut).toBe(1109n);
252
+ expect(tx.transactionFee).toBe(1109n);
227
253
  });
228
254
 
229
- it('should succeed with very small leftover (1 sat) as long as totalInput > amountSpent', async () => {
255
+ // Const real fees = 555n, dust = 330n
256
+ const shouldSucceed: [
257
+ delta: bigint,
258
+ succeed: boolean,
259
+ overflow: bigint,
260
+ message: string,
261
+ ][] = [
262
+ [0n, false, 0n, 'amount equal utxoValue'],
263
+ [1n, false, 0n, 'amount equal utxoValue less 1'],
264
+
265
+ // Check fees bondary
266
+ [553n, false, 0n, 'amount equal utxoValue less 553 (real fees - 2)'],
267
+ [554n, true, 0n, 'amount equal utxoValue less 554 (real fees - 1)'],
268
+ [555n, true, 0n, 'amount equal utxoValue less 555 (real fees)'],
269
+ [556n, true, 0n, 'amount equal utxoValue less 556 (real fees + 1)'],
270
+
271
+ // Check fees+dust bondaries
272
+ [555n + 329n, true, 0n, 'amount equal utxoValue less 884 (real fees + dust - 1)'],
273
+ [555n + 330n, true, 0n, 'amount equal utxoValue less 885 (real fees + dust)'],
274
+ [555n + 331n, true, 0n, 'amount equal utxoValue less 886 (real fees + dust + 1)'],
275
+
276
+ // If there is really a refund, a refund output section will be added
277
+ // to the tx, with a vbsize of 43 vbytes * 5 feeRate --> 215...
278
+ // Check the new fees+refund_section+dust boundaries
279
+ [
280
+ 555n + 43n * 5n + 329n,
281
+ true,
282
+ 0n,
283
+ 'amount equal utxoValue less 1109 (real fees + refund + dust - 1)',
284
+ ],
285
+ [
286
+ 555n + 43n * 5n + 330n,
287
+ true,
288
+ 330n,
289
+ 'amount equal utxoValue less 1110 (real fees + refund + dust)',
290
+ ],
291
+ [
292
+ 555n + 43n * 5n + 331n,
293
+ true,
294
+ 331n,
295
+ 'amount equal utxoValue less 1111 (real fees + refund + dust + 1)',
296
+ ],
297
+ ];
298
+ it('should succeed with very small diff from calculated fees (1 sat)', async () => {
230
299
  const utxoValue = 100_000n;
231
- const amount = utxoValue - 1n;
232
300
 
233
- const tx = buildFunding({ utxoValue, amount, feeRate: 5 });
301
+ let tx: FundingTransaction | undefined = undefined;
302
+ for (const [delta, shouldSuccess, overflow, message] of shouldSucceed) {
303
+ let succeed = false;
304
+ try {
305
+ const amount = utxoValue - delta;
234
306
 
235
- const signed = await tx.signTransaction();
236
- expect(signed.ins.length).toBeGreaterThan(0);
307
+ tx = buildFunding({ utxoValue, amount, feeRate: 5, debugFees: false });
237
308
 
238
- const totalOut = signed.outs.reduce((sum, o) => sum + BigInt(o.value), 0n);
239
- expect(utxoValue - totalOut).toBe(1n);
309
+ const signed = await tx.signTransaction();
310
+ expect(signed.ins.length).toBeGreaterThan(0);
311
+
312
+ const totalOut = signed.outs.reduce((sum, o) => sum + BigInt(o.value), 0n);
313
+
314
+ //console.log('DELTA', utxoValue, totalOut, delta, overflow, tx.estimatedFees, tx.overflowFees,);
315
+ expect(utxoValue - totalOut).toBe(delta - overflow);
316
+ succeed = true;
317
+ } catch (e) {
318
+ //console.log('E', e);
319
+ succeed = false;
320
+ }
321
+
322
+ expect(succeed, message).toBe(shouldSuccess);
323
+ expect(tx?.overflowFees, message).toBe(overflow);
324
+ }
240
325
  });
241
326
  });
242
327
 
@@ -0,0 +1,293 @@
1
+ /**
2
+ * csv-multisig-offline-edges.test.ts
3
+ *
4
+ * Edge cases for the OfflineTransactionManager CSV multisig API:
5
+ * - csvMultisigGetStatus standalone
6
+ * - csvMultisigFinalize error paths
7
+ * - addCSVMultisigSignature idempotency + non-cosigner no-op on first hop
8
+ * - Serializer v2 round-trip of partialPsbtBase64
9
+ * - signPSBT returns a partial PSBT for under-threshold CSV inputs
10
+ */
11
+
12
+ import { beforeAll, describe, expect, it } from 'vitest';
13
+ import {
14
+ crypto as bitCrypto,
15
+ networks,
16
+ payments,
17
+ Psbt,
18
+ toHex,
19
+ toXOnly,
20
+ type XOnlyPublicKey,
21
+ } from '@btc-vision/bitcoin';
22
+ import { type UniversalSigner } from '@btc-vision/ecpair';
23
+ import type { UTXO } from '../build/opnet.js';
24
+ import {
25
+ CSVMultisigProvider,
26
+ EcKeyPair,
27
+ FundingTransaction,
28
+ OfflineTransactionManager,
29
+ TransactionBuilder,
30
+ TransactionSerializer,
31
+ } from '../build/opnet.js';
32
+
33
+ const network = networks.regtest;
34
+
35
+ function makeSigner(seed: string): UniversalSigner {
36
+ return EcKeyPair.fromPrivateKey(
37
+ bitCrypto.sha256(new TextEncoder().encode(seed)),
38
+ network,
39
+ );
40
+ }
41
+
42
+ function buildAddr(signers: UniversalSigner[], csvBlocks: number, threshold: number) {
43
+ const pubkeys = signers.map((s) => toXOnly(s.publicKey) as XOnlyPublicKey);
44
+ return CSVMultisigProvider.generateAddress({ pubkeys, threshold, csvBlocks }, network);
45
+ }
46
+
47
+ function makeUtxo(
48
+ addr: ReturnType<typeof buildAddr>,
49
+ value: bigint,
50
+ txid: string = '22'.repeat(32),
51
+ ): UTXO {
52
+ return {
53
+ transactionId: txid,
54
+ outputIndex: 0,
55
+ value,
56
+ scriptPubKey: {
57
+ hex: toHex(addr.scriptPubKey),
58
+ address: addr.address,
59
+ },
60
+ witnessScript: addr.tapscript,
61
+ };
62
+ }
63
+
64
+ function fundingState(
65
+ addr: ReturnType<typeof buildAddr>,
66
+ utxo: UTXO,
67
+ placeholderSigner: UniversalSigner,
68
+ ): string {
69
+ const recipient = payments.p2tr({
70
+ internalPubkey: toXOnly(placeholderSigner.publicKey) as XOnlyPublicKey,
71
+ network,
72
+ }).address as string;
73
+
74
+ return OfflineTransactionManager.exportFunding({
75
+ signer: placeholderSigner,
76
+ mldsaSigner: null,
77
+ network,
78
+ utxos: [utxo],
79
+ to: recipient,
80
+ amount: TransactionBuilder.MINIMUM_DUST,
81
+ feeRate: 1,
82
+ priorityFee: 0n,
83
+ gasSatFee: 0n,
84
+ });
85
+ }
86
+
87
+ describe('OfflineTransactionManager CSV multisig — edges', () => {
88
+ let signerA: UniversalSigner;
89
+ let signerB: UniversalSigner;
90
+ let signerC: UniversalSigner;
91
+ let signerD: UniversalSigner; // never a cosigner anywhere
92
+
93
+ beforeAll(() => {
94
+ signerA = makeSigner('edges-A');
95
+ signerB = makeSigner('edges-B');
96
+ signerC = makeSigner('edges-C');
97
+ signerD = makeSigner('edges-D');
98
+ });
99
+
100
+ // ----------------------------------------------------------------
101
+ // csvMultisigGetStatus
102
+ // ----------------------------------------------------------------
103
+ describe('csvMultisigGetStatus', () => {
104
+ it('returns [] when no partial PSBT has been produced yet', () => {
105
+ const addr = buildAddr([signerA, signerB], 5, 2);
106
+ const utxo = makeUtxo(addr, 200_000n);
107
+ const state = fundingState(addr, utxo, signerA);
108
+
109
+ expect(OfflineTransactionManager.csvMultisigGetStatus(state)).toEqual([]);
110
+ });
111
+
112
+ it('reflects the same status as the hop result after addCSVMultisigSignature', async () => {
113
+ const addr = buildAddr([signerA, signerB, signerC], 5, 2);
114
+ const utxo = makeUtxo(addr, 200_000n);
115
+ const state = fundingState(addr, utxo, signerA);
116
+
117
+ const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
118
+ const direct = OfflineTransactionManager.csvMultisigGetStatus(hop1.state);
119
+
120
+ expect(direct).toEqual(hop1.perInput);
121
+ expect(direct[0]!.collected).toBe(1);
122
+ expect(direct[0]!.required).toBe(2);
123
+ });
124
+
125
+ it('reports collected === threshold for finalized inputs (threshold=1 single hop)', async () => {
126
+ const addr = buildAddr([signerA], 3, 1);
127
+ const utxo = makeUtxo(addr, 200_000n);
128
+ const state = fundingState(addr, utxo, signerA);
129
+
130
+ const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
131
+
132
+ const status = OfflineTransactionManager.csvMultisigGetStatus(hop1.state);
133
+ expect(status).toHaveLength(1);
134
+ expect(status[0]!.required).toBe(1);
135
+ expect(status[0]!.collected).toBe(1);
136
+ });
137
+ });
138
+
139
+ // ----------------------------------------------------------------
140
+ // csvMultisigFinalize error paths
141
+ // ----------------------------------------------------------------
142
+ describe('csvMultisigFinalize', () => {
143
+ it('throws when no partial PSBT exists in state', () => {
144
+ const addr = buildAddr([signerA, signerB], 5, 2);
145
+ const utxo = makeUtxo(addr, 200_000n);
146
+ const state = fundingState(addr, utxo, signerA);
147
+
148
+ expect(() => OfflineTransactionManager.csvMultisigFinalize(state)).toThrow(
149
+ /No partial PSBT/i,
150
+ );
151
+ });
152
+
153
+ it('throws when the partial PSBT is below threshold', async () => {
154
+ const addr = buildAddr([signerA, signerB, signerC], 5, 2);
155
+ const utxo = makeUtxo(addr, 200_000n);
156
+ const state = fundingState(addr, utxo, signerA);
157
+
158
+ const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
159
+ expect(hop1.final).toBe(false);
160
+
161
+ expect(() => OfflineTransactionManager.csvMultisigFinalize(hop1.state)).toThrow(
162
+ /needs 2 signatures, got 1/,
163
+ );
164
+ });
165
+ });
166
+
167
+ // ----------------------------------------------------------------
168
+ // addCSVMultisigSignature edge cases
169
+ // ----------------------------------------------------------------
170
+ describe('addCSVMultisigSignature', () => {
171
+ it('is idempotent — re-signing with the same cosigner does not double-count', async () => {
172
+ const addr = buildAddr([signerA, signerB, signerC], 5, 2);
173
+ const utxo = makeUtxo(addr, 200_000n);
174
+ const state = fundingState(addr, utxo, signerA);
175
+
176
+ const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
177
+ expect(hop1.perInput[0]!.collected).toBe(1);
178
+
179
+ const hop2 = await OfflineTransactionManager.addCSVMultisigSignature(
180
+ hop1.state,
181
+ signerA,
182
+ );
183
+ expect(hop2.perInput[0]!.collected).toBe(1);
184
+ expect(hop2.final).toBe(false);
185
+ });
186
+
187
+ it('non-cosigner on a non-first hop leaves the partial PSBT unchanged', async () => {
188
+ const addr = buildAddr([signerA, signerB], 5, 2);
189
+ const utxo = makeUtxo(addr, 200_000n);
190
+ const state = fundingState(addr, utxo, signerA);
191
+
192
+ const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
193
+ expect(hop1.perInput[0]!.collected).toBe(1);
194
+
195
+ // signerD is in neither the tapscript nor the original signer slot
196
+ const hop2 = await OfflineTransactionManager.addCSVMultisigSignature(
197
+ hop1.state,
198
+ signerD,
199
+ );
200
+ expect(hop2.perInput[0]!.collected).toBe(1);
201
+ expect(hop2.final).toBe(false);
202
+ });
203
+
204
+ it('reaches threshold across three distinct cosigners (2-of-3 with C+B)', async () => {
205
+ const addr = buildAddr([signerA, signerB, signerC], 5, 2);
206
+ const utxo = makeUtxo(addr, 200_000n);
207
+ // Placeholder signer is A — but the actual cosigners are C then B.
208
+ const state = fundingState(addr, utxo, signerA);
209
+
210
+ // Build PSBT using A as the placeholder for the first hop;
211
+ // immediately overlay C's signature in a second hop.
212
+ const hopA = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
213
+ expect(hopA.perInput[0]!.collected).toBe(1);
214
+
215
+ const hopC = await OfflineTransactionManager.addCSVMultisigSignature(
216
+ hopA.state,
217
+ signerC,
218
+ );
219
+ expect(hopC.perInput[0]!.collected).toBe(2);
220
+ expect(hopC.final).toBe(true);
221
+
222
+ // Finalization succeeds even though signerB never participated.
223
+ const rawTxHex = OfflineTransactionManager.csvMultisigFinalize(hopC.state);
224
+ expect(rawTxHex).toMatch(/^[0-9a-f]+$/);
225
+ });
226
+ });
227
+
228
+ // ----------------------------------------------------------------
229
+ // Serializer v2 round-trip
230
+ // ----------------------------------------------------------------
231
+ describe('serializer v2 — partialPsbtBase64 round-trip', () => {
232
+ it('omits the field on a freshly exported state', () => {
233
+ const addr = buildAddr([signerA, signerB], 5, 2);
234
+ const utxo = makeUtxo(addr, 200_000n);
235
+ const state = fundingState(addr, utxo, signerA);
236
+
237
+ const decoded = TransactionSerializer.fromBase64(state);
238
+ expect(decoded.partialPsbtBase64).toBeUndefined();
239
+ });
240
+
241
+ it('round-trips the partial PSBT verbatim after a signing hop', async () => {
242
+ const addr = buildAddr([signerA, signerB], 5, 2);
243
+ const utxo = makeUtxo(addr, 200_000n);
244
+ const state = fundingState(addr, utxo, signerA);
245
+
246
+ const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(state, signerA);
247
+
248
+ const decoded = TransactionSerializer.fromBase64(hop1.state);
249
+ expect(decoded.partialPsbtBase64).toBeDefined();
250
+
251
+ // Sanity: the carried PSBT actually parses and has one tapScriptSig.
252
+ const psbt = Psbt.fromBase64(decoded.partialPsbtBase64!, { network });
253
+ expect(psbt.data.inputs[0]!.tapScriptSig).toHaveLength(1);
254
+
255
+ // Round-trip via toBase64 yields a byte-identical payload.
256
+ const reEncoded = TransactionSerializer.toBase64(decoded);
257
+ expect(reEncoded).toBe(hop1.state);
258
+ });
259
+ });
260
+
261
+ // ----------------------------------------------------------------
262
+ // signPSBT semantic shift: returns the (possibly partial) PSBT
263
+ // ----------------------------------------------------------------
264
+ describe('TransactionBuilder.signPSBT — partial PSBT semantics', () => {
265
+ it('returns a PSBT with the tapScriptSig but no finalScriptWitness for under-threshold CSV input', async () => {
266
+ const addr = buildAddr([signerA, signerB, signerC], 5, 2);
267
+ const utxo = makeUtxo(addr, 200_000n);
268
+ const recipient = payments.p2tr({
269
+ internalPubkey: toXOnly(signerA.publicKey) as XOnlyPublicKey,
270
+ network,
271
+ }).address as string;
272
+
273
+ const tx = new FundingTransaction({
274
+ signer: signerA,
275
+ mldsaSigner: null,
276
+ network,
277
+ utxos: [utxo],
278
+ to: recipient,
279
+ amount: TransactionBuilder.MINIMUM_DUST,
280
+ feeRate: 1,
281
+ priorityFee: 0n,
282
+ gasSatFee: 0n,
283
+ });
284
+
285
+ const psbt = await tx.signPSBT();
286
+ const input = psbt.data.inputs[0]!;
287
+
288
+ expect(input.tapScriptSig).toBeDefined();
289
+ expect(input.tapScriptSig).toHaveLength(1);
290
+ expect(input.finalScriptWitness).toBeUndefined();
291
+ });
292
+ });
293
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * csv-multisig-offline.test.ts
3
+ *
4
+ * Library-native multi-signer flow for spending CSV multisig UTXOs using
5
+ * OfflineTransactionManager — the documented offline signing API. No raw
6
+ * @btc-vision/bitcoin Psbt construction.
7
+ */
8
+
9
+ import { beforeAll, describe, expect, it } from 'vitest';
10
+ import {
11
+ crypto as bitCrypto,
12
+ networks,
13
+ payments,
14
+ toHex,
15
+ toXOnly,
16
+ type XOnlyPublicKey,
17
+ } from '@btc-vision/bitcoin';
18
+ import { type UniversalSigner } from '@btc-vision/ecpair';
19
+ import type { UTXO } from '../build/opnet.js';
20
+ import {
21
+ CSVMultisigProvider,
22
+ EcKeyPair,
23
+ OfflineTransactionManager,
24
+ TransactionBuilder,
25
+ } from '../build/opnet.js';
26
+
27
+ const network = networks.regtest;
28
+
29
+ function makeSigner(seed: string): UniversalSigner {
30
+ return EcKeyPair.fromPrivateKey(
31
+ bitCrypto.sha256(new TextEncoder().encode(seed)),
32
+ network,
33
+ );
34
+ }
35
+
36
+ function buildAddr(signers: UniversalSigner[], csvBlocks: number, threshold: number) {
37
+ const pubkeys = signers.map((s) => toXOnly(s.publicKey) as XOnlyPublicKey);
38
+ return CSVMultisigProvider.generateAddress({ pubkeys, threshold, csvBlocks }, network);
39
+ }
40
+
41
+ function makeUtxo(addr: ReturnType<typeof buildAddr>, value: bigint): UTXO {
42
+ return {
43
+ transactionId: '11'.repeat(32),
44
+ outputIndex: 0,
45
+ value,
46
+ scriptPubKey: {
47
+ hex: toHex(addr.scriptPubKey),
48
+ address: addr.address,
49
+ },
50
+ witnessScript: addr.tapscript,
51
+ };
52
+ }
53
+
54
+ describe('CSV multisig offline multi-signer flow', () => {
55
+ let signerA: UniversalSigner;
56
+ let signerB: UniversalSigner;
57
+ let signerC: UniversalSigner;
58
+
59
+ beforeAll(() => {
60
+ signerA = makeSigner('offline-A');
61
+ signerB = makeSigner('offline-B');
62
+ signerC = makeSigner('offline-C');
63
+ });
64
+
65
+ it('2-of-3 signers produce a broadcastable tx through OfflineTransactionManager', async () => {
66
+ const addr = buildAddr([signerA, signerB, signerC], 10, 2);
67
+ const utxo = makeUtxo(addr, 1_000_000n);
68
+ const recipient = payments.p2tr({
69
+ internalPubkey: toXOnly(signerA.publicKey) as XOnlyPublicKey,
70
+ network,
71
+ }).address as string;
72
+
73
+ // --- Coordinator (online): export funding state ---
74
+ const initialState = OfflineTransactionManager.exportFunding({
75
+ signer: signerA, // placeholder; real signer supplied per-hop
76
+ mldsaSigner: null,
77
+ network,
78
+ utxos: [utxo],
79
+ to: recipient,
80
+ amount: TransactionBuilder.MINIMUM_DUST,
81
+ feeRate: 1,
82
+ priorityFee: 0n,
83
+ gasSatFee: 0n,
84
+ });
85
+
86
+ // Before any signing, there's no partial PSBT yet.
87
+ expect(OfflineTransactionManager.csvMultisigGetStatus(initialState)).toEqual([]);
88
+
89
+ // --- Hop 1: signer A adds signature (offline env) ---
90
+ const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(
91
+ initialState,
92
+ signerA,
93
+ );
94
+ expect(hop1.final).toBe(false);
95
+ expect(hop1.perInput).toHaveLength(1);
96
+ expect(hop1.perInput[0]!.required).toBe(2);
97
+ expect(hop1.perInput[0]!.collected).toBe(1);
98
+ expect(hop1.perInput[0]!.signers).toContain(toHex(toXOnly(signerA.publicKey)));
99
+
100
+ // --- Hop 2: signer B adds signature (different offline env) ---
101
+ const hop2 = await OfflineTransactionManager.addCSVMultisigSignature(
102
+ hop1.state,
103
+ signerB,
104
+ );
105
+ expect(hop2.final).toBe(true);
106
+ expect(hop2.perInput[0]!.collected).toBe(2);
107
+ expect(new Set(hop2.perInput[0]!.signers)).toEqual(
108
+ new Set([
109
+ toHex(toXOnly(signerA.publicKey)),
110
+ toHex(toXOnly(signerB.publicKey)),
111
+ ]),
112
+ );
113
+
114
+ // --- Finalize + extract (anyone, no key material required) ---
115
+ const rawTxHex = OfflineTransactionManager.csvMultisigFinalize(hop2.state);
116
+ expect(rawTxHex).toMatch(/^[0-9a-f]+$/);
117
+
118
+ // Sanity: the extracted tx has the expected witness shape and CSV sequence.
119
+ const state = OfflineTransactionManager.inspect(hop2.state);
120
+ expect(state.partialPsbtBase64).toBeDefined();
121
+ });
122
+
123
+ it('signer pubkey not in the tapscript is a no-op', async () => {
124
+ const addr = buildAddr([signerA, signerB], 5, 2);
125
+ const utxo = makeUtxo(addr, 500_000n);
126
+ const recipient = payments.p2tr({
127
+ internalPubkey: toXOnly(signerA.publicKey) as XOnlyPublicKey,
128
+ network,
129
+ }).address as string;
130
+
131
+ const initialState = OfflineTransactionManager.exportFunding({
132
+ signer: signerA,
133
+ mldsaSigner: null,
134
+ network,
135
+ utxos: [utxo],
136
+ to: recipient,
137
+ amount: TransactionBuilder.MINIMUM_DUST,
138
+ feeRate: 1,
139
+ priorityFee: 0n,
140
+ gasSatFee: 0n,
141
+ });
142
+
143
+ const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(
144
+ initialState,
145
+ signerA,
146
+ );
147
+ expect(hop1.perInput[0]!.collected).toBe(1);
148
+
149
+ // signerC is not in this 2-of-2 tapscript.
150
+ const hop2 = await OfflineTransactionManager.addCSVMultisigSignature(
151
+ hop1.state,
152
+ signerC,
153
+ );
154
+ expect(hop2.perInput[0]!.collected).toBe(1);
155
+ expect(hop2.final).toBe(false);
156
+
157
+ // Finalize should throw because we're still below threshold.
158
+ expect(() => OfflineTransactionManager.csvMultisigFinalize(hop2.state)).toThrow(
159
+ /needs 2 signatures, got 1/,
160
+ );
161
+
162
+ // Now add the real second signer and finalize.
163
+ const hop3 = await OfflineTransactionManager.addCSVMultisigSignature(
164
+ hop2.state,
165
+ signerB,
166
+ );
167
+ expect(hop3.final).toBe(true);
168
+
169
+ const rawTxHex = OfflineTransactionManager.csvMultisigFinalize(hop3.state);
170
+ expect(rawTxHex).toMatch(/^[0-9a-f]+$/);
171
+ });
172
+
173
+ it('threshold = 1 finalizes in a single hop and emits a ready-to-broadcast tx', async () => {
174
+ const addr = buildAddr([signerA], 3, 1);
175
+ const utxo = makeUtxo(addr, 500_000n);
176
+ const recipient = payments.p2tr({
177
+ internalPubkey: toXOnly(signerA.publicKey) as XOnlyPublicKey,
178
+ network,
179
+ }).address as string;
180
+
181
+ const initialState = OfflineTransactionManager.exportFunding({
182
+ signer: signerA,
183
+ mldsaSigner: null,
184
+ network,
185
+ utxos: [utxo],
186
+ to: recipient,
187
+ amount: TransactionBuilder.MINIMUM_DUST,
188
+ feeRate: 1,
189
+ priorityFee: 0n,
190
+ gasSatFee: 0n,
191
+ });
192
+
193
+ const hop1 = await OfflineTransactionManager.addCSVMultisigSignature(
194
+ initialState,
195
+ signerA,
196
+ );
197
+ expect(hop1.final).toBe(true);
198
+
199
+ const rawTxHex = OfflineTransactionManager.csvMultisigFinalize(hop1.state);
200
+ expect(rawTxHex).toMatch(/^[0-9a-f]+$/);
201
+ });
202
+ });