@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
435 lines (393 loc) • 16.5 kB
text/typescript
import {
fromHex,
type Network,
networks,
type PsbtOutputExtended,
type Script,
type Signer,
type Stack,
toSatoshi,
} from '@btc-vision/bitcoin';
import { type UniversalSigner } from '@btc-vision/ecpair';
import type { QuantumBIP32Interface } from '@btc-vision/bip32';
import type { UTXO } from '../../utxo/interfaces/IUTXO.js';
import type { AddressRotationConfig, SignerMap } from '../../signer/AddressRotation.js';
import { ChallengeSolution } from '../../epoch/ChallengeSolution.js';
import { TransactionType } from '../enums/TransactionType.js';
import { TransactionBuilder } from '../builders/TransactionBuilder.js';
import { FundingTransaction } from '../builders/FundingTransaction.js';
import { DeploymentTransaction } from '../builders/DeploymentTransaction.js';
import { InteractionTransaction } from '../builders/InteractionTransaction.js';
import { MultiSignTransaction } from '../builders/MultiSignTransaction.js';
import { CustomScriptTransaction } from '../builders/CustomScriptTransaction.js';
import { CancelTransaction } from '../builders/CancelTransaction.js';
import type {
ISerializableTransactionState,
SerializedOutput,
SerializedUTXO,
} from './interfaces/ISerializableState.js';
import {
type CancelSpecificData,
type CustomScriptSpecificData,
type DeploymentSpecificData,
type FundingSpecificData,
type InteractionSpecificData,
isCancelSpecificData,
isCustomScriptSpecificData,
isDeploymentSpecificData,
isFundingSpecificData,
isInteractionSpecificData,
isMultiSigSpecificData,
type MultiSigSpecificData,
} from './interfaces/ITypeSpecificData.js';
import type {
IDeploymentParameters,
IFundingTransactionParameters,
IInteractionParameters,
ITransactionParameters,
} from '../interfaces/ITransactionParameters.js';
import type { SupportedTransactionVersion } from '../interfaces/ITweakedTransactionData.js';
/**
* Options for reconstructing a transaction from serialized state
*/
export interface ReconstructionOptions {
/** Primary signer (used for normal mode or as default in rotation mode) */
signer: Signer | UniversalSigner;
/** Optional: Override fee rate for fee bumping */
newFeeRate?: number;
/** Optional: Override priority fee */
newPriorityFee?: bigint;
/** Optional: Override gas sat fee */
newGasSatFee?: bigint;
/** Signer map for address rotation mode (keyed by address) */
signerMap?: SignerMap;
/** MLDSA signer (for quantum-resistant features) */
mldsaSigner?: QuantumBIP32Interface | null;
}
/**
* Reconstructs transaction builders from serialized state.
* Supports fee bumping by allowing parameter overrides during reconstruction.
*/
export class TransactionReconstructor {
/**
* Reconstruct and optionally rebuild transaction with new parameters
* @param state - Serialized transaction state
* @param options - Signer(s) and optional fee overrides
* @returns Reconstructed transaction builder ready for signing
*/
public static reconstruct(
state: ISerializableTransactionState,
options: ReconstructionOptions,
): TransactionBuilder<TransactionType> {
const network = this.nameToNetwork(state.baseParams.networkName);
const utxos = this.deserializeUTXOs(state.utxos);
const optionalInputs = this.deserializeUTXOs(state.optionalInputs);
const optionalOutputs = this.deserializeOutputs(state.optionalOutputs);
// Build address rotation config
const addressRotation = this.buildAddressRotationConfig(
state.addressRotationEnabled,
options.signerMap,
);
// Apply fee overrides
const feeRate = options.newFeeRate ?? state.baseParams.feeRate;
const priorityFee = options.newPriorityFee ?? BigInt(state.baseParams.priorityFee);
const gasSatFee = options.newGasSatFee ?? BigInt(state.baseParams.gasSatFee);
// Build base params
const baseParams: ITransactionParameters = {
signer: options.signer,
mldsaSigner: options.mldsaSigner ?? null,
network,
utxos,
optionalInputs,
optionalOutputs,
from: state.baseParams.from,
feeRate,
priorityFee,
gasSatFee,
anchor: state.baseParams.anchor,
...(state.header.chainId !== undefined ? { chainId: state.header.chainId } : {}),
...(state.baseParams.to !== undefined ? { to: state.baseParams.to } : {}),
...(state.baseParams.txVersion !== undefined
? { txVersion: state.baseParams.txVersion as SupportedTransactionVersion }
: {}),
...(state.baseParams.note !== undefined
? { note: fromHex(state.baseParams.note) }
: {}),
...(state.baseParams.debugFees !== undefined
? { debugFees: state.baseParams.debugFees }
: {}),
...(addressRotation !== undefined ? { addressRotation } : {}),
...(state.precomputedData.estimatedFees !== undefined
? { estimatedFees: BigInt(state.precomputedData.estimatedFees) }
: {}),
...(state.precomputedData.compiledTargetScript !== undefined
? { compiledTargetScript: fromHex(state.precomputedData.compiledTargetScript) }
: {}),
};
// Dispatch based on transaction type
const typeData = state.typeSpecificData;
if (isFundingSpecificData(typeData)) {
return this.reconstructFunding(baseParams, typeData);
} else if (isDeploymentSpecificData(typeData)) {
return this.reconstructDeployment(baseParams, typeData, state);
} else if (isInteractionSpecificData(typeData)) {
return this.reconstructInteraction(baseParams, typeData, state);
} else if (isMultiSigSpecificData(typeData)) {
return this.reconstructMultiSig(baseParams, typeData);
} else if (isCustomScriptSpecificData(typeData)) {
return this.reconstructCustomScript(baseParams, typeData, state);
} else if (isCancelSpecificData(typeData)) {
return this.reconstructCancel(baseParams, typeData);
}
throw new Error(`Unsupported transaction type: ${state.header.transactionType}`);
}
/**
* Reconstruct a FundingTransaction
*/
private static reconstructFunding(
baseParams: ITransactionParameters,
data: FundingSpecificData,
): FundingTransaction {
const params: IFundingTransactionParameters = {
...baseParams,
amount: BigInt(data.amount),
splitInputsInto: data.splitInputsInto,
};
return new FundingTransaction(params);
}
/**
* Reconstruct a DeploymentTransaction
*/
private static reconstructDeployment(
baseParams: ITransactionParameters,
data: DeploymentSpecificData,
state: ISerializableTransactionState,
): DeploymentTransaction {
const challenge = new ChallengeSolution(data.challenge);
const params: IDeploymentParameters = {
...baseParams,
bytecode: fromHex(data.bytecode),
challenge,
...(data.calldata !== undefined ? { calldata: fromHex(data.calldata) } : {}),
...(state.precomputedData.randomBytes !== undefined
? { randomBytes: fromHex(state.precomputedData.randomBytes) }
: {}),
...(data.revealMLDSAPublicKey !== undefined
? { revealMLDSAPublicKey: data.revealMLDSAPublicKey }
: {}),
...(data.linkMLDSAPublicKeyToAddress !== undefined
? { linkMLDSAPublicKeyToAddress: data.linkMLDSAPublicKeyToAddress }
: {}),
};
return new DeploymentTransaction(params);
}
/**
* Reconstruct an InteractionTransaction
*/
private static reconstructInteraction(
baseParams: ITransactionParameters,
data: InteractionSpecificData,
state: ISerializableTransactionState,
): InteractionTransaction {
const challenge = new ChallengeSolution(data.challenge);
if (!baseParams.to) {
throw new Error('InteractionTransaction requires a "to" address');
}
const params: IInteractionParameters = {
...baseParams,
to: baseParams.to,
calldata: fromHex(data.calldata),
challenge,
...(data.contract !== undefined ? { contract: data.contract } : {}),
...(state.precomputedData.randomBytes !== undefined
? { randomBytes: fromHex(state.precomputedData.randomBytes) }
: {}),
...(data.loadedStorage !== undefined ? { loadedStorage: data.loadedStorage } : {}),
...(data.isCancellation !== undefined ? { isCancellation: data.isCancellation } : {}),
...(data.disableAutoRefund !== undefined
? { disableAutoRefund: data.disableAutoRefund }
: {}),
...(data.revealMLDSAPublicKey !== undefined
? { revealMLDSAPublicKey: data.revealMLDSAPublicKey }
: {}),
...(data.linkMLDSAPublicKeyToAddress !== undefined
? { linkMLDSAPublicKeyToAddress: data.linkMLDSAPublicKeyToAddress }
: {}),
};
return new InteractionTransaction(params);
}
/**
* Reconstruct a MultiSignTransaction
*/
private static reconstructMultiSig(
baseParams: ITransactionParameters,
data: MultiSigSpecificData,
): MultiSignTransaction {
const pubkeys = data.pubkeys.map((pk) => fromHex(pk));
// If there's an existing PSBT, use fromBase64 to preserve partial signatures
if (data.existingPsbtBase64) {
return MultiSignTransaction.fromBase64({
mldsaSigner: baseParams.mldsaSigner,
network: baseParams.network,
utxos: baseParams.utxos,
feeRate: baseParams.feeRate,
pubkeys,
minimumSignatures: data.minimumSignatures,
receiver: data.receiver,
requestedAmount: BigInt(data.requestedAmount),
refundVault: data.refundVault,
psbt: data.existingPsbtBase64,
...(baseParams.chainId !== undefined ? { chainId: baseParams.chainId } : {}),
...(baseParams.optionalInputs !== undefined
? { optionalInputs: baseParams.optionalInputs }
: {}),
...(baseParams.optionalOutputs !== undefined
? { optionalOutputs: baseParams.optionalOutputs }
: {}),
});
}
// No existing PSBT - create fresh transaction
const params = {
mldsaSigner: baseParams.mldsaSigner,
network: baseParams.network,
utxos: baseParams.utxos,
feeRate: baseParams.feeRate,
pubkeys,
minimumSignatures: data.minimumSignatures,
receiver: data.receiver,
requestedAmount: BigInt(data.requestedAmount),
refundVault: data.refundVault,
...(baseParams.chainId !== undefined ? { chainId: baseParams.chainId } : {}),
...(baseParams.optionalInputs !== undefined
? { optionalInputs: baseParams.optionalInputs }
: {}),
...(baseParams.optionalOutputs !== undefined
? { optionalOutputs: baseParams.optionalOutputs }
: {}),
};
return new MultiSignTransaction(params);
}
/**
* Reconstruct a CustomScriptTransaction
*/
private static reconstructCustomScript(
baseParams: ITransactionParameters,
data: CustomScriptSpecificData,
state: ISerializableTransactionState,
): CustomScriptTransaction {
// Convert serialized elements to (Uint8Array | Stack)[]
const scriptElements: (Uint8Array | Stack)[] = data.scriptElements.map((el) => {
if (el.elementType === 'buffer') {
return fromHex(el.value as string);
}
// Opcodes stored as numbers - wrap in array for Stack type
return [el.value as number] as Stack;
});
const witnesses = data.witnesses.map((w) => fromHex(w));
if (!baseParams.to) {
throw new Error('CustomScriptTransaction requires a "to" address');
}
const params = {
...baseParams,
to: baseParams.to,
script: scriptElements,
witnesses,
...(data.annex !== undefined ? { annex: fromHex(data.annex) } : {}),
...(state.precomputedData.randomBytes !== undefined
? { randomBytes: fromHex(state.precomputedData.randomBytes) }
: {}),
};
return new CustomScriptTransaction(params);
}
/**
* Reconstruct a CancelTransaction
*/
private static reconstructCancel(
baseParams: ITransactionParameters,
data: CancelSpecificData,
): CancelTransaction {
const params = {
...baseParams,
compiledTargetScript: fromHex(data.compiledTargetScript),
};
return new CancelTransaction(params);
}
/**
* Build address rotation config from options
*/
private static buildAddressRotationConfig(
enabled: boolean,
signerMap?: SignerMap,
): AddressRotationConfig | undefined {
if (!enabled) {
return undefined;
}
if (!signerMap || signerMap.size === 0) {
throw new Error(
'Address rotation enabled but no signerMap provided in reconstruction options',
);
}
return {
enabled: true,
signerMap,
};
}
/**
* Deserialize UTXOs from serialized format
*/
private static deserializeUTXOs(serialized: SerializedUTXO[]): UTXO[] {
return serialized.map((s) => {
const utxo: UTXO = {
transactionId: s.transactionId,
outputIndex: s.outputIndex,
value: BigInt(s.value),
scriptPubKey: {
hex: s.scriptPubKeyHex,
...(s.scriptPubKeyAddress !== undefined
? { address: s.scriptPubKeyAddress }
: {}),
},
};
if (s.redeemScript !== undefined) utxo.redeemScript = fromHex(s.redeemScript);
if (s.witnessScript !== undefined) utxo.witnessScript = fromHex(s.witnessScript);
if (s.nonWitnessUtxo !== undefined) utxo.nonWitnessUtxo = fromHex(s.nonWitnessUtxo);
return utxo;
});
}
/**
* Deserialize outputs from serialized format
*/
private static deserializeOutputs(serialized: SerializedOutput[]): PsbtOutputExtended[] {
return serialized.map((s): PsbtOutputExtended => {
const base = { value: toSatoshi(BigInt(s.value)) };
const tapKey =
s.tapInternalKey !== undefined ? { tapInternalKey: fromHex(s.tapInternalKey) } : {};
// PsbtOutputExtended is a union type - either has address OR script, not both
if (s.address) {
return { ...base, address: s.address, ...tapKey };
} else if (s.script) {
return { ...base, script: fromHex(s.script) as Script, ...tapKey };
} else {
// Fallback - shouldn't happen with valid data
return { ...base, address: '', ...tapKey };
}
});
}
/**
* Convert network name to Network object
*/
private static nameToNetwork(name: 'mainnet' | 'testnet' | 'opnetTestnet' | 'regtest'): Network {
switch (name) {
case 'mainnet':
return networks.bitcoin;
case 'testnet':
return networks.testnet;
case 'opnetTestnet':
return networks.opnetTestnet;
case 'regtest':
return networks.regtest;
default:
throw new Error(`Unknown network: ${name}`);
}
}
}