@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
text/typescript
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);