@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
630 lines (557 loc) • 22.3 kB
text/typescript
import { fromHex, Psbt, type PsbtInput, type Signer, toHex } from '@btc-vision/bitcoin';
import { type UniversalSigner } from '@btc-vision/ecpair';
import { TransactionType } from '../enums/TransactionType.js';
import { TransactionBuilder } from '../builders/TransactionBuilder.js';
import { MultiSignTransaction } from '../builders/MultiSignTransaction.js';
import type { ISerializableTransactionState, PrecomputedData, } from './interfaces/ISerializableState.js';
import { TransactionSerializer } from './TransactionSerializer.js';
import { type ReconstructionOptions, TransactionReconstructor, } from './TransactionReconstructor.js';
import { TransactionStateCapture } from './TransactionStateCapture.js';
import { isMultiSigSpecificData } from './interfaces/ITypeSpecificData.js';
import type {
IDeploymentParameters,
IFundingTransactionParameters,
IInteractionParameters,
ITransactionParameters,
} from '../interfaces/ITransactionParameters.js';
/**
* Export options for offline transaction signing
*/
export interface ExportOptions {
/** The original transaction parameters */
params: ITransactionParameters;
/** Transaction type */
type: TransactionType;
/** Precomputed data from the builder */
precomputed?: Partial<PrecomputedData>;
}
/**
* Main entry point for offline transaction signing workflow.
*
* This class provides a complete API for:
* 1. Phase 1 (Online): Building transactions and exporting state for offline signing
* 2. Phase 2 (Offline): Importing state, providing signers, and signing transactions
*
* Also supports fee bumping by allowing reconstruction with new fee parameters.
*
* @example
* ```typescript
* // Phase 1 (Online environment)
* const params: IFundingTransactionParameters = { ... };
* const state = OfflineTransactionManager.exportFunding(params);
* // Send state to offline environment
*
* // Phase 2 (Offline environment)
* const signedTxHex = await OfflineTransactionManager.importSignAndExport(state, {
* signer: offlineSigner,
* });
* // Send signedTxHex back to online environment for broadcast
* ```
*/
export class OfflineTransactionManager {
/**
* Export a FundingTransaction for offline signing
* @param params - Funding transaction parameters
* @param precomputed - Optional precomputed data
* @returns Base64-encoded serialized state
*/
public static exportFunding(
params: IFundingTransactionParameters,
precomputed?: Partial<PrecomputedData>,
): string {
const state = TransactionStateCapture.fromFunding(params, precomputed);
return TransactionSerializer.toBase64(state);
}
/**
* Export a DeploymentTransaction for offline signing
* @param params - Deployment transaction parameters
* @param precomputed - Required precomputed data (randomBytes, compiledTargetScript)
* @returns Base64-encoded serialized state
*/
public static exportDeployment(
params: IDeploymentParameters,
precomputed: Partial<PrecomputedData> & {
compiledTargetScript: string;
randomBytes: string;
},
): string {
const state = TransactionStateCapture.fromDeployment(params, precomputed);
return TransactionSerializer.toBase64(state);
}
/**
* Export an InteractionTransaction for offline signing
* @param params - Interaction transaction parameters
* @param precomputed - Required precomputed data (randomBytes, compiledTargetScript)
* @returns Base64-encoded serialized state
*/
public static exportInteraction(
params: IInteractionParameters,
precomputed: Partial<PrecomputedData> & {
compiledTargetScript: string;
randomBytes: string;
},
): string {
const state = TransactionStateCapture.fromInteraction(params, precomputed);
return TransactionSerializer.toBase64(state);
}
/**
* Export a MultiSignTransaction for offline signing
* @param params - MultiSig transaction parameters
* @param precomputed - Optional precomputed data
* @returns Base64-encoded serialized state
*/
public static exportMultiSig(
params: ITransactionParameters & {
pubkeys: Uint8Array[];
minimumSignatures: number;
receiver: string;
requestedAmount: bigint;
refundVault: string;
originalInputCount?: number;
existingPsbtBase64?: string;
},
precomputed?: Partial<PrecomputedData>,
): string {
const state = TransactionStateCapture.fromMultiSig(params, precomputed);
return TransactionSerializer.toBase64(state);
}
/**
* Export a CustomScriptTransaction for offline signing
* @param params - Custom script transaction parameters
* @param precomputed - Optional precomputed data
* @returns Base64-encoded serialized state
*/
public static exportCustomScript(
params: ITransactionParameters & {
scriptElements: (Uint8Array | number)[];
witnesses: Uint8Array[];
annex?: Uint8Array;
},
precomputed?: Partial<PrecomputedData>,
): string {
const state = TransactionStateCapture.fromCustomScript(params, precomputed);
return TransactionSerializer.toBase64(state);
}
/**
* Export a CancelTransaction for offline signing
* @param params - Cancel transaction parameters
* @param precomputed - Optional precomputed data
* @returns Base64-encoded serialized state
*/
public static exportCancel(
params: ITransactionParameters & {
compiledTargetScript: Uint8Array | string;
},
precomputed?: Partial<PrecomputedData>,
): string {
const state = TransactionStateCapture.fromCancel(params, precomputed);
return TransactionSerializer.toBase64(state);
}
/**
* Export transaction state from a builder instance.
* The builder must have been built but not yet signed.
* @param builder - Transaction builder instance
* @param params - Original construction parameters
* @param precomputed - Precomputed data from the builder
* @returns Base64-encoded serialized state
*/
public static exportFromBuilder<T extends TransactionType>(
builder: TransactionBuilder<T>,
params: ITransactionParameters,
precomputed?: Partial<PrecomputedData>,
): string {
const type = builder.type;
let state: ISerializableTransactionState;
switch (type) {
case TransactionType.FUNDING:
state = TransactionStateCapture.fromFunding(
params as IFundingTransactionParameters,
precomputed,
);
break;
case TransactionType.DEPLOYMENT:
state = TransactionStateCapture.fromDeployment(
params as IDeploymentParameters,
precomputed as Partial<PrecomputedData> & {
compiledTargetScript: string;
randomBytes: string;
},
);
break;
case TransactionType.INTERACTION:
state = TransactionStateCapture.fromInteraction(
params as IInteractionParameters,
precomputed as Partial<PrecomputedData> & {
compiledTargetScript: string;
randomBytes: string;
},
);
break;
default:
throw new Error(`Unsupported transaction type for export: ${type}`);
}
return TransactionSerializer.toBase64(state);
}
/**
* Import and reconstruct transaction for signing
* @param serializedState - Base64-encoded state from Phase 1
* @param options - Signer(s) and optional fee overrides
* @returns Reconstructed transaction builder ready for signing
*/
public static importForSigning(
serializedState: string,
options: ReconstructionOptions,
): TransactionBuilder<TransactionType> {
const state = TransactionSerializer.fromBase64(serializedState);
return TransactionReconstructor.reconstruct(state, options);
}
/**
* Complete signing and export signed transaction
* @param builder - Reconstructed builder from importForSigning
* @returns Signed transaction hex ready for broadcast
*/
public static async signAndExport(
builder: TransactionBuilder<TransactionType>,
): Promise<string> {
const tx = await builder.signTransaction();
return tx.toHex();
}
/**
* Convenience: Full Phase 2 in one call - import, sign, and export
* @param serializedState - Base64-encoded state
* @param options - Signer(s) and optional fee overrides
* @returns Signed transaction hex ready for broadcast
*/
public static async importSignAndExport(
serializedState: string,
options: ReconstructionOptions,
): Promise<string> {
const builder = this.importForSigning(serializedState, options);
return this.signAndExport(builder);
}
/**
* Rebuild transaction with new fee rate (fee bumping)
* @param serializedState - Original state
* @param newFeeRate - New fee rate in sat/vB
* @returns New serialized state with updated fees (not signed yet)
*/
public static rebuildWithNewFees(serializedState: string, newFeeRate: number): string {
// Parse the existing state
const state = TransactionSerializer.fromBase64(serializedState);
// Create a new state with updated fee rate
const newState: ISerializableTransactionState = {
...state,
baseParams: {
...state.baseParams,
feeRate: newFeeRate,
},
};
return TransactionSerializer.toBase64(newState);
}
/**
* Rebuild and immediately sign with new fee rate
* @param serializedState - Original state
* @param newFeeRate - New fee rate in sat/vB
* @param options - Signer options
* @returns Signed transaction hex with new fees
*/
public static async rebuildSignAndExport(
serializedState: string,
newFeeRate: number,
options: ReconstructionOptions,
): Promise<string> {
const builder = this.importForSigning(serializedState, {
...options,
newFeeRate,
});
return this.signAndExport(builder);
}
/**
* Inspect serialized state without signing
* @param serializedState - Base64-encoded state
* @returns Parsed state object for inspection
*/
public static inspect(serializedState: string): ISerializableTransactionState {
return TransactionSerializer.fromBase64(serializedState);
}
/**
* Validate serialized state integrity
* @param serializedState - Base64-encoded state
* @returns True if checksum and format are valid
*/
public static validate(serializedState: string): boolean {
try {
TransactionSerializer.fromBase64(serializedState);
return true;
} catch {
return false;
}
}
/**
* Get transaction type from serialized state
* @param serializedState - Base64-encoded state
* @returns Transaction type enum value
*/
public static getType(serializedState: string): TransactionType {
const state = TransactionSerializer.fromBase64(serializedState);
return state.header.transactionType;
}
/**
* Parse base64-encoded state into state object
* @param base64State - Base64-encoded state
* @returns Parsed state object
*/
public static fromBase64(base64State: string): ISerializableTransactionState {
return TransactionSerializer.fromBase64(base64State);
}
/**
* Serialize state object to base64
* @param state - State object to serialize
* @returns Base64-encoded state
*/
public static toBase64(state: ISerializableTransactionState): string {
return TransactionSerializer.toBase64(state);
}
/**
* Convert serialized state to hex format
* @param serializedState - Base64-encoded state
* @returns Hex-encoded state
*/
public static toHex(serializedState: string): string {
const state = TransactionSerializer.fromBase64(serializedState);
return TransactionSerializer.toHex(state);
}
/**
* Convert hex format back to base64
* @param hexState - Hex-encoded state
* @returns Base64-encoded state
*/
public static fromHex(hexState: string): string {
const state = TransactionSerializer.fromHex(hexState);
return TransactionSerializer.toBase64(state);
}
/**
* Add a partial signature to a multisig transaction state.
* This method signs the transaction with the provided signer and returns
* updated state with the new signature included.
*
* @param serializedState - Base64-encoded multisig state
* @param signer - The signer to add a signature with
* @returns Updated state with new signature, and signing result
*/
public static async multiSigAddSignature(
serializedState: string,
signer: Signer | UniversalSigner,
): Promise<{
state: string;
signed: boolean;
final: boolean;
psbtBase64: string;
}> {
const state = TransactionSerializer.fromBase64(serializedState);
if (!isMultiSigSpecificData(state.typeSpecificData)) {
throw new Error('State is not a multisig transaction');
}
const typeData = state.typeSpecificData;
const pubkeys = typeData.pubkeys.map((pk) => fromHex(pk));
// Parse existing PSBT or create new one
let psbt: Psbt;
const network = TransactionReconstructor['nameToNetwork'](state.baseParams.networkName);
if (typeData.existingPsbtBase64) {
psbt = Psbt.fromBase64(typeData.existingPsbtBase64, { network });
} else {
// Need to build the transaction first
const builder = this.importForSigning(serializedState, {
signer,
}) as MultiSignTransaction;
psbt = await builder.signPSBT();
}
// Calculate minimums array for each input
const minimums: number[] = [];
for (let i = typeData.originalInputCount; i < psbt.data.inputs.length; i++) {
minimums.push(typeData.minimumSignatures);
}
// Sign the PSBT
const result = MultiSignTransaction.signPartial(
psbt,
signer,
typeData.originalInputCount,
minimums,
);
// Finalize inputs (partial finalization to preserve signatures)
const orderedPubKeys: Uint8Array[][] = [];
for (let i = typeData.originalInputCount; i < psbt.data.inputs.length; i++) {
orderedPubKeys.push(pubkeys);
}
MultiSignTransaction.attemptFinalizeInputs(
psbt,
typeData.originalInputCount,
orderedPubKeys,
result.final,
);
const newPsbtBase64 = psbt.toBase64();
// Update the state with new PSBT
const newState: ISerializableTransactionState = {
...state,
typeSpecificData: {
...typeData,
existingPsbtBase64: newPsbtBase64,
},
};
return {
state: TransactionSerializer.toBase64(newState),
signed: result.signed,
final: result.final,
psbtBase64: newPsbtBase64,
};
}
/**
* Check if a public key has already signed a multisig transaction
*
* @param serializedState - Base64-encoded multisig state
* @param signerPubKey - Public key to check (Uint8Array or hex string)
* @returns True if the public key has already signed
*/
public static multiSigHasSigned(
serializedState: string,
signerPubKey: Uint8Array | string,
): boolean {
const state = TransactionSerializer.fromBase64(serializedState);
if (!isMultiSigSpecificData(state.typeSpecificData)) {
throw new Error('State is not a multisig transaction');
}
const typeData = state.typeSpecificData;
if (!typeData.existingPsbtBase64) {
return false;
}
const network = TransactionReconstructor['nameToNetwork'](state.baseParams.networkName);
const psbt = Psbt.fromBase64(typeData.existingPsbtBase64, { network });
const pubKeyBuffer =
signerPubKey instanceof Uint8Array ? signerPubKey : fromHex(signerPubKey);
return MultiSignTransaction.verifyIfSigned(psbt, pubKeyBuffer);
}
/**
* Get the current signature count for a multisig transaction
*
* @param serializedState - Base64-encoded multisig state
* @returns Object with signature count info
*/
public static multiSigGetSignatureStatus(serializedState: string): {
required: number;
collected: number;
isComplete: boolean;
signers: string[];
} {
const state = TransactionSerializer.fromBase64(serializedState);
if (!isMultiSigSpecificData(state.typeSpecificData)) {
throw new Error('State is not a multisig transaction');
}
const typeData = state.typeSpecificData;
const required = typeData.minimumSignatures;
if (!typeData.existingPsbtBase64) {
return {
required,
collected: 0,
isComplete: false,
signers: [],
};
}
const network = TransactionReconstructor['nameToNetwork'](state.baseParams.networkName);
const psbt = Psbt.fromBase64(typeData.existingPsbtBase64, { network });
// Collect signers from all inputs
const signerSet = new Set<string>();
for (let i = typeData.originalInputCount; i < psbt.data.inputs.length; i++) {
const input = psbt.data.inputs[i] as PsbtInput;
if (input.tapScriptSig) {
for (const sig of input.tapScriptSig) {
signerSet.add(toHex(sig.pubkey));
}
}
if (input.finalScriptWitness) {
const decoded = TransactionBuilder.readScriptWitnessToWitnessStack(
input.finalScriptWitness,
);
for (let j = 0; j < decoded.length - 2; j += 3) {
const pubKey = decoded[j + 2] as Uint8Array;
signerSet.add(toHex(pubKey));
}
}
}
const signers = Array.from(signerSet);
return {
required,
collected: signers.length,
isComplete: signers.length >= required,
signers,
};
}
/**
* Finalize a multisig transaction and extract the signed transaction hex.
* Only call this when all required signatures have been collected.
*
* @param serializedState - Base64-encoded multisig state with all signatures
* @returns Signed transaction hex ready for broadcast
*/
public static multiSigFinalize(serializedState: string): string {
const state = TransactionSerializer.fromBase64(serializedState);
if (!isMultiSigSpecificData(state.typeSpecificData)) {
throw new Error('State is not a multisig transaction');
}
const typeData = state.typeSpecificData;
if (!typeData.existingPsbtBase64) {
throw new Error('No PSBT found in state - transaction has not been signed');
}
const network = TransactionReconstructor['nameToNetwork'](state.baseParams.networkName);
const psbt = Psbt.fromBase64(typeData.existingPsbtBase64, { network });
const pubkeys = typeData.pubkeys.map((pk) => fromHex(pk));
const orderedPubKeys: Uint8Array[][] = [];
for (let i = typeData.originalInputCount; i < psbt.data.inputs.length; i++) {
orderedPubKeys.push(pubkeys);
}
// Final finalization
const success = MultiSignTransaction.attemptFinalizeInputs(
psbt,
typeData.originalInputCount,
orderedPubKeys,
true, // isFinal = true
);
if (!success) {
throw new Error('Failed to finalize multisig transaction - not enough signatures');
}
return psbt.extractTransaction(true, true).toHex();
}
/**
* Get the PSBT from a multisig state (for external signing tools)
*
* @param serializedState - Base64-encoded multisig state
* @returns PSBT in base64 format, or null if not yet built
*/
public static multiSigGetPsbt(serializedState: string): string | null {
const state = TransactionSerializer.fromBase64(serializedState);
if (!isMultiSigSpecificData(state.typeSpecificData)) {
throw new Error('State is not a multisig transaction');
}
return state.typeSpecificData.existingPsbtBase64 || null;
}
/**
* Update the PSBT in a multisig state (after external signing)
*
* @param serializedState - Base64-encoded multisig state
* @param psbtBase64 - New PSBT with additional signatures
* @returns Updated state
*/
public static multiSigUpdatePsbt(serializedState: string, psbtBase64: string): string {
const state = TransactionSerializer.fromBase64(serializedState);
if (!isMultiSigSpecificData(state.typeSpecificData)) {
throw new Error('State is not a multisig transaction');
}
const newState: ISerializableTransactionState = {
...state,
typeSpecificData: {
...state.typeSpecificData,
existingPsbtBase64: psbtBase64,
},
};
return TransactionSerializer.toBase64(newState);
}
}