UNPKG

@btc-vision/transaction

Version:

OPNet transaction library allows you to create and sign transactions for the OPNet network.

1,208 lines (1,031 loc) 89.8 kB
import { beforeAll, describe, expect, it } from 'vitest'; import { createHash } from 'crypto'; import { networks, payments, toHex } from '@btc-vision/bitcoin'; import { type UniversalSigner } from '@btc-vision/ecpair'; import type { CancelSpecificData, CustomScriptSpecificData, DeploymentSpecificData, FundingSpecificData, InteractionSpecificData, ISerializableTransactionState, MultiSigSpecificData, PrecomputedData, ReconstructionOptions, SerializedBaseParams, SerializedOutput, SerializedUTXO, UTXO, } from '../build/opnet.js'; import { ChainId, createAddressRotation, createSignerMap, currentConsensus, EcKeyPair, FundingTransaction, isCancelSpecificData, isCustomScriptSpecificData, isDeploymentSpecificData, isFundingSpecificData, isInteractionSpecificData, isMultiSigSpecificData, OfflineTransactionManager, SERIALIZATION_FORMAT_VERSION, TransactionReconstructor, TransactionSerializer, TransactionStateCapture, TransactionType, } from '../build/opnet.js'; describe('Offline Transaction Signing', () => { const network = networks.regtest; // Test keypairs let signer1: UniversalSigner; let signer2: UniversalSigner; let signer3: UniversalSigner; let defaultSigner: UniversalSigner; let address1: string; let address2: string; let address3: string; let defaultAddress: string; beforeAll(() => { signer1 = EcKeyPair.generateRandomKeyPair(network); signer2 = EcKeyPair.generateRandomKeyPair(network); signer3 = EcKeyPair.generateRandomKeyPair(network); defaultSigner = EcKeyPair.generateRandomKeyPair(network); address1 = EcKeyPair.getTaprootAddress(signer1, network); address2 = EcKeyPair.getTaprootAddress(signer2, network); address3 = EcKeyPair.getTaprootAddress(signer3, network); defaultAddress = EcKeyPair.getTaprootAddress(defaultSigner, network); }); // Helper to create a taproot UTXO const createTaprootUtxo = ( address: string, value: bigint, txId: string = '0'.repeat(64), index: number = 0, ): UTXO => { const p2tr = payments.p2tr({ address, network }); return { transactionId: txId, outputIndex: index, value, scriptPubKey: { hex: toHex(p2tr.output as Uint8Array), address, }, }; }; // Helper to create mock serialized state const createMockSerializedState = ( type: TransactionType = TransactionType.FUNDING, overrides: Partial<ISerializableTransactionState> = {}, ): ISerializableTransactionState => { const baseState: ISerializableTransactionState = { header: { formatVersion: SERIALIZATION_FORMAT_VERSION, consensusVersion: currentConsensus, transactionType: type, chainId: ChainId.Bitcoin, timestamp: Date.now(), }, baseParams: { from: address1, to: address2, feeRate: 10, priorityFee: '1000', gasSatFee: '500', networkName: 'regtest', txVersion: 2, anchor: false, }, utxos: [ { transactionId: '0'.repeat(64), outputIndex: 0, value: '100000', scriptPubKeyHex: toHex( payments.p2tr({ address: address1, network }).output as Uint8Array, ), scriptPubKeyAddress: address1, }, ], optionalInputs: [], optionalOutputs: [], addressRotationEnabled: false, signerMappings: [], typeSpecificData: { type: TransactionType.FUNDING, amount: '50000', splitInputsInto: 1, } as FundingSpecificData, precomputedData: {}, }; return { ...baseState, ...overrides }; }; describe('TransactionSerializer', () => { describe('serialize/deserialize', () => { it('should serialize and deserialize a basic funding transaction state', () => { const state = createMockSerializedState(TransactionType.FUNDING); const serialized = TransactionSerializer.serialize(state); expect(serialized).toBeInstanceOf(Uint8Array); expect(serialized.length).toBeGreaterThan(32); // At least checksum size const deserialized = TransactionSerializer.deserialize(serialized); expect(deserialized.header.transactionType).toBe(TransactionType.FUNDING); expect(deserialized.baseParams.from).toBe(state.baseParams.from); expect(deserialized.baseParams.feeRate).toBe(state.baseParams.feeRate); }); it('should preserve all header fields', () => { const state = createMockSerializedState(TransactionType.FUNDING, { header: { formatVersion: SERIALIZATION_FORMAT_VERSION, consensusVersion: currentConsensus, transactionType: TransactionType.FUNDING, chainId: ChainId.Bitcoin, timestamp: 1234567890123, }, }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(deserialized.header.formatVersion).toBe(SERIALIZATION_FORMAT_VERSION); expect(deserialized.header.consensusVersion).toBe(currentConsensus); expect(deserialized.header.transactionType).toBe(TransactionType.FUNDING); expect(deserialized.header.chainId).toBe(ChainId.Bitcoin); expect(deserialized.header.timestamp).toBe(1234567890123); }); it('should preserve all base params fields', () => { const baseParams: SerializedBaseParams = { from: address1, to: address2, feeRate: 15.5, // Test decimal fee rate priorityFee: '2000', gasSatFee: '1000', networkName: 'testnet', txVersion: 2, note: Buffer.from('test note').toString('hex'), anchor: true, debugFees: true, }; const state = createMockSerializedState(TransactionType.FUNDING, { baseParams, }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(deserialized.baseParams.from).toBe(baseParams.from); expect(deserialized.baseParams.to).toBe(baseParams.to); expect(deserialized.baseParams.feeRate).toBeCloseTo(baseParams.feeRate, 3); expect(deserialized.baseParams.priorityFee).toBe(baseParams.priorityFee); expect(deserialized.baseParams.gasSatFee).toBe(baseParams.gasSatFee); expect(deserialized.baseParams.networkName).toBe(baseParams.networkName); expect(deserialized.baseParams.txVersion).toBe(baseParams.txVersion); expect(deserialized.baseParams.note).toBe(baseParams.note); expect(deserialized.baseParams.anchor).toBe(baseParams.anchor); expect(deserialized.baseParams.debugFees).toBe(baseParams.debugFees); }); it('should handle optional "to" field being undefined', () => { const base = createMockSerializedState(); const { to: _to, ...baseParamsWithoutTo } = base.baseParams; const state = createMockSerializedState(TransactionType.FUNDING, { baseParams: baseParamsWithoutTo as SerializedBaseParams, }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(deserialized.baseParams.to).toBeUndefined(); }); it('should preserve UTXO data correctly', () => { const utxo: SerializedUTXO = { transactionId: 'a'.repeat(64), outputIndex: 5, value: '999999999', scriptPubKeyHex: 'deadbeef', scriptPubKeyAddress: address1, redeemScript: 'cafe0001', witnessScript: 'babe0002', nonWitnessUtxo: 'feed0003', }; const state = createMockSerializedState(TransactionType.FUNDING, { utxos: [utxo], }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(deserialized.utxos).toHaveLength(1); const utxo0 = deserialized.utxos[0] as SerializedUTXO; expect(utxo0.transactionId).toBe(utxo.transactionId); expect(utxo0.outputIndex).toBe(utxo.outputIndex); expect(utxo0.value).toBe(utxo.value); expect(utxo0.scriptPubKeyHex).toBe(utxo.scriptPubKeyHex); expect(utxo0.scriptPubKeyAddress).toBe(utxo.scriptPubKeyAddress); expect(utxo0.redeemScript).toBe(utxo.redeemScript); expect(utxo0.witnessScript).toBe(utxo.witnessScript); expect(utxo0.nonWitnessUtxo).toBe(utxo.nonWitnessUtxo); }); it('should handle multiple UTXOs', () => { const state = createMockSerializedState(TransactionType.FUNDING, { utxos: [ { transactionId: '1'.repeat(64), outputIndex: 0, value: '10000', scriptPubKeyHex: 'aa', scriptPubKeyAddress: address1, }, { transactionId: '2'.repeat(64), outputIndex: 1, value: '20000', scriptPubKeyHex: 'bb', scriptPubKeyAddress: address2, }, { transactionId: '3'.repeat(64), outputIndex: 2, value: '30000', scriptPubKeyHex: 'cc', scriptPubKeyAddress: address3, }, ], }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(deserialized.utxos).toHaveLength(3); expect((deserialized.utxos[0] as SerializedUTXO).transactionId).toBe( '1'.repeat(64), ); expect((deserialized.utxos[1] as SerializedUTXO).transactionId).toBe( '2'.repeat(64), ); expect((deserialized.utxos[2] as SerializedUTXO).transactionId).toBe( '3'.repeat(64), ); }); it('should preserve optional inputs', () => { const state = createMockSerializedState(TransactionType.FUNDING, { optionalInputs: [ { transactionId: 'f'.repeat(64), outputIndex: 99, value: '12345', scriptPubKeyHex: 'ff', }, ], }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(deserialized.optionalInputs).toHaveLength(1); expect((deserialized.optionalInputs[0] as SerializedUTXO).outputIndex).toBe(99); }); it('should preserve optional outputs', () => { const output: SerializedOutput = { value: 5000, address: address2, tapInternalKey: 'abcd1234', }; const state = createMockSerializedState(TransactionType.FUNDING, { optionalOutputs: [output], }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(deserialized.optionalOutputs).toHaveLength(1); const output0 = deserialized.optionalOutputs[0] as SerializedOutput; expect(output0.value).toBe(output.value); expect(output0.address).toBe(output.address); expect(output0.tapInternalKey).toBe(output.tapInternalKey); }); it('should preserve script-based outputs', () => { const output: SerializedOutput = { value: 6000, script: 'deadbeefcafe', }; const state = createMockSerializedState(TransactionType.FUNDING, { optionalOutputs: [output], }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(deserialized.optionalOutputs).toHaveLength(1); const scriptOutput0 = deserialized.optionalOutputs[0] as SerializedOutput; expect(scriptOutput0.script).toBe(output.script); expect(scriptOutput0.address).toBeUndefined(); }); it('should preserve signer mappings for address rotation', () => { const state = createMockSerializedState(TransactionType.FUNDING, { addressRotationEnabled: true, signerMappings: [ { address: address1, inputIndices: [0, 2, 4] }, { address: address2, inputIndices: [1, 3] }, ], }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(deserialized.addressRotationEnabled).toBe(true); expect(deserialized.signerMappings).toHaveLength(2); const mapping0 = deserialized .signerMappings[0] as (typeof deserialized.signerMappings)[0]; const mapping1 = deserialized .signerMappings[1] as (typeof deserialized.signerMappings)[0]; expect(mapping0.address).toBe(address1); expect(mapping0.inputIndices).toEqual([0, 2, 4]); expect(mapping1.address).toBe(address2); expect(mapping1.inputIndices).toEqual([1, 3]); }); it('should preserve precomputed data', () => { const precomputed: PrecomputedData = { compiledTargetScript: 'abcdef123456', randomBytes: '0123456789abcdef', estimatedFees: '5000', contractSeed: 'seedvalue', contractAddress: address3, }; const state = createMockSerializedState(TransactionType.FUNDING, { precomputedData: precomputed, }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(deserialized.precomputedData.compiledTargetScript).toBe( precomputed.compiledTargetScript, ); expect(deserialized.precomputedData.randomBytes).toBe(precomputed.randomBytes); expect(deserialized.precomputedData.estimatedFees).toBe(precomputed.estimatedFees); expect(deserialized.precomputedData.contractSeed).toBe(precomputed.contractSeed); expect(deserialized.precomputedData.contractAddress).toBe( precomputed.contractAddress, ); }); }); describe('type-specific data', () => { it('should serialize/deserialize FundingSpecificData', () => { const typeData: FundingSpecificData = { type: TransactionType.FUNDING, amount: '123456789', splitInputsInto: 5, }; const state = createMockSerializedState(TransactionType.FUNDING, { typeSpecificData: typeData, }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(isFundingSpecificData(deserialized.typeSpecificData)).toBe(true); const data = deserialized.typeSpecificData as FundingSpecificData; expect(data.amount).toBe(typeData.amount); expect(data.splitInputsInto).toBe(typeData.splitInputsInto); }); it('should serialize/deserialize DeploymentSpecificData', () => { const typeData: DeploymentSpecificData = { type: TransactionType.DEPLOYMENT, bytecode: 'deadbeef'.repeat(100), calldata: 'cafebabe', challenge: createMockChallenge(), revealMLDSAPublicKey: true, linkMLDSAPublicKeyToAddress: true, hashedPublicKey: 'abcd'.repeat(16), }; const state = createMockSerializedState(TransactionType.DEPLOYMENT, { header: { formatVersion: SERIALIZATION_FORMAT_VERSION, consensusVersion: currentConsensus, transactionType: TransactionType.DEPLOYMENT, chainId: ChainId.Bitcoin, timestamp: Date.now(), }, typeSpecificData: typeData, }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(isDeploymentSpecificData(deserialized.typeSpecificData)).toBe(true); const data = deserialized.typeSpecificData as DeploymentSpecificData; expect(data.bytecode).toBe(typeData.bytecode); expect(data.calldata).toBe(typeData.calldata); expect(data.revealMLDSAPublicKey).toBe(true); expect(data.linkMLDSAPublicKeyToAddress).toBe(true); expect(data.hashedPublicKey).toBe(typeData.hashedPublicKey); }); it('should serialize/deserialize InteractionSpecificData', () => { const typeData: InteractionSpecificData = { type: TransactionType.INTERACTION, calldata: 'cafebabe12345678', contract: 'bcrt1qtest', challenge: createMockChallenge(), loadedStorage: { key1: ['value1', 'value2'], key2: ['value3'], }, isCancellation: true, disableAutoRefund: true, revealMLDSAPublicKey: false, }; const state = createMockSerializedState(TransactionType.INTERACTION, { header: { formatVersion: SERIALIZATION_FORMAT_VERSION, consensusVersion: currentConsensus, transactionType: TransactionType.INTERACTION, chainId: ChainId.Bitcoin, timestamp: Date.now(), }, typeSpecificData: typeData, }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(isInteractionSpecificData(deserialized.typeSpecificData)).toBe(true); const data = deserialized.typeSpecificData as InteractionSpecificData; expect(data.calldata).toBe(typeData.calldata); expect(data.contract).toBe(typeData.contract); expect(data.loadedStorage).toEqual(typeData.loadedStorage); expect(data.isCancellation).toBe(true); expect(data.disableAutoRefund).toBe(true); }); it('should serialize/deserialize MultiSigSpecificData', () => { const typeData: MultiSigSpecificData = { type: TransactionType.MULTI_SIG, pubkeys: ['aa'.repeat(33), 'bb'.repeat(33), 'cc'.repeat(33)], minimumSignatures: 2, receiver: address2, requestedAmount: '500000', refundVault: address3, originalInputCount: 3, existingPsbtBase64: 'cHNidP8BAH...', }; const state = createMockSerializedState(TransactionType.MULTI_SIG, { header: { formatVersion: SERIALIZATION_FORMAT_VERSION, consensusVersion: currentConsensus, transactionType: TransactionType.MULTI_SIG, chainId: ChainId.Bitcoin, timestamp: Date.now(), }, typeSpecificData: typeData, }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(isMultiSigSpecificData(deserialized.typeSpecificData)).toBe(true); const data = deserialized.typeSpecificData as MultiSigSpecificData; expect(data.pubkeys).toEqual(typeData.pubkeys); expect(data.minimumSignatures).toBe(2); expect(data.receiver).toBe(typeData.receiver); expect(data.requestedAmount).toBe(typeData.requestedAmount); expect(data.refundVault).toBe(typeData.refundVault); expect(data.originalInputCount).toBe(3); expect(data.existingPsbtBase64).toBe(typeData.existingPsbtBase64); }); it('should serialize/deserialize CustomScriptSpecificData', () => { const typeData: CustomScriptSpecificData = { type: TransactionType.CUSTOM_CODE, scriptElements: [ { elementType: 'buffer', value: 'deadbeef' }, { elementType: 'opcode', value: 118 }, // OP_DUP { elementType: 'opcode', value: 169 }, // OP_HASH160 ], witnesses: ['abcdef0123456789', 'fedcba9876543210'], annex: 'aabbccdd', }; const state = createMockSerializedState(TransactionType.CUSTOM_CODE, { header: { formatVersion: SERIALIZATION_FORMAT_VERSION, consensusVersion: currentConsensus, transactionType: TransactionType.CUSTOM_CODE, chainId: ChainId.Bitcoin, timestamp: Date.now(), }, typeSpecificData: typeData, }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(isCustomScriptSpecificData(deserialized.typeSpecificData)).toBe(true); const data = deserialized.typeSpecificData as CustomScriptSpecificData; expect(data.scriptElements).toHaveLength(3); expect(data.scriptElements[0]).toEqual({ elementType: 'buffer', value: 'deadbeef', }); expect(data.scriptElements[1]).toEqual({ elementType: 'opcode', value: 118 }); expect(data.witnesses).toEqual(typeData.witnesses); expect(data.annex).toBe(typeData.annex); }); it('should serialize/deserialize CancelSpecificData', () => { const typeData: CancelSpecificData = { type: TransactionType.CANCEL, compiledTargetScript: 'deadbeefcafe1234', }; const state = createMockSerializedState(TransactionType.CANCEL, { header: { formatVersion: SERIALIZATION_FORMAT_VERSION, consensusVersion: currentConsensus, transactionType: TransactionType.CANCEL, chainId: ChainId.Bitcoin, timestamp: Date.now(), }, typeSpecificData: typeData, }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(isCancelSpecificData(deserialized.typeSpecificData)).toBe(true); const data = deserialized.typeSpecificData as CancelSpecificData; expect(data.compiledTargetScript).toBe(typeData.compiledTargetScript); }); }); describe('format conversion', () => { it('should convert to/from base64', () => { const state = createMockSerializedState(); const base64 = TransactionSerializer.toBase64(state); expect(typeof base64).toBe('string'); expect(base64.length).toBeGreaterThan(0); const restored = TransactionSerializer.fromBase64(base64); expect(restored.header.transactionType).toBe(state.header.transactionType); expect(restored.baseParams.from).toBe(state.baseParams.from); }); it('should convert to/from hex', () => { const state = createMockSerializedState(); const hex = TransactionSerializer.toHex(state); expect(typeof hex).toBe('string'); expect(/^[0-9a-f]+$/i.test(hex)).toBe(true); const restored = TransactionSerializer.fromHex(hex); expect(restored.header.transactionType).toBe(state.header.transactionType); }); }); describe('error handling', () => { it('should throw on invalid magic byte', () => { const state = createMockSerializedState(); const serialized = TransactionSerializer.serialize(state); // Corrupt magic byte serialized[0] = 0x00; // Recalculate checksum to bypass checksum error const payload = serialized.subarray(0, -32); const hash1 = createHash('sha256').update(payload).digest(); const newChecksum = createHash('sha256').update(hash1).digest(); newChecksum.copy(serialized, serialized.length - 32); expect(() => TransactionSerializer.deserialize(serialized)).toThrow( /Invalid magic byte/, ); }); it('should throw on invalid checksum', () => { const state = createMockSerializedState(); const serialized = TransactionSerializer.serialize(state); // Corrupt checksum serialized[serialized.length - 1] = (serialized[serialized.length - 1] as number) ^ 0xff; expect(() => TransactionSerializer.deserialize(serialized)).toThrow( /Invalid checksum/, ); }); it('should throw on data too short', () => { const shortData = Buffer.alloc(16); // Less than 32 bytes expect(() => TransactionSerializer.deserialize(shortData)).toThrow(/too short/); }); it('should throw on unsupported format version', () => { const state = createMockSerializedState(); const serialized = TransactionSerializer.serialize(state); // Set format version to a high value serialized[1] = 255; // Recalculate checksum const payload = serialized.subarray(0, -32); const hash1 = createHash('sha256').update(payload).digest(); const newChecksum = createHash('sha256').update(hash1).digest(); newChecksum.copy(serialized, serialized.length - 32); expect(() => TransactionSerializer.deserialize(serialized)).toThrow( /Unsupported format version/, ); }); }); describe('network serialization', () => { it('should serialize mainnet correctly', () => { const base = createMockSerializedState(); const state = createMockSerializedState(TransactionType.FUNDING, { baseParams: { ...base.baseParams, networkName: 'mainnet' }, }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(deserialized.baseParams.networkName).toBe('mainnet'); }); it('should serialize testnet correctly', () => { const base = createMockSerializedState(); const state = createMockSerializedState(TransactionType.FUNDING, { baseParams: { ...base.baseParams, networkName: 'testnet' }, }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(deserialized.baseParams.networkName).toBe('testnet'); }); it('should serialize regtest correctly', () => { const base = createMockSerializedState(); const state = createMockSerializedState(TransactionType.FUNDING, { baseParams: { ...base.baseParams, networkName: 'regtest' }, }); const deserialized = TransactionSerializer.deserialize( TransactionSerializer.serialize(state), ); expect(deserialized.baseParams.networkName).toBe('regtest'); }); }); }); describe('TransactionStateCapture', () => { describe('fromFunding', () => { it('should capture state from funding transaction parameters', () => { const params = { signer: defaultSigner, mldsaSigner: null, network, utxos: [createTaprootUtxo(address1, 100000n)], from: address1, to: address2, feeRate: 10, priorityFee: 1000n, gasSatFee: 500n, amount: 50000n, splitInputsInto: 2, }; const state = TransactionStateCapture.fromFunding(params); expect(state.header.transactionType).toBe(TransactionType.FUNDING); expect(state.baseParams.from).toBe(address1); expect(state.baseParams.to).toBe(address2); expect(state.baseParams.feeRate).toBe(10); expect(state.utxos).toHaveLength(1); expect(isFundingSpecificData(state.typeSpecificData)).toBe(true); const data = state.typeSpecificData as FundingSpecificData; expect(data.amount).toBe('50000'); expect(data.splitInputsInto).toBe(2); }); it('should capture precomputed data', () => { const params = { signer: defaultSigner, mldsaSigner: null, network, utxos: [createTaprootUtxo(address1, 100000n)], from: address1, to: address2, feeRate: 10, priorityFee: 1000n, gasSatFee: 500n, amount: 50000n, }; const precomputed = { estimatedFees: '2000', }; const state = TransactionStateCapture.fromFunding(params, precomputed); expect(state.precomputedData.estimatedFees).toBe('2000'); }); it('should handle address rotation configuration', () => { const signerMap = createSignerMap([ [address1, signer1], [address2, signer2], ]); const params = { signer: defaultSigner, mldsaSigner: null, network, utxos: [ createTaprootUtxo(address1, 50000n, '1'.repeat(64), 0), createTaprootUtxo(address2, 50000n, '2'.repeat(64), 0), ], from: address1, to: address3, feeRate: 10, priorityFee: 1000n, gasSatFee: 500n, amount: 80000n, addressRotation: createAddressRotation(signerMap), }; const state = TransactionStateCapture.fromFunding(params); expect(state.addressRotationEnabled).toBe(true); expect(state.signerMappings).toHaveLength(2); }); }); describe('UTXO serialization', () => { it('should serialize UTXO with all optional fields', () => { const utxo: UTXO = { transactionId: 'a'.repeat(64), outputIndex: 5, value: 999999n, scriptPubKey: { hex: 'deadbeef', address: address1, }, redeemScript: Buffer.from('cafe', 'hex'), witnessScript: Buffer.from('babe', 'hex'), nonWitnessUtxo: Buffer.from('feed', 'hex'), }; const params = { signer: defaultSigner, mldsaSigner: null, network, utxos: [utxo], from: address1, to: address2, feeRate: 10, priorityFee: 1000n, gasSatFee: 500n, amount: 50000n, }; const state = TransactionStateCapture.fromFunding(params); const stateUtxo0 = state.utxos[0] as SerializedUTXO; expect(stateUtxo0.transactionId).toBe(utxo.transactionId); expect(stateUtxo0.redeemScript).toBe('cafe'); expect(stateUtxo0.witnessScript).toBe('babe'); expect(stateUtxo0.nonWitnessUtxo).toBe('feed'); }); it('should handle UTXOs with string scripts', () => { const utxo: UTXO = { transactionId: 'b'.repeat(64), outputIndex: 0, value: 10000n, scriptPubKey: { hex: 'aabbcc', address: address1, }, redeemScript: 'ddeeff', // String instead of Buffer }; const params = { signer: defaultSigner, mldsaSigner: null, network, utxos: [utxo], from: address1, to: address2, feeRate: 10, priorityFee: 1000n, gasSatFee: 500n, amount: 5000n, }; const state = TransactionStateCapture.fromFunding(params); expect((state.utxos[0] as SerializedUTXO).redeemScript).toBe('ddeeff'); }); }); }); describe('TransactionReconstructor', () => { describe('reconstruct', () => { it('should reconstruct a funding transaction', () => { const state = createMockSerializedState(TransactionType.FUNDING); const options: ReconstructionOptions = { signer: defaultSigner, }; const builder = TransactionReconstructor.reconstruct(state, options); expect(builder).toBeInstanceOf(FundingTransaction); expect(builder.type).toBe(TransactionType.FUNDING); }); it('should apply fee rate override', () => { const base = createMockSerializedState(); const state = createMockSerializedState(TransactionType.FUNDING, { baseParams: { ...base.baseParams, feeRate: 10 }, }); const options: ReconstructionOptions = { signer: defaultSigner, newFeeRate: 50, }; const builder = TransactionReconstructor.reconstruct(state, options); // The builder should have the new fee rate expect(builder).toBeDefined(); }); it('should apply priority fee override', () => { const state = createMockSerializedState(TransactionType.FUNDING); const options: ReconstructionOptions = { signer: defaultSigner, newPriorityFee: 5000n, }; const builder = TransactionReconstructor.reconstruct(state, options); expect(builder).toBeDefined(); }); it('should apply gas sat fee override', () => { const state = createMockSerializedState(TransactionType.FUNDING); const options: ReconstructionOptions = { signer: defaultSigner, newGasSatFee: 2000n, }; const builder = TransactionReconstructor.reconstruct(state, options); expect(builder).toBeDefined(); }); it('should throw when address rotation enabled but no signerMap provided', () => { const state = createMockSerializedState(TransactionType.FUNDING, { addressRotationEnabled: true, }); const options: ReconstructionOptions = { signer: defaultSigner, // No signerMap provided }; expect(() => TransactionReconstructor.reconstruct(state, options)).toThrow( /signerMap/, ); }); it('should reconstruct with address rotation when signerMap provided', () => { const state = createMockSerializedState(TransactionType.FUNDING, { addressRotationEnabled: true, signerMappings: [{ address: address1, inputIndices: [0] }], }); const signerMap = createSignerMap([[address1, signer1]]); const options: ReconstructionOptions = { signer: defaultSigner, signerMap, }; const builder = TransactionReconstructor.reconstruct(state, options); expect(builder).toBeDefined(); }); }); describe('network conversion', () => { it('should convert mainnet name to network', () => { const base = createMockSerializedState(); const state = createMockSerializedState(TransactionType.FUNDING, { baseParams: { ...base.baseParams, networkName: 'mainnet' }, }); const options: ReconstructionOptions = { signer: defaultSigner, }; const builder = TransactionReconstructor.reconstruct(state, options); expect(builder).toBeDefined(); }); it('should convert testnet name to network', () => { const base = createMockSerializedState(); const state = createMockSerializedState(TransactionType.FUNDING, { baseParams: { ...base.baseParams, networkName: 'testnet' }, }); const options: ReconstructionOptions = { signer: defaultSigner, }; const builder = TransactionReconstructor.reconstruct(state, options); expect(builder).toBeDefined(); }); it('should convert regtest name to network', () => { const base = createMockSerializedState(); const state = createMockSerializedState(TransactionType.FUNDING, { baseParams: { ...base.baseParams, networkName: 'regtest' }, }); const options: ReconstructionOptions = { signer: defaultSigner, }; const builder = TransactionReconstructor.reconstruct(state, options); expect(builder).toBeDefined(); }); }); }); describe('OfflineTransactionManager', () => { describe('exportFunding', () => { it('should export funding transaction to base64', () => { const params = { signer: defaultSigner, mldsaSigner: null, network, utxos: [createTaprootUtxo(address1, 100000n)], from: address1, to: address2, feeRate: 10, priorityFee: 1000n, gasSatFee: 500n, amount: 50000n, }; const exported = OfflineTransactionManager.exportFunding(params); expect(typeof exported).toBe('string'); expect(exported.length).toBeGreaterThan(0); // Should be valid base64 expect(() => Buffer.from(exported, 'base64')).not.toThrow(); }); }); describe('importForSigning', () => { it('should import serialized state and create builder', () => { const params = { signer: defaultSigner, mldsaSigner: null, network, utxos: [createTaprootUtxo(address1, 100000n)], from: address1, to: address2, feeRate: 10, priorityFee: 1000n, gasSatFee: 500n, amount: 50000n, }; const exported = OfflineTransactionManager.exportFunding(params); const builder = OfflineTransactionManager.importForSigning(exported, { signer: signer1, }); expect(builder).toBeInstanceOf(FundingTransaction); }); }); describe('inspect', () => { it('should return parsed state for inspection', () => { const params = { signer: defaultSigner, mldsaSigner: null, network, utxos: [createTaprootUtxo(address1, 100000n)], from: address1, to: address2, feeRate: 15, priorityFee: 1000n, gasSatFee: 500n, amount: 50000n, }; const exported = OfflineTransactionManager.exportFunding(params); const inspected = OfflineTransactionManager.inspect(exported); expect(inspected.header.transactionType).toBe(TransactionType.FUNDING); expect(inspected.baseParams.from).toBe(address1); expect(inspected.baseParams.to).toBe(address2); expect(inspected.baseParams.feeRate).toBeCloseTo(15, 3); }); }); describe('validate', () => { it('should return true for valid serialized state', () => { const params = { signer: defaultSigner, mldsaSigner: null, network, utxos: [createTaprootUtxo(address1, 100000n)], from: address1, to: address2, feeRate: 10, priorityFee: 1000n, gasSatFee: 500n, amount: 50000n, }; const exported = OfflineTransactionManager.exportFunding(params); expect(OfflineTransactionManager.validate(exported)).toBe(true); }); it('should return false for invalid serialized state', () => { expect(OfflineTransactionManager.validate('invalid base64!')).toBe(false); expect(OfflineTransactionManager.validate('')).toBe(false); expect(OfflineTransactionManager.validate('YWJj')).toBe(false); // Valid base64 but invalid data }); }); describe('getType', () => { it('should return transaction type from serialized state', () => { const params = { signer: defaultSigner, mldsaSigner: null, network, utxos: [createTaprootUtxo(address1, 100000n)], from: address1, to: address2, feeRate: 10, priorityFee: 1000n, gasSatFee: 500n, amount: 50000n, }; const exported = OfflineTransactionManager.exportFunding(params); const type = OfflineTransactionManager.getType(exported); expect(type).toBe(TransactionType.FUNDING); }); }); describe('toHex/fromHex', () => { it('should convert between base64 and hex formats', () => { const params = { signer: defaultSigner, mldsaSigner: null, network, utxos: [createTaprootUtxo(address1, 100000n)], from: address1, to: address2, feeRate: 10, priorityFee: 1000n, gasSatFee: 500n, amount: 50000n, }; const base64 = OfflineTransactionManager.exportFunding(params); const hex = OfflineTransactionManager.toHex(base64); expect(/^[0-9a-f]+$/i.test(hex)).toBe(true); const backToBase64 = OfflineTransactionManager.fromHex(hex); // Both should deserialize to the same state const state1 = OfflineTransactionManager.inspect(base64); const state2 = OfflineTransactionManager.inspect(backToBase64); expect(state1.baseParams.from).toBe(state2.baseParams.from); expect(state1.baseParams.to).toBe(state2.baseParams.to); }); }); describe('full workflow', () => { it('should complete export -> import -> reconstruct workflow', () => { // Phase 1: Export const params = { signer: defaultSigner, mldsaSigner: null, network, utxos: [createTaprootUtxo(address1, 100000n)], from: address1, to: address2, feeRate: 10, priorityFee: 1000n, gasSatFee: 500n, amount: 50000n, }; const exported = OfflineTransactionManager.exportFunding(params); // Validate expect(OfflineTransactionManager.validate(exported)).toBe(true); // Inspect const inspected = OfflineTransactionManager.inspect(exported); expect(inspected.header.transactionType).toBe(TransactionType.FUNDING); // Phase 2: Import with different signer const builder = OfflineTransactionManager.importForSigning(exported, { signer: signer1, }); expect(builder).toBeDefined(); expect(builder.type).toBe(TransactionType.FUNDING);