@btc-vision/transaction 1.7.18 → 1.7.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 (92) hide show
  1. package/LICENSE +190 -21
  2. package/README.md +1 -1
  3. package/browser/_version.d.ts +1 -1
  4. package/browser/generators/builders/HashCommitmentGenerator.d.ts +49 -0
  5. package/browser/index.js +1 -1
  6. package/browser/keypair/Address.d.ts +3 -1
  7. package/browser/opnet.d.ts +6 -1
  8. package/browser/signer/AddressRotation.d.ts +12 -0
  9. package/browser/transaction/TransactionFactory.d.ts +14 -0
  10. package/browser/transaction/builders/ConsolidatedInteractionTransaction.d.ts +44 -0
  11. package/browser/transaction/enums/TransactionType.d.ts +3 -1
  12. package/browser/transaction/interfaces/IConsolidatedTransactionParameters.d.ts +31 -0
  13. package/browser/transaction/interfaces/ITransactionParameters.d.ts +2 -0
  14. package/browser/transaction/offline/OfflineTransactionManager.d.ts +69 -0
  15. package/browser/transaction/offline/TransactionReconstructor.d.ts +28 -0
  16. package/browser/transaction/offline/TransactionSerializer.d.ts +50 -0
  17. package/browser/transaction/offline/TransactionStateCapture.d.ts +52 -0
  18. package/browser/transaction/offline/index.d.ts +5 -0
  19. package/browser/transaction/offline/interfaces/ISerializableState.d.ts +62 -0
  20. package/browser/transaction/offline/interfaces/ITypeSpecificData.d.ts +62 -0
  21. package/browser/transaction/offline/interfaces/index.d.ts +2 -0
  22. package/browser/transaction/shared/TweakedTransaction.d.ts +12 -1
  23. package/browser/utxo/interfaces/IUTXO.d.ts +2 -0
  24. package/build/_version.d.ts +1 -1
  25. package/build/_version.js +1 -1
  26. package/build/generators/builders/HashCommitmentGenerator.d.ts +49 -0
  27. package/build/generators/builders/HashCommitmentGenerator.js +229 -0
  28. package/build/keypair/Address.d.ts +3 -1
  29. package/build/keypair/Address.js +87 -54
  30. package/build/opnet.d.ts +6 -1
  31. package/build/opnet.js +6 -1
  32. package/build/signer/AddressRotation.d.ts +12 -0
  33. package/build/signer/AddressRotation.js +16 -0
  34. package/build/transaction/TransactionFactory.d.ts +14 -0
  35. package/build/transaction/TransactionFactory.js +36 -0
  36. package/build/transaction/builders/ConsolidatedInteractionTransaction.d.ts +44 -0
  37. package/build/transaction/builders/ConsolidatedInteractionTransaction.js +259 -0
  38. package/build/transaction/builders/TransactionBuilder.js +2 -0
  39. package/build/transaction/enums/TransactionType.d.ts +3 -1
  40. package/build/transaction/enums/TransactionType.js +2 -0
  41. package/build/transaction/interfaces/IConsolidatedTransactionParameters.d.ts +31 -0
  42. package/build/transaction/interfaces/IConsolidatedTransactionParameters.js +1 -0
  43. package/build/transaction/interfaces/ITransactionParameters.d.ts +2 -0
  44. package/build/transaction/offline/OfflineTransactionManager.d.ts +69 -0
  45. package/build/transaction/offline/OfflineTransactionManager.js +255 -0
  46. package/build/transaction/offline/TransactionReconstructor.d.ts +28 -0
  47. package/build/transaction/offline/TransactionReconstructor.js +243 -0
  48. package/build/transaction/offline/TransactionSerializer.d.ts +50 -0
  49. package/build/transaction/offline/TransactionSerializer.js +700 -0
  50. package/build/transaction/offline/TransactionStateCapture.d.ts +52 -0
  51. package/build/transaction/offline/TransactionStateCapture.js +275 -0
  52. package/build/transaction/offline/index.d.ts +5 -0
  53. package/build/transaction/offline/index.js +5 -0
  54. package/build/transaction/offline/interfaces/ISerializableState.d.ts +62 -0
  55. package/build/transaction/offline/interfaces/ISerializableState.js +2 -0
  56. package/build/transaction/offline/interfaces/ITypeSpecificData.d.ts +62 -0
  57. package/build/transaction/offline/interfaces/ITypeSpecificData.js +19 -0
  58. package/build/transaction/offline/interfaces/index.d.ts +2 -0
  59. package/build/transaction/offline/interfaces/index.js +2 -0
  60. package/build/transaction/shared/TweakedTransaction.d.ts +12 -1
  61. package/build/transaction/shared/TweakedTransaction.js +75 -8
  62. package/build/utxo/interfaces/IUTXO.d.ts +2 -0
  63. package/documentation/README.md +5 -0
  64. package/documentation/offline-transaction-signing.md +650 -0
  65. package/documentation/transaction-building.md +603 -0
  66. package/package.json +2 -2
  67. package/src/_version.ts +1 -1
  68. package/src/generators/builders/HashCommitmentGenerator.ts +495 -0
  69. package/src/keypair/Address.ts +123 -70
  70. package/src/opnet.ts +8 -1
  71. package/src/signer/AddressRotation.ts +72 -0
  72. package/src/transaction/TransactionFactory.ts +94 -1
  73. package/src/transaction/builders/CancelTransaction.ts +4 -2
  74. package/src/transaction/builders/ConsolidatedInteractionTransaction.ts +568 -0
  75. package/src/transaction/builders/CustomScriptTransaction.ts +4 -2
  76. package/src/transaction/builders/MultiSignTransaction.ts +4 -2
  77. package/src/transaction/builders/TransactionBuilder.ts +8 -2
  78. package/src/transaction/enums/TransactionType.ts +2 -0
  79. package/src/transaction/interfaces/IConsolidatedTransactionParameters.ts +78 -0
  80. package/src/transaction/interfaces/ITransactionParameters.ts +8 -0
  81. package/src/transaction/offline/OfflineTransactionManager.ts +630 -0
  82. package/src/transaction/offline/TransactionReconstructor.ts +402 -0
  83. package/src/transaction/offline/TransactionSerializer.ts +920 -0
  84. package/src/transaction/offline/TransactionStateCapture.ts +469 -0
  85. package/src/transaction/offline/index.ts +8 -0
  86. package/src/transaction/offline/interfaces/ISerializableState.ts +141 -0
  87. package/src/transaction/offline/interfaces/ITypeSpecificData.ts +172 -0
  88. package/src/transaction/offline/interfaces/index.ts +2 -0
  89. package/src/transaction/shared/TweakedTransaction.ts +156 -9
  90. package/src/utxo/interfaces/IUTXO.ts +8 -0
  91. package/test/address-rotation.test.ts +553 -0
  92. package/test/offline-transaction.test.ts +2065 -0
@@ -0,0 +1,2065 @@
1
+ import { describe, expect, it, beforeAll } from 'vitest';
2
+ import { networks, payments } from '@btc-vision/bitcoin';
3
+ import { ECPairInterface } from 'ecpair';
4
+ import {
5
+ // Core offline signing exports
6
+ TransactionSerializer,
7
+ TransactionStateCapture,
8
+ TransactionReconstructor,
9
+ OfflineTransactionManager,
10
+ ReconstructionOptions,
11
+ // Interfaces
12
+ ISerializableTransactionState,
13
+ SerializedUTXO,
14
+ SerializedOutput,
15
+ SerializedBaseParams,
16
+ PrecomputedData,
17
+ SERIALIZATION_FORMAT_VERSION,
18
+ // Type-specific data
19
+ FundingSpecificData,
20
+ DeploymentSpecificData,
21
+ InteractionSpecificData,
22
+ MultiSigSpecificData,
23
+ CustomScriptSpecificData,
24
+ CancelSpecificData,
25
+ isFundingSpecificData,
26
+ isDeploymentSpecificData,
27
+ isInteractionSpecificData,
28
+ isMultiSigSpecificData,
29
+ isCustomScriptSpecificData,
30
+ isCancelSpecificData,
31
+ // Transaction types
32
+ TransactionType,
33
+ FundingTransaction,
34
+ // Utilities
35
+ EcKeyPair,
36
+ createSignerMap,
37
+ createAddressRotation,
38
+ UTXO,
39
+ ChainId,
40
+ } from '../build/opnet.js';
41
+ import { currentConsensus } from '../build/opnet.js';
42
+
43
+ describe('Offline Transaction Signing', () => {
44
+ const network = networks.regtest;
45
+
46
+ // Test keypairs
47
+ let signer1: ECPairInterface;
48
+ let signer2: ECPairInterface;
49
+ let signer3: ECPairInterface;
50
+ let defaultSigner: ECPairInterface;
51
+
52
+ let address1: string;
53
+ let address2: string;
54
+ let address3: string;
55
+ let defaultAddress: string;
56
+
57
+ beforeAll(() => {
58
+ signer1 = EcKeyPair.generateRandomKeyPair(network);
59
+ signer2 = EcKeyPair.generateRandomKeyPair(network);
60
+ signer3 = EcKeyPair.generateRandomKeyPair(network);
61
+ defaultSigner = EcKeyPair.generateRandomKeyPair(network);
62
+
63
+ address1 = EcKeyPair.getTaprootAddress(signer1, network);
64
+ address2 = EcKeyPair.getTaprootAddress(signer2, network);
65
+ address3 = EcKeyPair.getTaprootAddress(signer3, network);
66
+ defaultAddress = EcKeyPair.getTaprootAddress(defaultSigner, network);
67
+ });
68
+
69
+ // Helper to create a taproot UTXO
70
+ const createTaprootUtxo = (
71
+ address: string,
72
+ value: bigint,
73
+ txId: string = '0'.repeat(64),
74
+ index: number = 0,
75
+ ): UTXO => {
76
+ const p2tr = payments.p2tr({ address, network });
77
+ return {
78
+ transactionId: txId,
79
+ outputIndex: index,
80
+ value,
81
+ scriptPubKey: {
82
+ hex: p2tr.output!.toString('hex'),
83
+ address,
84
+ },
85
+ };
86
+ };
87
+
88
+ // Helper to create mock serialized state
89
+ const createMockSerializedState = (
90
+ type: TransactionType = TransactionType.FUNDING,
91
+ overrides: Partial<ISerializableTransactionState> = {},
92
+ ): ISerializableTransactionState => {
93
+ const baseState: ISerializableTransactionState = {
94
+ header: {
95
+ formatVersion: SERIALIZATION_FORMAT_VERSION,
96
+ consensusVersion: currentConsensus,
97
+ transactionType: type,
98
+ chainId: ChainId.Bitcoin,
99
+ timestamp: Date.now(),
100
+ },
101
+ baseParams: {
102
+ from: address1,
103
+ to: address2,
104
+ feeRate: 10,
105
+ priorityFee: '1000',
106
+ gasSatFee: '500',
107
+ networkName: 'regtest',
108
+ txVersion: 2,
109
+ anchor: false,
110
+ },
111
+ utxos: [
112
+ {
113
+ transactionId: '0'.repeat(64),
114
+ outputIndex: 0,
115
+ value: '100000',
116
+ scriptPubKeyHex: payments.p2tr({ address: address1, network }).output!.toString('hex'),
117
+ scriptPubKeyAddress: address1,
118
+ },
119
+ ],
120
+ optionalInputs: [],
121
+ optionalOutputs: [],
122
+ addressRotationEnabled: false,
123
+ signerMappings: [],
124
+ typeSpecificData: {
125
+ type: TransactionType.FUNDING,
126
+ amount: '50000',
127
+ splitInputsInto: 1,
128
+ } as FundingSpecificData,
129
+ precomputedData: {},
130
+ };
131
+
132
+ return { ...baseState, ...overrides };
133
+ };
134
+
135
+ describe('TransactionSerializer', () => {
136
+ describe('serialize/deserialize', () => {
137
+ it('should serialize and deserialize a basic funding transaction state', () => {
138
+ const state = createMockSerializedState(TransactionType.FUNDING);
139
+
140
+ const serialized = TransactionSerializer.serialize(state);
141
+ expect(serialized).toBeInstanceOf(Buffer);
142
+ expect(serialized.length).toBeGreaterThan(32); // At least checksum size
143
+
144
+ const deserialized = TransactionSerializer.deserialize(serialized);
145
+ expect(deserialized.header.transactionType).toBe(TransactionType.FUNDING);
146
+ expect(deserialized.baseParams.from).toBe(state.baseParams.from);
147
+ expect(deserialized.baseParams.feeRate).toBe(state.baseParams.feeRate);
148
+ });
149
+
150
+ it('should preserve all header fields', () => {
151
+ const state = createMockSerializedState();
152
+ state.header.timestamp = 1234567890123;
153
+
154
+ const deserialized = TransactionSerializer.deserialize(
155
+ TransactionSerializer.serialize(state),
156
+ );
157
+
158
+ expect(deserialized.header.formatVersion).toBe(SERIALIZATION_FORMAT_VERSION);
159
+ expect(deserialized.header.consensusVersion).toBe(currentConsensus);
160
+ expect(deserialized.header.transactionType).toBe(TransactionType.FUNDING);
161
+ expect(deserialized.header.chainId).toBe(ChainId.Bitcoin);
162
+ expect(deserialized.header.timestamp).toBe(1234567890123);
163
+ });
164
+
165
+ it('should preserve all base params fields', () => {
166
+ const state = createMockSerializedState();
167
+ const baseParams: SerializedBaseParams = {
168
+ from: address1,
169
+ to: address2,
170
+ feeRate: 15.5, // Test decimal fee rate
171
+ priorityFee: '2000',
172
+ gasSatFee: '1000',
173
+ networkName: 'testnet',
174
+ txVersion: 2,
175
+ note: Buffer.from('test note').toString('hex'),
176
+ anchor: true,
177
+ debugFees: true,
178
+ };
179
+ state.baseParams = baseParams;
180
+
181
+ const deserialized = TransactionSerializer.deserialize(
182
+ TransactionSerializer.serialize(state),
183
+ );
184
+
185
+ expect(deserialized.baseParams.from).toBe(baseParams.from);
186
+ expect(deserialized.baseParams.to).toBe(baseParams.to);
187
+ expect(deserialized.baseParams.feeRate).toBeCloseTo(baseParams.feeRate, 3);
188
+ expect(deserialized.baseParams.priorityFee).toBe(baseParams.priorityFee);
189
+ expect(deserialized.baseParams.gasSatFee).toBe(baseParams.gasSatFee);
190
+ expect(deserialized.baseParams.networkName).toBe(baseParams.networkName);
191
+ expect(deserialized.baseParams.txVersion).toBe(baseParams.txVersion);
192
+ expect(deserialized.baseParams.note).toBe(baseParams.note);
193
+ expect(deserialized.baseParams.anchor).toBe(baseParams.anchor);
194
+ expect(deserialized.baseParams.debugFees).toBe(baseParams.debugFees);
195
+ });
196
+
197
+ it('should handle optional "to" field being undefined', () => {
198
+ const state = createMockSerializedState();
199
+ state.baseParams.to = undefined;
200
+
201
+ const deserialized = TransactionSerializer.deserialize(
202
+ TransactionSerializer.serialize(state),
203
+ );
204
+
205
+ expect(deserialized.baseParams.to).toBeUndefined();
206
+ });
207
+
208
+ it('should preserve UTXO data correctly', () => {
209
+ const utxo: SerializedUTXO = {
210
+ transactionId: 'a'.repeat(64),
211
+ outputIndex: 5,
212
+ value: '999999999',
213
+ scriptPubKeyHex: 'deadbeef',
214
+ scriptPubKeyAddress: address1,
215
+ redeemScript: 'cafe0001',
216
+ witnessScript: 'babe0002',
217
+ nonWitnessUtxo: 'feed0003',
218
+ };
219
+
220
+ const state = createMockSerializedState();
221
+ state.utxos = [utxo];
222
+
223
+ const deserialized = TransactionSerializer.deserialize(
224
+ TransactionSerializer.serialize(state),
225
+ );
226
+
227
+ expect(deserialized.utxos).toHaveLength(1);
228
+ expect(deserialized.utxos[0].transactionId).toBe(utxo.transactionId);
229
+ expect(deserialized.utxos[0].outputIndex).toBe(utxo.outputIndex);
230
+ expect(deserialized.utxos[0].value).toBe(utxo.value);
231
+ expect(deserialized.utxos[0].scriptPubKeyHex).toBe(utxo.scriptPubKeyHex);
232
+ expect(deserialized.utxos[0].scriptPubKeyAddress).toBe(utxo.scriptPubKeyAddress);
233
+ expect(deserialized.utxos[0].redeemScript).toBe(utxo.redeemScript);
234
+ expect(deserialized.utxos[0].witnessScript).toBe(utxo.witnessScript);
235
+ expect(deserialized.utxos[0].nonWitnessUtxo).toBe(utxo.nonWitnessUtxo);
236
+ });
237
+
238
+ it('should handle multiple UTXOs', () => {
239
+ const state = createMockSerializedState();
240
+ state.utxos = [
241
+ {
242
+ transactionId: '1'.repeat(64),
243
+ outputIndex: 0,
244
+ value: '10000',
245
+ scriptPubKeyHex: 'aa',
246
+ scriptPubKeyAddress: address1,
247
+ },
248
+ {
249
+ transactionId: '2'.repeat(64),
250
+ outputIndex: 1,
251
+ value: '20000',
252
+ scriptPubKeyHex: 'bb',
253
+ scriptPubKeyAddress: address2,
254
+ },
255
+ {
256
+ transactionId: '3'.repeat(64),
257
+ outputIndex: 2,
258
+ value: '30000',
259
+ scriptPubKeyHex: 'cc',
260
+ scriptPubKeyAddress: address3,
261
+ },
262
+ ];
263
+
264
+ const deserialized = TransactionSerializer.deserialize(
265
+ TransactionSerializer.serialize(state),
266
+ );
267
+
268
+ expect(deserialized.utxos).toHaveLength(3);
269
+ expect(deserialized.utxos[0].transactionId).toBe('1'.repeat(64));
270
+ expect(deserialized.utxos[1].transactionId).toBe('2'.repeat(64));
271
+ expect(deserialized.utxos[2].transactionId).toBe('3'.repeat(64));
272
+ });
273
+
274
+ it('should preserve optional inputs', () => {
275
+ const state = createMockSerializedState();
276
+ state.optionalInputs = [
277
+ {
278
+ transactionId: 'f'.repeat(64),
279
+ outputIndex: 99,
280
+ value: '12345',
281
+ scriptPubKeyHex: 'ff',
282
+ },
283
+ ];
284
+
285
+ const deserialized = TransactionSerializer.deserialize(
286
+ TransactionSerializer.serialize(state),
287
+ );
288
+
289
+ expect(deserialized.optionalInputs).toHaveLength(1);
290
+ expect(deserialized.optionalInputs[0].outputIndex).toBe(99);
291
+ });
292
+
293
+ it('should preserve optional outputs', () => {
294
+ const output: SerializedOutput = {
295
+ value: 5000,
296
+ address: address2,
297
+ tapInternalKey: 'abcd1234',
298
+ };
299
+
300
+ const state = createMockSerializedState();
301
+ state.optionalOutputs = [output];
302
+
303
+ const deserialized = TransactionSerializer.deserialize(
304
+ TransactionSerializer.serialize(state),
305
+ );
306
+
307
+ expect(deserialized.optionalOutputs).toHaveLength(1);
308
+ expect(deserialized.optionalOutputs[0].value).toBe(output.value);
309
+ expect(deserialized.optionalOutputs[0].address).toBe(output.address);
310
+ expect(deserialized.optionalOutputs[0].tapInternalKey).toBe(output.tapInternalKey);
311
+ });
312
+
313
+ it('should preserve script-based outputs', () => {
314
+ const output: SerializedOutput = {
315
+ value: 6000,
316
+ script: 'deadbeefcafe',
317
+ };
318
+
319
+ const state = createMockSerializedState();
320
+ state.optionalOutputs = [output];
321
+
322
+ const deserialized = TransactionSerializer.deserialize(
323
+ TransactionSerializer.serialize(state),
324
+ );
325
+
326
+ expect(deserialized.optionalOutputs).toHaveLength(1);
327
+ expect(deserialized.optionalOutputs[0].script).toBe(output.script);
328
+ expect(deserialized.optionalOutputs[0].address).toBeUndefined();
329
+ });
330
+
331
+ it('should preserve signer mappings for address rotation', () => {
332
+ const state = createMockSerializedState();
333
+ state.addressRotationEnabled = true;
334
+ state.signerMappings = [
335
+ { address: address1, inputIndices: [0, 2, 4] },
336
+ { address: address2, inputIndices: [1, 3] },
337
+ ];
338
+
339
+ const deserialized = TransactionSerializer.deserialize(
340
+ TransactionSerializer.serialize(state),
341
+ );
342
+
343
+ expect(deserialized.addressRotationEnabled).toBe(true);
344
+ expect(deserialized.signerMappings).toHaveLength(2);
345
+ expect(deserialized.signerMappings[0].address).toBe(address1);
346
+ expect(deserialized.signerMappings[0].inputIndices).toEqual([0, 2, 4]);
347
+ expect(deserialized.signerMappings[1].address).toBe(address2);
348
+ expect(deserialized.signerMappings[1].inputIndices).toEqual([1, 3]);
349
+ });
350
+
351
+ it('should preserve precomputed data', () => {
352
+ const precomputed: PrecomputedData = {
353
+ compiledTargetScript: 'abcdef123456',
354
+ randomBytes: '0123456789abcdef',
355
+ estimatedFees: '5000',
356
+ contractSeed: 'seedvalue',
357
+ contractAddress: address3,
358
+ };
359
+
360
+ const state = createMockSerializedState();
361
+ state.precomputedData = precomputed;
362
+
363
+ const deserialized = TransactionSerializer.deserialize(
364
+ TransactionSerializer.serialize(state),
365
+ );
366
+
367
+ expect(deserialized.precomputedData.compiledTargetScript).toBe(precomputed.compiledTargetScript);
368
+ expect(deserialized.precomputedData.randomBytes).toBe(precomputed.randomBytes);
369
+ expect(deserialized.precomputedData.estimatedFees).toBe(precomputed.estimatedFees);
370
+ expect(deserialized.precomputedData.contractSeed).toBe(precomputed.contractSeed);
371
+ expect(deserialized.precomputedData.contractAddress).toBe(precomputed.contractAddress);
372
+ });
373
+ });
374
+
375
+ describe('type-specific data', () => {
376
+ it('should serialize/deserialize FundingSpecificData', () => {
377
+ const typeData: FundingSpecificData = {
378
+ type: TransactionType.FUNDING,
379
+ amount: '123456789',
380
+ splitInputsInto: 5,
381
+ };
382
+
383
+ const state = createMockSerializedState(TransactionType.FUNDING);
384
+ state.typeSpecificData = typeData;
385
+
386
+ const deserialized = TransactionSerializer.deserialize(
387
+ TransactionSerializer.serialize(state),
388
+ );
389
+
390
+ expect(isFundingSpecificData(deserialized.typeSpecificData)).toBe(true);
391
+ const data = deserialized.typeSpecificData as FundingSpecificData;
392
+ expect(data.amount).toBe(typeData.amount);
393
+ expect(data.splitInputsInto).toBe(typeData.splitInputsInto);
394
+ });
395
+
396
+ it('should serialize/deserialize DeploymentSpecificData', () => {
397
+ const typeData: DeploymentSpecificData = {
398
+ type: TransactionType.DEPLOYMENT,
399
+ bytecode: 'deadbeef'.repeat(100),
400
+ calldata: 'cafebabe',
401
+ challenge: createMockChallenge(),
402
+ revealMLDSAPublicKey: true,
403
+ linkMLDSAPublicKeyToAddress: true,
404
+ hashedPublicKey: 'abcd'.repeat(16),
405
+ };
406
+
407
+ const state = createMockSerializedState(TransactionType.DEPLOYMENT);
408
+ state.header.transactionType = TransactionType.DEPLOYMENT;
409
+ state.typeSpecificData = typeData;
410
+
411
+ const deserialized = TransactionSerializer.deserialize(
412
+ TransactionSerializer.serialize(state),
413
+ );
414
+
415
+ expect(isDeploymentSpecificData(deserialized.typeSpecificData)).toBe(true);
416
+ const data = deserialized.typeSpecificData as DeploymentSpecificData;
417
+ expect(data.bytecode).toBe(typeData.bytecode);
418
+ expect(data.calldata).toBe(typeData.calldata);
419
+ expect(data.revealMLDSAPublicKey).toBe(true);
420
+ expect(data.linkMLDSAPublicKeyToAddress).toBe(true);
421
+ expect(data.hashedPublicKey).toBe(typeData.hashedPublicKey);
422
+ });
423
+
424
+ it('should serialize/deserialize InteractionSpecificData', () => {
425
+ const typeData: InteractionSpecificData = {
426
+ type: TransactionType.INTERACTION,
427
+ calldata: 'cafebabe12345678',
428
+ contract: 'bcrt1qtest',
429
+ challenge: createMockChallenge(),
430
+ loadedStorage: {
431
+ 'key1': ['value1', 'value2'],
432
+ 'key2': ['value3'],
433
+ },
434
+ isCancellation: true,
435
+ disableAutoRefund: true,
436
+ revealMLDSAPublicKey: false,
437
+ };
438
+
439
+ const state = createMockSerializedState(TransactionType.INTERACTION);
440
+ state.header.transactionType = TransactionType.INTERACTION;
441
+ state.typeSpecificData = typeData;
442
+
443
+ const deserialized = TransactionSerializer.deserialize(
444
+ TransactionSerializer.serialize(state),
445
+ );
446
+
447
+ expect(isInteractionSpecificData(deserialized.typeSpecificData)).toBe(true);
448
+ const data = deserialized.typeSpecificData as InteractionSpecificData;
449
+ expect(data.calldata).toBe(typeData.calldata);
450
+ expect(data.contract).toBe(typeData.contract);
451
+ expect(data.loadedStorage).toEqual(typeData.loadedStorage);
452
+ expect(data.isCancellation).toBe(true);
453
+ expect(data.disableAutoRefund).toBe(true);
454
+ });
455
+
456
+ it('should serialize/deserialize MultiSigSpecificData', () => {
457
+ const typeData: MultiSigSpecificData = {
458
+ type: TransactionType.MULTI_SIG,
459
+ pubkeys: ['aa'.repeat(33), 'bb'.repeat(33), 'cc'.repeat(33)],
460
+ minimumSignatures: 2,
461
+ receiver: address2,
462
+ requestedAmount: '500000',
463
+ refundVault: address3,
464
+ originalInputCount: 3,
465
+ existingPsbtBase64: 'cHNidP8BAH...',
466
+ };
467
+
468
+ const state = createMockSerializedState(TransactionType.MULTI_SIG);
469
+ state.header.transactionType = TransactionType.MULTI_SIG;
470
+ state.typeSpecificData = typeData;
471
+
472
+ const deserialized = TransactionSerializer.deserialize(
473
+ TransactionSerializer.serialize(state),
474
+ );
475
+
476
+ expect(isMultiSigSpecificData(deserialized.typeSpecificData)).toBe(true);
477
+ const data = deserialized.typeSpecificData as MultiSigSpecificData;
478
+ expect(data.pubkeys).toEqual(typeData.pubkeys);
479
+ expect(data.minimumSignatures).toBe(2);
480
+ expect(data.receiver).toBe(typeData.receiver);
481
+ expect(data.requestedAmount).toBe(typeData.requestedAmount);
482
+ expect(data.refundVault).toBe(typeData.refundVault);
483
+ expect(data.originalInputCount).toBe(3);
484
+ expect(data.existingPsbtBase64).toBe(typeData.existingPsbtBase64);
485
+ });
486
+
487
+ it('should serialize/deserialize CustomScriptSpecificData', () => {
488
+ const typeData: CustomScriptSpecificData = {
489
+ type: TransactionType.CUSTOM_CODE,
490
+ scriptElements: [
491
+ { elementType: 'buffer', value: 'deadbeef' },
492
+ { elementType: 'opcode', value: 118 }, // OP_DUP
493
+ { elementType: 'opcode', value: 169 }, // OP_HASH160
494
+ ],
495
+ witnesses: ['abcdef0123456789', 'fedcba9876543210'],
496
+ annex: 'aabbccdd',
497
+ };
498
+
499
+ const state = createMockSerializedState(TransactionType.CUSTOM_CODE);
500
+ state.header.transactionType = TransactionType.CUSTOM_CODE;
501
+ state.typeSpecificData = typeData;
502
+
503
+ const deserialized = TransactionSerializer.deserialize(
504
+ TransactionSerializer.serialize(state),
505
+ );
506
+
507
+ expect(isCustomScriptSpecificData(deserialized.typeSpecificData)).toBe(true);
508
+ const data = deserialized.typeSpecificData as CustomScriptSpecificData;
509
+ expect(data.scriptElements).toHaveLength(3);
510
+ expect(data.scriptElements[0]).toEqual({ elementType: 'buffer', value: 'deadbeef' });
511
+ expect(data.scriptElements[1]).toEqual({ elementType: 'opcode', value: 118 });
512
+ expect(data.witnesses).toEqual(typeData.witnesses);
513
+ expect(data.annex).toBe(typeData.annex);
514
+ });
515
+
516
+ it('should serialize/deserialize CancelSpecificData', () => {
517
+ const typeData: CancelSpecificData = {
518
+ type: TransactionType.CANCEL,
519
+ compiledTargetScript: 'deadbeefcafe1234',
520
+ };
521
+
522
+ const state = createMockSerializedState(TransactionType.CANCEL);
523
+ state.header.transactionType = TransactionType.CANCEL;
524
+ state.typeSpecificData = typeData;
525
+
526
+ const deserialized = TransactionSerializer.deserialize(
527
+ TransactionSerializer.serialize(state),
528
+ );
529
+
530
+ expect(isCancelSpecificData(deserialized.typeSpecificData)).toBe(true);
531
+ const data = deserialized.typeSpecificData as CancelSpecificData;
532
+ expect(data.compiledTargetScript).toBe(typeData.compiledTargetScript);
533
+ });
534
+ });
535
+
536
+ describe('format conversion', () => {
537
+ it('should convert to/from base64', () => {
538
+ const state = createMockSerializedState();
539
+
540
+ const base64 = TransactionSerializer.toBase64(state);
541
+ expect(typeof base64).toBe('string');
542
+ expect(base64.length).toBeGreaterThan(0);
543
+
544
+ const restored = TransactionSerializer.fromBase64(base64);
545
+ expect(restored.header.transactionType).toBe(state.header.transactionType);
546
+ expect(restored.baseParams.from).toBe(state.baseParams.from);
547
+ });
548
+
549
+ it('should convert to/from hex', () => {
550
+ const state = createMockSerializedState();
551
+
552
+ const hex = TransactionSerializer.toHex(state);
553
+ expect(typeof hex).toBe('string');
554
+ expect(/^[0-9a-f]+$/i.test(hex)).toBe(true);
555
+
556
+ const restored = TransactionSerializer.fromHex(hex);
557
+ expect(restored.header.transactionType).toBe(state.header.transactionType);
558
+ });
559
+ });
560
+
561
+ describe('error handling', () => {
562
+ it('should throw on invalid magic byte', () => {
563
+ const state = createMockSerializedState();
564
+ const serialized = TransactionSerializer.serialize(state);
565
+
566
+ // Corrupt magic byte
567
+ serialized[0] = 0x00;
568
+
569
+ // Recalculate checksum to bypass checksum error
570
+ const payload = serialized.subarray(0, -32);
571
+ const crypto = require('crypto');
572
+ const hash1 = crypto.createHash('sha256').update(payload).digest();
573
+ const newChecksum = crypto.createHash('sha256').update(hash1).digest();
574
+ newChecksum.copy(serialized, serialized.length - 32);
575
+
576
+ expect(() => TransactionSerializer.deserialize(serialized)).toThrow(/Invalid magic byte/);
577
+ });
578
+
579
+ it('should throw on invalid checksum', () => {
580
+ const state = createMockSerializedState();
581
+ const serialized = TransactionSerializer.serialize(state);
582
+
583
+ // Corrupt checksum
584
+ serialized[serialized.length - 1] ^= 0xff;
585
+
586
+ expect(() => TransactionSerializer.deserialize(serialized)).toThrow(/Invalid checksum/);
587
+ });
588
+
589
+ it('should throw on data too short', () => {
590
+ const shortData = Buffer.alloc(16); // Less than 32 bytes
591
+
592
+ expect(() => TransactionSerializer.deserialize(shortData)).toThrow(/too short/);
593
+ });
594
+
595
+ it('should throw on unsupported format version', () => {
596
+ const state = createMockSerializedState();
597
+ const serialized = TransactionSerializer.serialize(state);
598
+
599
+ // Set format version to a high value
600
+ serialized[1] = 255;
601
+
602
+ // Recalculate checksum
603
+ const payload = serialized.subarray(0, -32);
604
+ const crypto = require('crypto');
605
+ const hash1 = crypto.createHash('sha256').update(payload).digest();
606
+ const newChecksum = crypto.createHash('sha256').update(hash1).digest();
607
+ newChecksum.copy(serialized, serialized.length - 32);
608
+
609
+ expect(() => TransactionSerializer.deserialize(serialized)).toThrow(/Unsupported format version/);
610
+ });
611
+ });
612
+
613
+ describe('network serialization', () => {
614
+ it('should serialize mainnet correctly', () => {
615
+ const state = createMockSerializedState();
616
+ state.baseParams.networkName = 'mainnet';
617
+
618
+ const deserialized = TransactionSerializer.deserialize(
619
+ TransactionSerializer.serialize(state),
620
+ );
621
+
622
+ expect(deserialized.baseParams.networkName).toBe('mainnet');
623
+ });
624
+
625
+ it('should serialize testnet correctly', () => {
626
+ const state = createMockSerializedState();
627
+ state.baseParams.networkName = 'testnet';
628
+
629
+ const deserialized = TransactionSerializer.deserialize(
630
+ TransactionSerializer.serialize(state),
631
+ );
632
+
633
+ expect(deserialized.baseParams.networkName).toBe('testnet');
634
+ });
635
+
636
+ it('should serialize regtest correctly', () => {
637
+ const state = createMockSerializedState();
638
+ state.baseParams.networkName = 'regtest';
639
+
640
+ const deserialized = TransactionSerializer.deserialize(
641
+ TransactionSerializer.serialize(state),
642
+ );
643
+
644
+ expect(deserialized.baseParams.networkName).toBe('regtest');
645
+ });
646
+ });
647
+ });
648
+
649
+ describe('TransactionStateCapture', () => {
650
+ describe('fromFunding', () => {
651
+ it('should capture state from funding transaction parameters', () => {
652
+ const params = {
653
+ signer: defaultSigner,
654
+ mldsaSigner: null,
655
+ network,
656
+ utxos: [createTaprootUtxo(address1, 100000n)],
657
+ from: address1,
658
+ to: address2,
659
+ feeRate: 10,
660
+ priorityFee: 1000n,
661
+ gasSatFee: 500n,
662
+ amount: 50000n,
663
+ splitInputsInto: 2,
664
+ };
665
+
666
+ const state = TransactionStateCapture.fromFunding(params);
667
+
668
+ expect(state.header.transactionType).toBe(TransactionType.FUNDING);
669
+ expect(state.baseParams.from).toBe(address1);
670
+ expect(state.baseParams.to).toBe(address2);
671
+ expect(state.baseParams.feeRate).toBe(10);
672
+ expect(state.utxos).toHaveLength(1);
673
+ expect(isFundingSpecificData(state.typeSpecificData)).toBe(true);
674
+ const data = state.typeSpecificData as FundingSpecificData;
675
+ expect(data.amount).toBe('50000');
676
+ expect(data.splitInputsInto).toBe(2);
677
+ });
678
+
679
+ it('should capture precomputed data', () => {
680
+ const params = {
681
+ signer: defaultSigner,
682
+ mldsaSigner: null,
683
+ network,
684
+ utxos: [createTaprootUtxo(address1, 100000n)],
685
+ from: address1,
686
+ to: address2,
687
+ feeRate: 10,
688
+ priorityFee: 1000n,
689
+ gasSatFee: 500n,
690
+ amount: 50000n,
691
+ };
692
+
693
+ const precomputed = {
694
+ estimatedFees: '2000',
695
+ };
696
+
697
+ const state = TransactionStateCapture.fromFunding(params, precomputed);
698
+
699
+ expect(state.precomputedData.estimatedFees).toBe('2000');
700
+ });
701
+
702
+ it('should handle address rotation configuration', () => {
703
+ const signerMap = createSignerMap([
704
+ [address1, signer1],
705
+ [address2, signer2],
706
+ ]);
707
+
708
+ const params = {
709
+ signer: defaultSigner,
710
+ mldsaSigner: null,
711
+ network,
712
+ utxos: [
713
+ createTaprootUtxo(address1, 50000n, '1'.repeat(64), 0),
714
+ createTaprootUtxo(address2, 50000n, '2'.repeat(64), 0),
715
+ ],
716
+ from: address1,
717
+ to: address3,
718
+ feeRate: 10,
719
+ priorityFee: 1000n,
720
+ gasSatFee: 500n,
721
+ amount: 80000n,
722
+ addressRotation: createAddressRotation(signerMap),
723
+ };
724
+
725
+ const state = TransactionStateCapture.fromFunding(params);
726
+
727
+ expect(state.addressRotationEnabled).toBe(true);
728
+ expect(state.signerMappings).toHaveLength(2);
729
+ });
730
+ });
731
+
732
+ describe('UTXO serialization', () => {
733
+ it('should serialize UTXO with all optional fields', () => {
734
+ const utxo: UTXO = {
735
+ transactionId: 'a'.repeat(64),
736
+ outputIndex: 5,
737
+ value: 999999n,
738
+ scriptPubKey: {
739
+ hex: 'deadbeef',
740
+ address: address1,
741
+ },
742
+ redeemScript: Buffer.from('cafe', 'hex'),
743
+ witnessScript: Buffer.from('babe', 'hex'),
744
+ nonWitnessUtxo: Buffer.from('feed', 'hex'),
745
+ };
746
+
747
+ const params = {
748
+ signer: defaultSigner,
749
+ mldsaSigner: null,
750
+ network,
751
+ utxos: [utxo],
752
+ from: address1,
753
+ to: address2,
754
+ feeRate: 10,
755
+ priorityFee: 1000n,
756
+ gasSatFee: 500n,
757
+ amount: 50000n,
758
+ };
759
+
760
+ const state = TransactionStateCapture.fromFunding(params);
761
+
762
+ expect(state.utxos[0].transactionId).toBe(utxo.transactionId);
763
+ expect(state.utxos[0].redeemScript).toBe('cafe');
764
+ expect(state.utxos[0].witnessScript).toBe('babe');
765
+ expect(state.utxos[0].nonWitnessUtxo).toBe('feed');
766
+ });
767
+
768
+ it('should handle UTXOs with string scripts', () => {
769
+ const utxo: UTXO = {
770
+ transactionId: 'b'.repeat(64),
771
+ outputIndex: 0,
772
+ value: 10000n,
773
+ scriptPubKey: {
774
+ hex: 'aabbcc',
775
+ address: address1,
776
+ },
777
+ redeemScript: 'ddeeff', // String instead of Buffer
778
+ };
779
+
780
+ const params = {
781
+ signer: defaultSigner,
782
+ mldsaSigner: null,
783
+ network,
784
+ utxos: [utxo],
785
+ from: address1,
786
+ to: address2,
787
+ feeRate: 10,
788
+ priorityFee: 1000n,
789
+ gasSatFee: 500n,
790
+ amount: 5000n,
791
+ };
792
+
793
+ const state = TransactionStateCapture.fromFunding(params);
794
+
795
+ expect(state.utxos[0].redeemScript).toBe('ddeeff');
796
+ });
797
+ });
798
+ });
799
+
800
+ describe('TransactionReconstructor', () => {
801
+ describe('reconstruct', () => {
802
+ it('should reconstruct a funding transaction', () => {
803
+ const state = createMockSerializedState(TransactionType.FUNDING);
804
+
805
+ const options: ReconstructionOptions = {
806
+ signer: defaultSigner,
807
+ };
808
+
809
+ const builder = TransactionReconstructor.reconstruct(state, options);
810
+
811
+ expect(builder).toBeInstanceOf(FundingTransaction);
812
+ expect(builder.type).toBe(TransactionType.FUNDING);
813
+ });
814
+
815
+ it('should apply fee rate override', () => {
816
+ const state = createMockSerializedState(TransactionType.FUNDING);
817
+ state.baseParams.feeRate = 10;
818
+
819
+ const options: ReconstructionOptions = {
820
+ signer: defaultSigner,
821
+ newFeeRate: 50,
822
+ };
823
+
824
+ const builder = TransactionReconstructor.reconstruct(state, options);
825
+
826
+ // The builder should have the new fee rate
827
+ expect(builder).toBeDefined();
828
+ });
829
+
830
+ it('should apply priority fee override', () => {
831
+ const state = createMockSerializedState(TransactionType.FUNDING);
832
+
833
+ const options: ReconstructionOptions = {
834
+ signer: defaultSigner,
835
+ newPriorityFee: 5000n,
836
+ };
837
+
838
+ const builder = TransactionReconstructor.reconstruct(state, options);
839
+ expect(builder).toBeDefined();
840
+ });
841
+
842
+ it('should apply gas sat fee override', () => {
843
+ const state = createMockSerializedState(TransactionType.FUNDING);
844
+
845
+ const options: ReconstructionOptions = {
846
+ signer: defaultSigner,
847
+ newGasSatFee: 2000n,
848
+ };
849
+
850
+ const builder = TransactionReconstructor.reconstruct(state, options);
851
+ expect(builder).toBeDefined();
852
+ });
853
+
854
+ it('should throw when address rotation enabled but no signerMap provided', () => {
855
+ const state = createMockSerializedState(TransactionType.FUNDING);
856
+ state.addressRotationEnabled = true;
857
+
858
+ const options: ReconstructionOptions = {
859
+ signer: defaultSigner,
860
+ // No signerMap provided
861
+ };
862
+
863
+ expect(() => TransactionReconstructor.reconstruct(state, options)).toThrow(
864
+ /signerMap/,
865
+ );
866
+ });
867
+
868
+ it('should reconstruct with address rotation when signerMap provided', () => {
869
+ const state = createMockSerializedState(TransactionType.FUNDING);
870
+ state.addressRotationEnabled = true;
871
+ state.signerMappings = [{ address: address1, inputIndices: [0] }];
872
+
873
+ const signerMap = createSignerMap([[address1, signer1]]);
874
+
875
+ const options: ReconstructionOptions = {
876
+ signer: defaultSigner,
877
+ signerMap,
878
+ };
879
+
880
+ const builder = TransactionReconstructor.reconstruct(state, options);
881
+ expect(builder).toBeDefined();
882
+ });
883
+ });
884
+
885
+ describe('network conversion', () => {
886
+ it('should convert mainnet name to network', () => {
887
+ const state = createMockSerializedState();
888
+ state.baseParams.networkName = 'mainnet';
889
+
890
+ const options: ReconstructionOptions = {
891
+ signer: defaultSigner,
892
+ };
893
+
894
+ const builder = TransactionReconstructor.reconstruct(state, options);
895
+ expect(builder).toBeDefined();
896
+ });
897
+
898
+ it('should convert testnet name to network', () => {
899
+ const state = createMockSerializedState();
900
+ state.baseParams.networkName = 'testnet';
901
+
902
+ const options: ReconstructionOptions = {
903
+ signer: defaultSigner,
904
+ };
905
+
906
+ const builder = TransactionReconstructor.reconstruct(state, options);
907
+ expect(builder).toBeDefined();
908
+ });
909
+
910
+ it('should convert regtest name to network', () => {
911
+ const state = createMockSerializedState();
912
+ state.baseParams.networkName = 'regtest';
913
+
914
+ const options: ReconstructionOptions = {
915
+ signer: defaultSigner,
916
+ };
917
+
918
+ const builder = TransactionReconstructor.reconstruct(state, options);
919
+ expect(builder).toBeDefined();
920
+ });
921
+ });
922
+ });
923
+
924
+ describe('OfflineTransactionManager', () => {
925
+ describe('exportFunding', () => {
926
+ it('should export funding transaction to base64', () => {
927
+ const params = {
928
+ signer: defaultSigner,
929
+ mldsaSigner: null,
930
+ network,
931
+ utxos: [createTaprootUtxo(address1, 100000n)],
932
+ from: address1,
933
+ to: address2,
934
+ feeRate: 10,
935
+ priorityFee: 1000n,
936
+ gasSatFee: 500n,
937
+ amount: 50000n,
938
+ };
939
+
940
+ const exported = OfflineTransactionManager.exportFunding(params);
941
+
942
+ expect(typeof exported).toBe('string');
943
+ expect(exported.length).toBeGreaterThan(0);
944
+
945
+ // Should be valid base64
946
+ expect(() => Buffer.from(exported, 'base64')).not.toThrow();
947
+ });
948
+ });
949
+
950
+ describe('importForSigning', () => {
951
+ it('should import serialized state and create builder', () => {
952
+ const params = {
953
+ signer: defaultSigner,
954
+ mldsaSigner: null,
955
+ network,
956
+ utxos: [createTaprootUtxo(address1, 100000n)],
957
+ from: address1,
958
+ to: address2,
959
+ feeRate: 10,
960
+ priorityFee: 1000n,
961
+ gasSatFee: 500n,
962
+ amount: 50000n,
963
+ };
964
+
965
+ const exported = OfflineTransactionManager.exportFunding(params);
966
+
967
+ const builder = OfflineTransactionManager.importForSigning(exported, {
968
+ signer: signer1,
969
+ });
970
+
971
+ expect(builder).toBeInstanceOf(FundingTransaction);
972
+ });
973
+ });
974
+
975
+ describe('inspect', () => {
976
+ it('should return parsed state for inspection', () => {
977
+ const params = {
978
+ signer: defaultSigner,
979
+ mldsaSigner: null,
980
+ network,
981
+ utxos: [createTaprootUtxo(address1, 100000n)],
982
+ from: address1,
983
+ to: address2,
984
+ feeRate: 15,
985
+ priorityFee: 1000n,
986
+ gasSatFee: 500n,
987
+ amount: 50000n,
988
+ };
989
+
990
+ const exported = OfflineTransactionManager.exportFunding(params);
991
+ const inspected = OfflineTransactionManager.inspect(exported);
992
+
993
+ expect(inspected.header.transactionType).toBe(TransactionType.FUNDING);
994
+ expect(inspected.baseParams.from).toBe(address1);
995
+ expect(inspected.baseParams.to).toBe(address2);
996
+ expect(inspected.baseParams.feeRate).toBeCloseTo(15, 3);
997
+ });
998
+ });
999
+
1000
+ describe('validate', () => {
1001
+ it('should return true for valid serialized state', () => {
1002
+ const params = {
1003
+ signer: defaultSigner,
1004
+ mldsaSigner: null,
1005
+ network,
1006
+ utxos: [createTaprootUtxo(address1, 100000n)],
1007
+ from: address1,
1008
+ to: address2,
1009
+ feeRate: 10,
1010
+ priorityFee: 1000n,
1011
+ gasSatFee: 500n,
1012
+ amount: 50000n,
1013
+ };
1014
+
1015
+ const exported = OfflineTransactionManager.exportFunding(params);
1016
+
1017
+ expect(OfflineTransactionManager.validate(exported)).toBe(true);
1018
+ });
1019
+
1020
+ it('should return false for invalid serialized state', () => {
1021
+ expect(OfflineTransactionManager.validate('invalid base64!')).toBe(false);
1022
+ expect(OfflineTransactionManager.validate('')).toBe(false);
1023
+ expect(OfflineTransactionManager.validate('YWJj')).toBe(false); // Valid base64 but invalid data
1024
+ });
1025
+ });
1026
+
1027
+ describe('getType', () => {
1028
+ it('should return transaction type from serialized state', () => {
1029
+ const params = {
1030
+ signer: defaultSigner,
1031
+ mldsaSigner: null,
1032
+ network,
1033
+ utxos: [createTaprootUtxo(address1, 100000n)],
1034
+ from: address1,
1035
+ to: address2,
1036
+ feeRate: 10,
1037
+ priorityFee: 1000n,
1038
+ gasSatFee: 500n,
1039
+ amount: 50000n,
1040
+ };
1041
+
1042
+ const exported = OfflineTransactionManager.exportFunding(params);
1043
+ const type = OfflineTransactionManager.getType(exported);
1044
+
1045
+ expect(type).toBe(TransactionType.FUNDING);
1046
+ });
1047
+ });
1048
+
1049
+ describe('toHex/fromHex', () => {
1050
+ it('should convert between base64 and hex formats', () => {
1051
+ const params = {
1052
+ signer: defaultSigner,
1053
+ mldsaSigner: null,
1054
+ network,
1055
+ utxos: [createTaprootUtxo(address1, 100000n)],
1056
+ from: address1,
1057
+ to: address2,
1058
+ feeRate: 10,
1059
+ priorityFee: 1000n,
1060
+ gasSatFee: 500n,
1061
+ amount: 50000n,
1062
+ };
1063
+
1064
+ const base64 = OfflineTransactionManager.exportFunding(params);
1065
+ const hex = OfflineTransactionManager.toHex(base64);
1066
+
1067
+ expect(/^[0-9a-f]+$/i.test(hex)).toBe(true);
1068
+
1069
+ const backToBase64 = OfflineTransactionManager.fromHex(hex);
1070
+
1071
+ // Both should deserialize to the same state
1072
+ const state1 = OfflineTransactionManager.inspect(base64);
1073
+ const state2 = OfflineTransactionManager.inspect(backToBase64);
1074
+
1075
+ expect(state1.baseParams.from).toBe(state2.baseParams.from);
1076
+ expect(state1.baseParams.to).toBe(state2.baseParams.to);
1077
+ });
1078
+ });
1079
+
1080
+ describe('full workflow', () => {
1081
+ it('should complete export -> import -> reconstruct workflow', () => {
1082
+ // Phase 1: Export
1083
+ const params = {
1084
+ signer: defaultSigner,
1085
+ mldsaSigner: null,
1086
+ network,
1087
+ utxos: [createTaprootUtxo(address1, 100000n)],
1088
+ from: address1,
1089
+ to: address2,
1090
+ feeRate: 10,
1091
+ priorityFee: 1000n,
1092
+ gasSatFee: 500n,
1093
+ amount: 50000n,
1094
+ };
1095
+
1096
+ const exported = OfflineTransactionManager.exportFunding(params);
1097
+
1098
+ // Validate
1099
+ expect(OfflineTransactionManager.validate(exported)).toBe(true);
1100
+
1101
+ // Inspect
1102
+ const inspected = OfflineTransactionManager.inspect(exported);
1103
+ expect(inspected.header.transactionType).toBe(TransactionType.FUNDING);
1104
+
1105
+ // Phase 2: Import with different signer
1106
+ const builder = OfflineTransactionManager.importForSigning(exported, {
1107
+ signer: signer1,
1108
+ });
1109
+
1110
+ expect(builder).toBeDefined();
1111
+ expect(builder.type).toBe(TransactionType.FUNDING);
1112
+ });
1113
+
1114
+ it('should support fee bumping workflow', () => {
1115
+ const params = {
1116
+ signer: defaultSigner,
1117
+ mldsaSigner: null,
1118
+ network,
1119
+ utxos: [createTaprootUtxo(address1, 100000n)],
1120
+ from: address1,
1121
+ to: address2,
1122
+ feeRate: 10,
1123
+ priorityFee: 1000n,
1124
+ gasSatFee: 500n,
1125
+ amount: 50000n,
1126
+ };
1127
+
1128
+ const exported = OfflineTransactionManager.exportFunding(params);
1129
+
1130
+ // Rebuild with higher fee
1131
+ const bumpedState = OfflineTransactionManager.rebuildWithNewFees(
1132
+ exported,
1133
+ 50, // New fee rate
1134
+ );
1135
+
1136
+ // Verify the new state has updated fee
1137
+ const inspected = OfflineTransactionManager.inspect(bumpedState);
1138
+ expect(inspected.baseParams.feeRate).toBeCloseTo(50, 3);
1139
+ });
1140
+ });
1141
+ });
1142
+
1143
+ describe('Type Guards', () => {
1144
+ it('isFundingSpecificData should correctly identify funding data', () => {
1145
+ const fundingData: FundingSpecificData = {
1146
+ type: TransactionType.FUNDING,
1147
+ amount: '1000',
1148
+ splitInputsInto: 1,
1149
+ };
1150
+
1151
+ expect(isFundingSpecificData(fundingData)).toBe(true);
1152
+
1153
+ const otherData: CancelSpecificData = {
1154
+ type: TransactionType.CANCEL,
1155
+ compiledTargetScript: 'abc',
1156
+ };
1157
+
1158
+ expect(isFundingSpecificData(otherData)).toBe(false);
1159
+ });
1160
+
1161
+ it('isDeploymentSpecificData should correctly identify deployment data', () => {
1162
+ const deploymentData: DeploymentSpecificData = {
1163
+ type: TransactionType.DEPLOYMENT,
1164
+ bytecode: 'abc',
1165
+ challenge: createMockChallenge(),
1166
+ };
1167
+
1168
+ expect(isDeploymentSpecificData(deploymentData)).toBe(true);
1169
+ expect(isDeploymentSpecificData({ type: TransactionType.FUNDING } as any)).toBe(false);
1170
+ });
1171
+
1172
+ it('isInteractionSpecificData should correctly identify interaction data', () => {
1173
+ const interactionData: InteractionSpecificData = {
1174
+ type: TransactionType.INTERACTION,
1175
+ calldata: 'abc',
1176
+ challenge: createMockChallenge(),
1177
+ };
1178
+
1179
+ expect(isInteractionSpecificData(interactionData)).toBe(true);
1180
+ expect(isInteractionSpecificData({ type: TransactionType.FUNDING } as any)).toBe(false);
1181
+ });
1182
+
1183
+ it('isMultiSigSpecificData should correctly identify multisig data', () => {
1184
+ const multiSigData: MultiSigSpecificData = {
1185
+ type: TransactionType.MULTI_SIG,
1186
+ pubkeys: [],
1187
+ minimumSignatures: 2,
1188
+ receiver: 'addr',
1189
+ requestedAmount: '1000',
1190
+ refundVault: 'vault',
1191
+ originalInputCount: 1,
1192
+ };
1193
+
1194
+ expect(isMultiSigSpecificData(multiSigData)).toBe(true);
1195
+ expect(isMultiSigSpecificData({ type: TransactionType.FUNDING } as any)).toBe(false);
1196
+ });
1197
+
1198
+ it('isCustomScriptSpecificData should correctly identify custom script data', () => {
1199
+ const customData: CustomScriptSpecificData = {
1200
+ type: TransactionType.CUSTOM_CODE,
1201
+ scriptElements: [],
1202
+ witnesses: [],
1203
+ };
1204
+
1205
+ expect(isCustomScriptSpecificData(customData)).toBe(true);
1206
+ expect(isCustomScriptSpecificData({ type: TransactionType.FUNDING } as any)).toBe(false);
1207
+ });
1208
+
1209
+ it('isCancelSpecificData should correctly identify cancel data', () => {
1210
+ const cancelData: CancelSpecificData = {
1211
+ type: TransactionType.CANCEL,
1212
+ compiledTargetScript: 'abc',
1213
+ };
1214
+
1215
+ expect(isCancelSpecificData(cancelData)).toBe(true);
1216
+ expect(isCancelSpecificData({ type: TransactionType.FUNDING } as any)).toBe(false);
1217
+ });
1218
+ });
1219
+
1220
+ describe('Edge Cases', () => {
1221
+ it('should handle empty UTXOs array', () => {
1222
+ const state = createMockSerializedState();
1223
+ state.utxos = [];
1224
+
1225
+ const serialized = TransactionSerializer.serialize(state);
1226
+ const deserialized = TransactionSerializer.deserialize(serialized);
1227
+
1228
+ expect(deserialized.utxos).toHaveLength(0);
1229
+ });
1230
+
1231
+ it('should handle very large values', () => {
1232
+ const state = createMockSerializedState();
1233
+ state.utxos = [
1234
+ {
1235
+ transactionId: '0'.repeat(64),
1236
+ outputIndex: 0,
1237
+ value: '9999999999999999', // Large value
1238
+ scriptPubKeyHex: 'aa',
1239
+ },
1240
+ ];
1241
+
1242
+ const deserialized = TransactionSerializer.deserialize(
1243
+ TransactionSerializer.serialize(state),
1244
+ );
1245
+
1246
+ expect(deserialized.utxos[0].value).toBe('9999999999999999');
1247
+ });
1248
+
1249
+ it('should handle empty strings', () => {
1250
+ const state = createMockSerializedState();
1251
+ state.baseParams.from = '';
1252
+ state.baseParams.to = '';
1253
+
1254
+ const deserialized = TransactionSerializer.deserialize(
1255
+ TransactionSerializer.serialize(state),
1256
+ );
1257
+
1258
+ expect(deserialized.baseParams.from).toBe('');
1259
+ });
1260
+
1261
+ it('should handle special characters in note', () => {
1262
+ const state = createMockSerializedState();
1263
+ const specialNote = Buffer.from('Hello\x00World\n\t\r').toString('hex');
1264
+ state.baseParams.note = specialNote;
1265
+
1266
+ const deserialized = TransactionSerializer.deserialize(
1267
+ TransactionSerializer.serialize(state),
1268
+ );
1269
+
1270
+ expect(deserialized.baseParams.note).toBe(specialNote);
1271
+ });
1272
+
1273
+ it('should handle zero fee rate', () => {
1274
+ const state = createMockSerializedState();
1275
+ state.baseParams.feeRate = 0;
1276
+
1277
+ const deserialized = TransactionSerializer.deserialize(
1278
+ TransactionSerializer.serialize(state),
1279
+ );
1280
+
1281
+ expect(deserialized.baseParams.feeRate).toBe(0);
1282
+ });
1283
+
1284
+ it('should handle maximum input indices in signer mappings', () => {
1285
+ const state = createMockSerializedState();
1286
+ state.addressRotationEnabled = true;
1287
+ state.signerMappings = [
1288
+ { address: address1, inputIndices: Array.from({ length: 100 }, (_, i) => i) },
1289
+ ];
1290
+
1291
+ const deserialized = TransactionSerializer.deserialize(
1292
+ TransactionSerializer.serialize(state),
1293
+ );
1294
+
1295
+ expect(deserialized.signerMappings[0].inputIndices).toHaveLength(100);
1296
+ });
1297
+
1298
+ it('should handle loaded storage with many keys', () => {
1299
+ const state = createMockSerializedState(TransactionType.INTERACTION);
1300
+ state.header.transactionType = TransactionType.INTERACTION;
1301
+
1302
+ const loadedStorage: { [key: string]: string[] } = {};
1303
+ for (let i = 0; i < 50; i++) {
1304
+ loadedStorage[`key${i}`] = [`value${i}a`, `value${i}b`];
1305
+ }
1306
+
1307
+ state.typeSpecificData = {
1308
+ type: TransactionType.INTERACTION,
1309
+ calldata: 'abc',
1310
+ challenge: createMockChallenge(),
1311
+ loadedStorage,
1312
+ } as InteractionSpecificData;
1313
+
1314
+ const deserialized = TransactionSerializer.deserialize(
1315
+ TransactionSerializer.serialize(state),
1316
+ );
1317
+
1318
+ const data = deserialized.typeSpecificData as InteractionSpecificData;
1319
+ expect(Object.keys(data.loadedStorage || {}).length).toBe(50);
1320
+ });
1321
+ });
1322
+
1323
+ describe('Round-trip Tests', () => {
1324
+ it('should preserve all data through serialize/deserialize cycle', () => {
1325
+ const originalState: ISerializableTransactionState = {
1326
+ header: {
1327
+ formatVersion: SERIALIZATION_FORMAT_VERSION,
1328
+ consensusVersion: currentConsensus,
1329
+ transactionType: TransactionType.FUNDING,
1330
+ chainId: ChainId.Bitcoin,
1331
+ timestamp: 1700000000000,
1332
+ },
1333
+ baseParams: {
1334
+ from: address1,
1335
+ to: address2,
1336
+ feeRate: 12.5,
1337
+ priorityFee: '1500',
1338
+ gasSatFee: '750',
1339
+ networkName: 'regtest',
1340
+ txVersion: 2,
1341
+ note: 'deadbeef',
1342
+ anchor: true,
1343
+ debugFees: true,
1344
+ },
1345
+ utxos: [
1346
+ {
1347
+ transactionId: 'a'.repeat(64),
1348
+ outputIndex: 3,
1349
+ value: '123456',
1350
+ scriptPubKeyHex: 'aa',
1351
+ scriptPubKeyAddress: address1,
1352
+ redeemScript: 'bb',
1353
+ witnessScript: 'cc',
1354
+ nonWitnessUtxo: 'dd',
1355
+ },
1356
+ ],
1357
+ optionalInputs: [
1358
+ {
1359
+ transactionId: 'b'.repeat(64),
1360
+ outputIndex: 1,
1361
+ value: '789',
1362
+ scriptPubKeyHex: 'ee',
1363
+ },
1364
+ ],
1365
+ optionalOutputs: [
1366
+ {
1367
+ value: 5000,
1368
+ address: address3,
1369
+ tapInternalKey: 'ff'.repeat(16),
1370
+ },
1371
+ ],
1372
+ addressRotationEnabled: true,
1373
+ signerMappings: [
1374
+ { address: address1, inputIndices: [0, 1] },
1375
+ { address: address2, inputIndices: [2] },
1376
+ ],
1377
+ typeSpecificData: {
1378
+ type: TransactionType.FUNDING,
1379
+ amount: '99999',
1380
+ splitInputsInto: 3,
1381
+ },
1382
+ precomputedData: {
1383
+ compiledTargetScript: '1234',
1384
+ randomBytes: '5678',
1385
+ estimatedFees: '1000',
1386
+ contractSeed: 'seed',
1387
+ contractAddress: address3,
1388
+ },
1389
+ };
1390
+
1391
+ const serialized = TransactionSerializer.serialize(originalState);
1392
+ const deserialized = TransactionSerializer.deserialize(serialized);
1393
+
1394
+ // Verify header
1395
+ expect(deserialized.header.formatVersion).toBe(originalState.header.formatVersion);
1396
+ expect(deserialized.header.consensusVersion).toBe(originalState.header.consensusVersion);
1397
+ expect(deserialized.header.transactionType).toBe(originalState.header.transactionType);
1398
+ expect(deserialized.header.chainId).toBe(originalState.header.chainId);
1399
+ expect(deserialized.header.timestamp).toBe(originalState.header.timestamp);
1400
+
1401
+ // Verify base params
1402
+ expect(deserialized.baseParams.from).toBe(originalState.baseParams.from);
1403
+ expect(deserialized.baseParams.to).toBe(originalState.baseParams.to);
1404
+ expect(deserialized.baseParams.feeRate).toBeCloseTo(originalState.baseParams.feeRate, 3);
1405
+ expect(deserialized.baseParams.priorityFee).toBe(originalState.baseParams.priorityFee);
1406
+ expect(deserialized.baseParams.gasSatFee).toBe(originalState.baseParams.gasSatFee);
1407
+ expect(deserialized.baseParams.networkName).toBe(originalState.baseParams.networkName);
1408
+ expect(deserialized.baseParams.txVersion).toBe(originalState.baseParams.txVersion);
1409
+ expect(deserialized.baseParams.note).toBe(originalState.baseParams.note);
1410
+ expect(deserialized.baseParams.anchor).toBe(originalState.baseParams.anchor);
1411
+ expect(deserialized.baseParams.debugFees).toBe(originalState.baseParams.debugFees);
1412
+
1413
+ // Verify UTXOs
1414
+ expect(deserialized.utxos).toEqual(originalState.utxos);
1415
+
1416
+ // Verify optional inputs/outputs
1417
+ expect(deserialized.optionalInputs).toEqual(originalState.optionalInputs);
1418
+ expect(deserialized.optionalOutputs).toEqual(originalState.optionalOutputs);
1419
+
1420
+ // Verify address rotation
1421
+ expect(deserialized.addressRotationEnabled).toBe(originalState.addressRotationEnabled);
1422
+ expect(deserialized.signerMappings).toEqual(originalState.signerMappings);
1423
+
1424
+ // Verify type-specific data
1425
+ expect(deserialized.typeSpecificData).toEqual(originalState.typeSpecificData);
1426
+
1427
+ // Verify precomputed data
1428
+ expect(deserialized.precomputedData).toEqual(originalState.precomputedData);
1429
+ });
1430
+
1431
+ it('should preserve data through export/import/reconstruct cycle', () => {
1432
+ const params = {
1433
+ signer: defaultSigner,
1434
+ mldsaSigner: null,
1435
+ network,
1436
+ utxos: [
1437
+ createTaprootUtxo(address1, 50000n, '1'.repeat(64), 0),
1438
+ createTaprootUtxo(address2, 30000n, '2'.repeat(64), 1),
1439
+ ],
1440
+ from: address1,
1441
+ to: address3,
1442
+ feeRate: 20,
1443
+ priorityFee: 2000n,
1444
+ gasSatFee: 1000n,
1445
+ amount: 60000n,
1446
+ splitInputsInto: 2,
1447
+ };
1448
+
1449
+ // Export
1450
+ const exported = OfflineTransactionManager.exportFunding(params);
1451
+
1452
+ // Inspect
1453
+ const inspected = OfflineTransactionManager.inspect(exported);
1454
+ expect(inspected.baseParams.feeRate).toBeCloseTo(20, 3);
1455
+ expect(inspected.utxos).toHaveLength(2);
1456
+
1457
+ // Import with new signer
1458
+ const builder = OfflineTransactionManager.importForSigning(exported, {
1459
+ signer: signer1,
1460
+ });
1461
+
1462
+ expect(builder).toBeDefined();
1463
+ expect(builder.type).toBe(TransactionType.FUNDING);
1464
+ });
1465
+ });
1466
+
1467
+ describe('Actual Transaction Signing', () => {
1468
+ it('should sign a funding transaction through full offline workflow', async () => {
1469
+ // Phase 1: Online - Create and export transaction
1470
+ const params = {
1471
+ signer: defaultSigner,
1472
+ mldsaSigner: null,
1473
+ network,
1474
+ utxos: [createTaprootUtxo(defaultAddress, 100000n, 'a'.repeat(64), 0)],
1475
+ from: defaultAddress,
1476
+ to: address2,
1477
+ feeRate: 10,
1478
+ priorityFee: 1000n,
1479
+ gasSatFee: 500n,
1480
+ amount: 50000n,
1481
+ };
1482
+
1483
+ const exportedState = OfflineTransactionManager.exportFunding(params);
1484
+
1485
+ // Verify export is valid
1486
+ expect(OfflineTransactionManager.validate(exportedState)).toBe(true);
1487
+
1488
+ // Phase 2: Offline - Import, sign, export
1489
+ const signedTxHex = await OfflineTransactionManager.importSignAndExport(
1490
+ exportedState,
1491
+ { signer: defaultSigner },
1492
+ );
1493
+
1494
+ // Verify we got a valid hex transaction
1495
+ expect(signedTxHex).toBeDefined();
1496
+ expect(typeof signedTxHex).toBe('string');
1497
+ expect(signedTxHex.length).toBeGreaterThan(0);
1498
+ expect(/^[0-9a-f]+$/i.test(signedTxHex)).toBe(true);
1499
+ });
1500
+
1501
+ it('should sign using two-step import then sign process', async () => {
1502
+ const params = {
1503
+ signer: signer1,
1504
+ mldsaSigner: null,
1505
+ network,
1506
+ utxos: [createTaprootUtxo(address1, 80000n, 'b'.repeat(64), 0)],
1507
+ from: address1,
1508
+ to: address2,
1509
+ feeRate: 15,
1510
+ priorityFee: 500n,
1511
+ gasSatFee: 300n,
1512
+ amount: 40000n,
1513
+ };
1514
+
1515
+ const exportedState = OfflineTransactionManager.exportFunding(params);
1516
+
1517
+ // Step 1: Import for signing
1518
+ const builder = OfflineTransactionManager.importForSigning(exportedState, {
1519
+ signer: signer1,
1520
+ });
1521
+
1522
+ expect(builder).toBeDefined();
1523
+ expect(builder.type).toBe(TransactionType.FUNDING);
1524
+
1525
+ // Step 2: Sign and export
1526
+ const signedTxHex = await OfflineTransactionManager.signAndExport(builder);
1527
+
1528
+ expect(signedTxHex).toBeDefined();
1529
+ expect(/^[0-9a-f]+$/i.test(signedTxHex)).toBe(true);
1530
+ });
1531
+
1532
+ it('should sign with fee bumping', async () => {
1533
+ const params = {
1534
+ signer: signer2,
1535
+ mldsaSigner: null,
1536
+ network,
1537
+ utxos: [createTaprootUtxo(address2, 120000n, 'c'.repeat(64), 0)],
1538
+ from: address2,
1539
+ to: address3,
1540
+ feeRate: 5,
1541
+ priorityFee: 200n,
1542
+ gasSatFee: 100n,
1543
+ amount: 60000n,
1544
+ };
1545
+
1546
+ const originalState = OfflineTransactionManager.exportFunding(params);
1547
+
1548
+ // Verify original fee rate
1549
+ const originalInspected = OfflineTransactionManager.inspect(originalState);
1550
+ expect(originalInspected.baseParams.feeRate).toBeCloseTo(5, 3);
1551
+
1552
+ // Bump fee to 25 sat/vB
1553
+ const bumpedState = OfflineTransactionManager.rebuildWithNewFees(
1554
+ originalState,
1555
+ 25,
1556
+ );
1557
+
1558
+ // Verify bumped fee rate
1559
+ const bumpedInspected = OfflineTransactionManager.inspect(bumpedState);
1560
+ expect(bumpedInspected.baseParams.feeRate).toBeCloseTo(25, 3);
1561
+
1562
+ // Sign the bumped transaction
1563
+ const signedTxHex = await OfflineTransactionManager.importSignAndExport(
1564
+ bumpedState,
1565
+ { signer: signer2 },
1566
+ );
1567
+
1568
+ expect(signedTxHex).toBeDefined();
1569
+ expect(/^[0-9a-f]+$/i.test(signedTxHex)).toBe(true);
1570
+ });
1571
+
1572
+ it('should sign with rebuildSignAndExport convenience method', async () => {
1573
+ const params = {
1574
+ signer: signer3,
1575
+ mldsaSigner: null,
1576
+ network,
1577
+ utxos: [createTaprootUtxo(address3, 90000n, 'd'.repeat(64), 0)],
1578
+ from: address3,
1579
+ to: address1,
1580
+ feeRate: 8,
1581
+ priorityFee: 300n,
1582
+ gasSatFee: 150n,
1583
+ amount: 45000n,
1584
+ };
1585
+
1586
+ const originalState = OfflineTransactionManager.exportFunding(params);
1587
+
1588
+ // Bump and sign in one call
1589
+ const signedTxHex = await OfflineTransactionManager.rebuildSignAndExport(
1590
+ originalState,
1591
+ 40, // New fee rate
1592
+ { signer: signer3 },
1593
+ );
1594
+
1595
+ expect(signedTxHex).toBeDefined();
1596
+ expect(/^[0-9a-f]+$/i.test(signedTxHex)).toBe(true);
1597
+ });
1598
+
1599
+ it('should sign with multiple UTXOs', async () => {
1600
+ const params = {
1601
+ signer: defaultSigner,
1602
+ mldsaSigner: null,
1603
+ network,
1604
+ utxos: [
1605
+ createTaprootUtxo(defaultAddress, 30000n, 'e'.repeat(64), 0),
1606
+ createTaprootUtxo(defaultAddress, 40000n, 'f'.repeat(64), 1),
1607
+ createTaprootUtxo(defaultAddress, 50000n, '1'.repeat(64), 2),
1608
+ ],
1609
+ from: defaultAddress,
1610
+ to: address2,
1611
+ feeRate: 12,
1612
+ priorityFee: 600n,
1613
+ gasSatFee: 400n,
1614
+ amount: 100000n,
1615
+ };
1616
+
1617
+ const exportedState = OfflineTransactionManager.exportFunding(params);
1618
+
1619
+ // Verify all UTXOs are captured
1620
+ const inspected = OfflineTransactionManager.inspect(exportedState);
1621
+ expect(inspected.utxos).toHaveLength(3);
1622
+
1623
+ // Sign
1624
+ const signedTxHex = await OfflineTransactionManager.importSignAndExport(
1625
+ exportedState,
1626
+ { signer: defaultSigner },
1627
+ );
1628
+
1629
+ expect(signedTxHex).toBeDefined();
1630
+ expect(/^[0-9a-f]+$/i.test(signedTxHex)).toBe(true);
1631
+ });
1632
+
1633
+ it('should sign with address rotation using multiple signers', async () => {
1634
+ // For address rotation, UTXOs must use addresses that match the signers
1635
+ // Use all UTXOs from defaultAddress with defaultSigner for simplicity
1636
+ const signerMap = createSignerMap([
1637
+ [defaultAddress, defaultSigner],
1638
+ ]);
1639
+
1640
+ const params = {
1641
+ signer: defaultSigner,
1642
+ mldsaSigner: null,
1643
+ network,
1644
+ utxos: [
1645
+ createTaprootUtxo(defaultAddress, 50000n, '2'.repeat(64), 0),
1646
+ createTaprootUtxo(defaultAddress, 60000n, '3'.repeat(64), 1),
1647
+ ],
1648
+ from: defaultAddress,
1649
+ to: address3,
1650
+ feeRate: 10,
1651
+ priorityFee: 500n,
1652
+ gasSatFee: 250n,
1653
+ amount: 80000n,
1654
+ addressRotation: createAddressRotation(signerMap),
1655
+ };
1656
+
1657
+ const exportedState = OfflineTransactionManager.exportFunding(params);
1658
+
1659
+ // Verify address rotation is captured
1660
+ const inspected = OfflineTransactionManager.inspect(exportedState);
1661
+ expect(inspected.addressRotationEnabled).toBe(true);
1662
+ expect(inspected.signerMappings.length).toBeGreaterThan(0);
1663
+
1664
+ // Sign with address rotation
1665
+ const signedTxHex = await OfflineTransactionManager.importSignAndExport(
1666
+ exportedState,
1667
+ {
1668
+ signer: defaultSigner,
1669
+ signerMap,
1670
+ },
1671
+ );
1672
+
1673
+ expect(signedTxHex).toBeDefined();
1674
+ expect(/^[0-9a-f]+$/i.test(signedTxHex)).toBe(true);
1675
+ });
1676
+
1677
+ it('should produce different signatures with different signers', async () => {
1678
+ // Export state that can be signed by either signer
1679
+ const params = {
1680
+ signer: signer1,
1681
+ mldsaSigner: null,
1682
+ network,
1683
+ utxos: [createTaprootUtxo(address1, 100000n, '4'.repeat(64), 0)],
1684
+ from: address1,
1685
+ to: address2,
1686
+ feeRate: 10,
1687
+ priorityFee: 1000n,
1688
+ gasSatFee: 500n,
1689
+ amount: 50000n,
1690
+ };
1691
+
1692
+ const exportedState = OfflineTransactionManager.exportFunding(params);
1693
+
1694
+ // Sign with signer1
1695
+ const signedTx1 = await OfflineTransactionManager.importSignAndExport(
1696
+ exportedState,
1697
+ { signer: signer1 },
1698
+ );
1699
+
1700
+ // Sign again with signer1 (should produce same structure, potentially different due to nonce)
1701
+ const signedTx1Again = await OfflineTransactionManager.importSignAndExport(
1702
+ exportedState,
1703
+ { signer: signer1 },
1704
+ );
1705
+
1706
+ // Both signatures should be valid hex
1707
+ expect(/^[0-9a-f]+$/i.test(signedTx1)).toBe(true);
1708
+ expect(/^[0-9a-f]+$/i.test(signedTx1Again)).toBe(true);
1709
+
1710
+ // Transaction structure should be similar in length
1711
+ // (exact match not guaranteed due to Schnorr signature randomness)
1712
+ expect(Math.abs(signedTx1.length - signedTx1Again.length)).toBeLessThan(10);
1713
+ });
1714
+
1715
+ it('should handle split funding transaction', async () => {
1716
+ const params = {
1717
+ signer: defaultSigner,
1718
+ mldsaSigner: null,
1719
+ network,
1720
+ utxos: [createTaprootUtxo(defaultAddress, 200000n, '5'.repeat(64), 0)],
1721
+ from: defaultAddress,
1722
+ to: address2,
1723
+ feeRate: 10,
1724
+ priorityFee: 1000n,
1725
+ gasSatFee: 500n,
1726
+ amount: 150000n,
1727
+ splitInputsInto: 3, // Split into 3 outputs
1728
+ };
1729
+
1730
+ const exportedState = OfflineTransactionManager.exportFunding(params);
1731
+
1732
+ // Verify split is captured
1733
+ const inspected = OfflineTransactionManager.inspect(exportedState);
1734
+ expect(isFundingSpecificData(inspected.typeSpecificData)).toBe(true);
1735
+ const fundingData = inspected.typeSpecificData as FundingSpecificData;
1736
+ expect(fundingData.splitInputsInto).toBe(3);
1737
+
1738
+ // Sign
1739
+ const signedTxHex = await OfflineTransactionManager.importSignAndExport(
1740
+ exportedState,
1741
+ { signer: defaultSigner },
1742
+ );
1743
+
1744
+ expect(signedTxHex).toBeDefined();
1745
+ expect(/^[0-9a-f]+$/i.test(signedTxHex)).toBe(true);
1746
+ });
1747
+
1748
+ it('should sign after format conversion (base64 -> hex -> base64)', async () => {
1749
+ const params = {
1750
+ signer: signer1,
1751
+ mldsaSigner: null,
1752
+ network,
1753
+ utxos: [createTaprootUtxo(address1, 75000n, '6'.repeat(64), 0)],
1754
+ from: address1,
1755
+ to: address3,
1756
+ feeRate: 20,
1757
+ priorityFee: 800n,
1758
+ gasSatFee: 400n,
1759
+ amount: 35000n,
1760
+ };
1761
+
1762
+ // Export as base64
1763
+ const base64State = OfflineTransactionManager.exportFunding(params);
1764
+
1765
+ // Convert to hex
1766
+ const hexState = OfflineTransactionManager.toHex(base64State);
1767
+ expect(/^[0-9a-f]+$/i.test(hexState)).toBe(true);
1768
+
1769
+ // Convert back to base64
1770
+ const backToBase64 = OfflineTransactionManager.fromHex(hexState);
1771
+
1772
+ // Both should validate
1773
+ expect(OfflineTransactionManager.validate(base64State)).toBe(true);
1774
+ expect(OfflineTransactionManager.validate(backToBase64)).toBe(true);
1775
+
1776
+ // Sign from the converted state
1777
+ const signedTxHex = await OfflineTransactionManager.importSignAndExport(
1778
+ backToBase64,
1779
+ { signer: signer1 },
1780
+ );
1781
+
1782
+ expect(signedTxHex).toBeDefined();
1783
+ expect(/^[0-9a-f]+$/i.test(signedTxHex)).toBe(true);
1784
+ });
1785
+ });
1786
+
1787
+ describe('MultiSig Offline Signing', () => {
1788
+ it('should export and validate multisig state', () => {
1789
+ const pubkeys = [
1790
+ signer1.publicKey,
1791
+ signer2.publicKey,
1792
+ signer3.publicKey,
1793
+ ];
1794
+
1795
+ const params = {
1796
+ network,
1797
+ mldsaSigner: null,
1798
+ utxos: [createTaprootUtxo(defaultAddress, 100000n, 'a'.repeat(64), 0)],
1799
+ feeRate: 10,
1800
+ pubkeys,
1801
+ minimumSignatures: 2,
1802
+ receiver: address1,
1803
+ requestedAmount: 50000n,
1804
+ refundVault: address2,
1805
+ };
1806
+
1807
+ const state = OfflineTransactionManager.exportMultiSig(params);
1808
+
1809
+ expect(OfflineTransactionManager.validate(state)).toBe(true);
1810
+ expect(OfflineTransactionManager.getType(state)).toBe(TransactionType.MULTI_SIG);
1811
+
1812
+ const inspected = OfflineTransactionManager.inspect(state);
1813
+ expect(inspected.typeSpecificData.type).toBe(TransactionType.MULTI_SIG);
1814
+ });
1815
+
1816
+ it('should serialize and deserialize multisig specific data', () => {
1817
+ const pubkeys = [
1818
+ signer1.publicKey,
1819
+ signer2.publicKey,
1820
+ ];
1821
+
1822
+ const params = {
1823
+ network,
1824
+ mldsaSigner: null,
1825
+ utxos: [createTaprootUtxo(defaultAddress, 80000n, 'b'.repeat(64), 0)],
1826
+ feeRate: 15,
1827
+ pubkeys,
1828
+ minimumSignatures: 2,
1829
+ receiver: address1,
1830
+ requestedAmount: 40000n,
1831
+ refundVault: address2,
1832
+ };
1833
+
1834
+ const state = OfflineTransactionManager.exportMultiSig(params);
1835
+ const inspected = OfflineTransactionManager.inspect(state);
1836
+
1837
+ expect(isMultiSigSpecificData(inspected.typeSpecificData)).toBe(true);
1838
+
1839
+ if (isMultiSigSpecificData(inspected.typeSpecificData)) {
1840
+ expect(inspected.typeSpecificData.pubkeys).toHaveLength(2);
1841
+ expect(inspected.typeSpecificData.minimumSignatures).toBe(2);
1842
+ expect(inspected.typeSpecificData.receiver).toBe(address1);
1843
+ expect(inspected.typeSpecificData.requestedAmount).toBe('40000');
1844
+ expect(inspected.typeSpecificData.refundVault).toBe(address2);
1845
+ }
1846
+ });
1847
+
1848
+ it('should report no signatures initially', () => {
1849
+ const pubkeys = [
1850
+ signer1.publicKey,
1851
+ signer2.publicKey,
1852
+ ];
1853
+
1854
+ const params = {
1855
+ network,
1856
+ mldsaSigner: null,
1857
+ utxos: [createTaprootUtxo(defaultAddress, 100000n, 'c'.repeat(64), 0)],
1858
+ feeRate: 10,
1859
+ pubkeys,
1860
+ minimumSignatures: 2,
1861
+ receiver: address1,
1862
+ requestedAmount: 50000n,
1863
+ refundVault: address2,
1864
+ };
1865
+
1866
+ const state = OfflineTransactionManager.exportMultiSig(params);
1867
+ const status = OfflineTransactionManager.multiSigGetSignatureStatus(state);
1868
+
1869
+ expect(status.required).toBe(2);
1870
+ expect(status.collected).toBe(0);
1871
+ expect(status.isComplete).toBe(false);
1872
+ expect(status.signers).toHaveLength(0);
1873
+ });
1874
+
1875
+ it('should return null for PSBT before signing', () => {
1876
+ const pubkeys = [
1877
+ signer1.publicKey,
1878
+ signer2.publicKey,
1879
+ ];
1880
+
1881
+ const params = {
1882
+ network,
1883
+ mldsaSigner: null,
1884
+ utxos: [createTaprootUtxo(defaultAddress, 100000n, 'd'.repeat(64), 0)],
1885
+ feeRate: 10,
1886
+ pubkeys,
1887
+ minimumSignatures: 2,
1888
+ receiver: address1,
1889
+ requestedAmount: 50000n,
1890
+ refundVault: address2,
1891
+ };
1892
+
1893
+ const state = OfflineTransactionManager.exportMultiSig(params);
1894
+ const psbt = OfflineTransactionManager.multiSigGetPsbt(state);
1895
+
1896
+ expect(psbt).toBeNull();
1897
+ });
1898
+
1899
+ it('should report signer has not signed before signing', () => {
1900
+ const pubkeys = [
1901
+ signer1.publicKey,
1902
+ signer2.publicKey,
1903
+ ];
1904
+
1905
+ const params = {
1906
+ network,
1907
+ mldsaSigner: null,
1908
+ utxos: [createTaprootUtxo(defaultAddress, 100000n, 'e'.repeat(64), 0)],
1909
+ feeRate: 10,
1910
+ pubkeys,
1911
+ minimumSignatures: 2,
1912
+ receiver: address1,
1913
+ requestedAmount: 50000n,
1914
+ refundVault: address2,
1915
+ };
1916
+
1917
+ const state = OfflineTransactionManager.exportMultiSig(params);
1918
+
1919
+ expect(OfflineTransactionManager.multiSigHasSigned(state, signer1.publicKey)).toBe(false);
1920
+ expect(OfflineTransactionManager.multiSigHasSigned(state, signer2.publicKey)).toBe(false);
1921
+ });
1922
+
1923
+ it('should throw error for non-multisig state in multisig methods', () => {
1924
+ const fundingParams = {
1925
+ signer: defaultSigner,
1926
+ mldsaSigner: null,
1927
+ network,
1928
+ utxos: [createTaprootUtxo(defaultAddress, 100000n, 'f'.repeat(64), 0)],
1929
+ from: defaultAddress,
1930
+ to: address1,
1931
+ feeRate: 10,
1932
+ priorityFee: 1000n,
1933
+ gasSatFee: 500n,
1934
+ amount: 50000n,
1935
+ };
1936
+
1937
+ const fundingState = OfflineTransactionManager.exportFunding(fundingParams);
1938
+
1939
+ expect(() => OfflineTransactionManager.multiSigGetSignatureStatus(fundingState))
1940
+ .toThrow('State is not a multisig transaction');
1941
+
1942
+ expect(() => OfflineTransactionManager.multiSigHasSigned(fundingState, signer1.publicKey))
1943
+ .toThrow('State is not a multisig transaction');
1944
+
1945
+ expect(() => OfflineTransactionManager.multiSigGetPsbt(fundingState))
1946
+ .toThrow('State is not a multisig transaction');
1947
+
1948
+ expect(() => OfflineTransactionManager.multiSigFinalize(fundingState))
1949
+ .toThrow('State is not a multisig transaction');
1950
+ });
1951
+
1952
+ it('should throw error when finalizing without signatures', () => {
1953
+ const pubkeys = [
1954
+ signer1.publicKey,
1955
+ signer2.publicKey,
1956
+ ];
1957
+
1958
+ const params = {
1959
+ network,
1960
+ mldsaSigner: null,
1961
+ utxos: [createTaprootUtxo(defaultAddress, 100000n, '1'.repeat(64), 0)],
1962
+ feeRate: 10,
1963
+ pubkeys,
1964
+ minimumSignatures: 2,
1965
+ receiver: address1,
1966
+ requestedAmount: 50000n,
1967
+ refundVault: address2,
1968
+ };
1969
+
1970
+ const state = OfflineTransactionManager.exportMultiSig(params);
1971
+
1972
+ expect(() => OfflineTransactionManager.multiSigFinalize(state))
1973
+ .toThrow('No PSBT found in state');
1974
+ });
1975
+
1976
+ it('should update PSBT in state', () => {
1977
+ const pubkeys = [
1978
+ signer1.publicKey,
1979
+ signer2.publicKey,
1980
+ ];
1981
+
1982
+ const params = {
1983
+ network,
1984
+ mldsaSigner: null,
1985
+ utxos: [createTaprootUtxo(defaultAddress, 100000n, '2'.repeat(64), 0)],
1986
+ feeRate: 10,
1987
+ pubkeys,
1988
+ minimumSignatures: 2,
1989
+ receiver: address1,
1990
+ requestedAmount: 50000n,
1991
+ refundVault: address2,
1992
+ };
1993
+
1994
+ const state = OfflineTransactionManager.exportMultiSig(params);
1995
+
1996
+ // Update with a mock PSBT
1997
+ const mockPsbtBase64 = 'cHNidP8BAH0CAAAAAb=='; // Minimal valid base64
1998
+ const updatedState = OfflineTransactionManager.multiSigUpdatePsbt(state, mockPsbtBase64);
1999
+
2000
+ const inspected = OfflineTransactionManager.inspect(updatedState);
2001
+ if (isMultiSigSpecificData(inspected.typeSpecificData)) {
2002
+ expect(inspected.typeSpecificData.existingPsbtBase64).toBe(mockPsbtBase64);
2003
+ }
2004
+ });
2005
+
2006
+ it('should preserve multisig data through serialization round-trip', () => {
2007
+ const pubkeys = [
2008
+ signer1.publicKey,
2009
+ signer2.publicKey,
2010
+ signer3.publicKey,
2011
+ ];
2012
+
2013
+ const params = {
2014
+ network,
2015
+ mldsaSigner: null,
2016
+ utxos: [
2017
+ createTaprootUtxo(defaultAddress, 50000n, '3'.repeat(64), 0),
2018
+ createTaprootUtxo(defaultAddress, 60000n, '4'.repeat(64), 1),
2019
+ ],
2020
+ feeRate: 20,
2021
+ pubkeys,
2022
+ minimumSignatures: 2,
2023
+ receiver: address1,
2024
+ requestedAmount: 80000n,
2025
+ refundVault: address2,
2026
+ };
2027
+
2028
+ // Export
2029
+ const state = OfflineTransactionManager.exportMultiSig(params);
2030
+
2031
+ // Convert to hex and back
2032
+ const hexState = OfflineTransactionManager.toHex(state);
2033
+ const backToBase64 = OfflineTransactionManager.fromHex(hexState);
2034
+
2035
+ // Verify data preserved
2036
+ const original = OfflineTransactionManager.inspect(state);
2037
+ const restored = OfflineTransactionManager.inspect(backToBase64);
2038
+
2039
+ expect(restored.typeSpecificData).toEqual(original.typeSpecificData);
2040
+ expect(restored.utxos).toHaveLength(2);
2041
+ });
2042
+ });
2043
+ });
2044
+
2045
+ // Helper function to create mock challenge data
2046
+ function createMockChallenge() {
2047
+ return {
2048
+ epochNumber: '100',
2049
+ mldsaPublicKey: '0x' + 'aa'.repeat(32),
2050
+ legacyPublicKey: '0x' + 'bb'.repeat(33),
2051
+ solution: '0x' + 'cc'.repeat(32),
2052
+ salt: '0x' + 'dd'.repeat(32),
2053
+ graffiti: '0x' + 'ee'.repeat(16),
2054
+ difficulty: 20,
2055
+ verification: {
2056
+ epochHash: '0x' + '11'.repeat(32),
2057
+ epochRoot: '0x' + '22'.repeat(32),
2058
+ targetHash: '0x' + '33'.repeat(32),
2059
+ targetChecksum: '0x' + '44'.repeat(32),
2060
+ startBlock: '1000',
2061
+ endBlock: '2000',
2062
+ proofs: ['0x' + '55'.repeat(32), '0x' + '66'.repeat(32)],
2063
+ },
2064
+ };
2065
+ }