@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
1,016 lines (876 loc) • 38.7 kB
text/typescript
import {
type PsbtOutputExtended,
type Script,
toHex,
toSatoshi,
Transaction,
type TxOutput,
} from '@btc-vision/bitcoin';
import type { UTXO } from '../utxo/interfaces/IUTXO.js';
import { CustomScriptTransaction } from './builders/CustomScriptTransaction.js';
import { DeploymentTransaction } from './builders/DeploymentTransaction.js';
import { FundingTransaction } from './builders/FundingTransaction.js';
import { InteractionTransaction } from './builders/InteractionTransaction.js';
import { TransactionBuilder } from './builders/TransactionBuilder.js';
import { TransactionType } from './enums/TransactionType.js';
import type {
IDeploymentParameters,
IFundingTransactionParameters,
IInteractionParameters,
ITransactionParameters,
} from './interfaces/ITransactionParameters.js';
import type {
ICancelTransactionParametersWithoutSigner,
ICustomTransactionWithoutSigner,
IDeploymentParametersWithoutSigner,
IFundingTransactionParametersWithoutSigner,
InteractionParametersWithoutSigner,
} from './interfaces/IWeb3ProviderTypes.js';
import type { WindowWithWallets } from './browser/extensions/UnisatSigner.js';
import type { IChallengeSolution, RawChallenge } from '../epoch/interfaces/IChallengeSolution.js';
import { P2WDADetector } from '../p2wda/P2WDADetector.js';
import { InteractionTransactionP2WDA } from './builders/InteractionTransactionP2WDA.js';
import { Address } from '../keypair/Address.js';
import { BitcoinUtils } from '../utils/BitcoinUtils.js';
import { CancelTransaction } from './builders/CancelTransaction.js';
import { ConsolidatedInteractionTransaction } from './builders/ConsolidatedInteractionTransaction.js';
import type { IConsolidatedInteractionParameters } from './interfaces/IConsolidatedTransactionParameters.js';
import type {
BitcoinTransferBase,
CancelledTransaction,
DeploymentResult,
InteractionResponse,
} from './interfaces/ITransactionResponses.js';
import type { ICancelTransactionParameters } from './interfaces/ICancelTransactionParameters.js';
import type { ICustomTransactionParameters } from './interfaces/ICustomTransactionParameters.js';
export interface FundingTransactionResponse {
readonly tx: Transaction;
readonly original: FundingTransaction;
readonly estimatedFees: bigint;
readonly nextUTXOs: UTXO[];
readonly inputUtxos: UTXO[];
}
export type { BitcoinTransferBase } from './interfaces/ITransactionResponses.js';
export interface BitcoinTransferResponse extends BitcoinTransferBase {
readonly original: FundingTransaction;
}
/**
* Response from signConsolidatedInteraction.
* Contains both setup and reveal transactions for the CHCT system.
*/
export interface ConsolidatedInteractionResponse {
/** Setup transaction hex - creates P2WSH commitment outputs */
readonly setupTransaction: string;
/** Reveal transaction hex - spends commitments, reveals data in witnesses */
readonly revealTransaction: string;
/** Setup transaction ID */
readonly setupTxId: string;
/** Reveal transaction ID */
readonly revealTxId: string;
/** Total fees across both transactions in satoshis */
readonly totalFees: bigint;
/** Number of data chunks */
readonly chunkCount: number;
/** Total compiled data size in bytes */
readonly dataSize: number;
/** Challenge for the interaction */
readonly challenge: RawChallenge;
/** Input UTXOs used */
readonly inputUtxos: UTXO[];
/** Compiled target script (same as InteractionTransaction) */
readonly compiledTargetScript: string;
}
export class TransactionFactory {
public debug: boolean = false;
private readonly DUMMY_PUBKEY = new Uint8Array(32).fill(1);
private readonly P2TR_SCRIPT = Uint8Array.from([0x51, 0x20, ...this.DUMMY_PUBKEY]) as Script;
private readonly INITIAL_FUNDING_ESTIMATE = 2000n;
private readonly MAX_ITERATIONS = 10;
/**
* @description Creates a cancellable transaction.
* @param {ICancelTransactionParameters | ICancelTransactionParametersWithoutSigner} params - The cancel transaction parameters
* @returns {Promise<CancelledTransaction>} - The cancelled transaction result
*/
public async createCancellableTransaction(
params: ICancelTransactionParameters | ICancelTransactionParametersWithoutSigner,
): Promise<CancelledTransaction> {
if (!params.to) {
throw new Error('Field "to" not provided.');
}
if (!params.from) {
throw new Error('Field "from" not provided.');
}
if (!params.utxos[0]) {
throw new Error('Missing at least one UTXO.');
}
const opWalletCancel = await this.detectCancelOPWallet(params);
if (opWalletCancel) {
return opWalletCancel;
}
if (!('signer' in params)) {
throw new Error('Field "signer" not provided, OP_WALLET not detected.');
}
const cancel = new CancelTransaction(params);
const signed = await cancel.signTransaction();
const rawTx = signed.toHex();
return {
transaction: rawTx,
nextUTXOs: this.getUTXOAsTransaction(signed, params.from, 0),
inputUtxos: params.utxos,
};
}
/**
* @description Generate a transaction with a custom script.
* @param {ICustomTransactionParameters | ICustomTransactionWithoutSigner} interactionParameters - The custom transaction parameters
* @returns {Promise<[string, string, UTXO[], UTXO[]]>} - The signed transaction tuple [fundingTx, customTx, nextUTXOs, inputUtxos]
*/
public async createCustomScriptTransaction(
interactionParameters: ICustomTransactionParameters | ICustomTransactionWithoutSigner,
): Promise<[string, string, UTXO[], UTXO[]]> {
if (!interactionParameters.to) {
throw new Error('Field "to" not provided.');
}
if (!interactionParameters.from) {
throw new Error('Field "from" not provided.');
}
if (!interactionParameters.utxos[0]) {
throw new Error('Missing at least one UTXO.');
}
if (!('signer' in interactionParameters)) {
throw new Error('Field "signer" not provided, OP_WALLET not detected.');
}
const inputs = this.parseOptionalInputs(interactionParameters.optionalInputs);
const { finalTransaction, estimatedAmount } = await this.iterateFundingAmount(
{ ...interactionParameters, optionalInputs: inputs },
CustomScriptTransaction,
async (tx) => {
const fee = await tx.estimateTransactionFees();
const priorityFee = this.getPriorityFee(interactionParameters);
const optionalValue = tx.getOptionalOutputValue();
return fee + priorityFee + optionalValue;
},
'CustomScript',
);
const parameters: IFundingTransactionParameters =
await finalTransaction.getFundingTransactionParameters();
parameters.utxos = interactionParameters.utxos;
parameters.amount = estimatedAmount;
const feeEstimationFunding = await this.createFundTransaction({
...parameters,
optionalOutputs: [],
optionalInputs: [],
});
if (!feeEstimationFunding) {
throw new Error('Could not sign funding transaction.');
}
parameters.estimatedFees = feeEstimationFunding.estimatedFees;
const signedTransaction = await this.createFundTransaction({
...parameters,
optionalOutputs: [],
optionalInputs: [],
});
if (!signedTransaction) {
throw new Error('Could not sign funding transaction.');
}
const newParams: ICustomTransactionParameters = {
...interactionParameters,
utxos: this.getUTXOAsTransaction(signedTransaction.tx, interactionParameters.to, 0),
randomBytes: finalTransaction.getRndBytes(),
nonWitnessUtxo: signedTransaction.tx.toBuffer(),
estimatedFees: finalTransaction.estimatedFees,
compiledTargetScript: finalTransaction.exportCompiledTargetScript(),
optionalInputs: inputs,
};
const customTransaction = new CustomScriptTransaction(newParams);
const outTx = await customTransaction.signTransaction();
return [
signedTransaction.tx.toHex(),
outTx.toHex(),
this.getUTXOAsTransaction(signedTransaction.tx, interactionParameters.from, 1),
interactionParameters.utxos,
];
}
/**
* @description Generates the required transactions.
* @param {IInteractionParameters | InteractionParametersWithoutSigner} interactionParameters - The interaction parameters
* @returns {Promise<InteractionResponse>} - The signed transaction
*/
public async signInteraction(
interactionParameters: IInteractionParameters | InteractionParametersWithoutSigner,
): Promise<InteractionResponse> {
if (!interactionParameters.to) {
throw new Error('Field "to" not provided.');
}
if (!interactionParameters.from) {
throw new Error('Field "from" not provided.');
}
if (!interactionParameters.utxos[0]) {
throw new Error('Missing at least one UTXO.');
}
const opWalletInteraction = await this.detectInteractionOPWallet(interactionParameters);
if (opWalletInteraction) {
return opWalletInteraction;
}
if (!('signer' in interactionParameters)) {
throw new Error('Field "signer" not provided, OP_WALLET not detected.');
}
const useP2WDA = this.hasP2WDAInputs(interactionParameters.utxos);
if (useP2WDA) {
return this.signP2WDAInteraction(interactionParameters);
}
const inputs = this.parseOptionalInputs(interactionParameters.optionalInputs);
const { finalTransaction, estimatedAmount, challenge } = await this.iterateFundingAmount(
{ ...interactionParameters, optionalInputs: inputs },
InteractionTransaction,
async (tx) => {
const fee = await tx.estimateTransactionFees();
const outputsValue = tx.getTotalOutputValue();
const total = fee + outputsValue;
if (
interactionParameters.subtractExtraUTXOFromAmountRequired &&
interactionParameters.optionalInputs &&
interactionParameters.optionalInputs.length > 0
) {
const optionalInputValue = interactionParameters.optionalInputs.reduce(
(sum, input) => sum + (input.value satisfies bigint),
0n,
);
const adjusted = total > optionalInputValue ? total - optionalInputValue : 0n;
return adjusted < TransactionBuilder.MINIMUM_DUST
? TransactionBuilder.MINIMUM_DUST
: adjusted;
}
return total;
},
'Interaction',
);
if (!challenge) {
throw new Error('Failed to get challenge from interaction transaction');
}
const parameters: IFundingTransactionParameters =
await finalTransaction.getFundingTransactionParameters();
parameters.utxos = interactionParameters.utxos;
parameters.amount = estimatedAmount;
const feeEstimationFunding = await this.createFundTransaction({
...parameters,
optionalOutputs: [],
optionalInputs: [],
});
if (!feeEstimationFunding) {
throw new Error('Could not sign funding transaction.');
}
parameters.estimatedFees = feeEstimationFunding.estimatedFees;
const signedTransaction = await this.createFundTransaction({
...parameters,
optionalOutputs: [],
optionalInputs: [],
});
if (!signedTransaction) {
throw new Error('Could not sign funding transaction.');
}
const fundingUTXO = this.getUTXOAsTransaction(
signedTransaction.tx,
finalTransaction.getScriptAddress(),
0,
);
const newParams: IInteractionParameters = {
...interactionParameters,
utxos: fundingUTXO,
randomBytes: finalTransaction.getRndBytes(),
challenge: challenge,
compiledTargetScript: finalTransaction.exportCompiledTargetScript(),
nonWitnessUtxo: signedTransaction.tx.toBuffer(),
estimatedFees: finalTransaction.estimatedFees,
optionalInputs: inputs,
};
const interactionTx = new InteractionTransaction(newParams);
const outTx = await interactionTx.signTransaction();
return {
interactionAddress: finalTransaction.getScriptAddress(),
fundingTransaction: signedTransaction.tx.toHex(),
interactionTransaction: outTx.toHex(),
estimatedFees: interactionTx.transactionFee,
nextUTXOs: this.getUTXOAsTransaction(
signedTransaction.tx,
interactionParameters.from,
1,
),
challenge: challenge.toRaw(),
fundingUTXOs: [...fundingUTXO, ...inputs],
fundingInputUtxos: interactionParameters.utxos,
compiledTargetScript: toHex(interactionTx.exportCompiledTargetScript()),
};
}
/**
* @description Generates a consolidated interaction transaction (CHCT system).
*
* Drop-in replacement for signInteraction that bypasses BIP110/Bitcoin Knots censorship.
* Uses P2WSH with HASH160 commitments instead of Tapscript (which uses OP_IF and gets censored).
*
* Returns two transactions:
* - Setup: Creates P2WSH outputs with hash commitments to data chunks
* - Reveal: Spends those outputs, revealing data in witnesses
*
* Data integrity is consensus-enforced - if data is stripped/modified,
* HASH160(data) != committed_hash and the transaction is INVALID.
*
* @param {IConsolidatedInteractionParameters} interactionParameters - Same parameters as signInteraction
* @returns {Promise<ConsolidatedInteractionResponse>} - Both setup and reveal transactions
*/
public async signConsolidatedInteraction(
interactionParameters: IConsolidatedInteractionParameters,
): Promise<ConsolidatedInteractionResponse> {
if (!interactionParameters.to) {
throw new Error('Field "to" not provided.');
}
if (!interactionParameters.from) {
throw new Error('Field "from" not provided.');
}
if (!interactionParameters.utxos[0]) {
throw new Error('Missing at least one UTXO.');
}
if (!('signer' in interactionParameters)) {
throw new Error('Field "signer" not provided.');
}
if (!interactionParameters.challenge) {
throw new Error('Field "challenge" not provided.');
}
const inputs = this.parseOptionalInputs(interactionParameters.optionalInputs);
const consolidatedTx = new ConsolidatedInteractionTransaction({
...interactionParameters,
optionalInputs: inputs,
});
const result = await consolidatedTx.build();
return {
setupTransaction: result.setup.txHex,
revealTransaction: result.reveal.txHex,
setupTxId: result.setup.txId,
revealTxId: result.reveal.txId,
totalFees: result.totalFees,
chunkCount: result.setup.chunkCount,
dataSize: result.setup.totalDataSize,
challenge: consolidatedTx.getChallenge().toRaw(),
inputUtxos: interactionParameters.utxos,
compiledTargetScript: toHex(consolidatedTx.exportCompiledTargetScript()),
};
}
/**
* @description Generates the required transactions.
* @param {IDeploymentParameters} deploymentParameters - The deployment parameters
* @returns {Promise<DeploymentResult>} - The signed transaction
*/
public async signDeployment(
deploymentParameters: IDeploymentParameters,
): Promise<DeploymentResult> {
const opWalletDeployment = await this.detectDeploymentOPWallet(deploymentParameters);
if (opWalletDeployment) {
return opWalletDeployment;
}
if (!('signer' in deploymentParameters)) {
throw new Error('Field "signer" not provided, OP_WALLET not detected.');
}
const inputs = this.parseOptionalInputs(deploymentParameters.optionalInputs);
const { finalTransaction, estimatedAmount, challenge } = await this.iterateFundingAmount(
{ ...deploymentParameters, optionalInputs: inputs },
DeploymentTransaction,
async (tx) => {
const fee = await tx.estimateTransactionFees();
const priorityFee = this.getPriorityFee(deploymentParameters);
const optionalValue = tx.getOptionalOutputValue();
return fee + priorityFee + optionalValue;
},
'Deployment',
);
if (!challenge) {
throw new Error('Failed to get challenge from deployment transaction');
}
const parameters: IFundingTransactionParameters =
await finalTransaction.getFundingTransactionParameters();
parameters.utxos = deploymentParameters.utxos;
parameters.amount = estimatedAmount;
const feeEstimationFunding = await this.createFundTransaction({
...parameters,
optionalOutputs: [],
optionalInputs: [],
});
if (!feeEstimationFunding) {
throw new Error('Could not sign funding transaction.');
}
parameters.estimatedFees = feeEstimationFunding.estimatedFees;
const fundingTransaction = new FundingTransaction({
...parameters,
optionalInputs: [],
optionalOutputs: [],
});
const signedTransaction = await fundingTransaction.signTransaction();
if (!signedTransaction) {
throw new Error('Could not sign funding transaction.');
}
const out = signedTransaction.outs[0] as TxOutput;
const newUtxo: UTXO = {
transactionId: signedTransaction.getId(),
outputIndex: 0,
scriptPubKey: {
hex: toHex(out.script),
address: finalTransaction.getScriptAddress(),
},
value: BigInt(out.value),
};
const newParams: IDeploymentParameters = {
...deploymentParameters,
utxos: [newUtxo],
randomBytes: finalTransaction.getRndBytes(),
compiledTargetScript: finalTransaction.exportCompiledTargetScript(),
challenge: challenge,
nonWitnessUtxo: signedTransaction.toBuffer(),
estimatedFees: finalTransaction.estimatedFees,
optionalInputs: inputs,
};
const deploymentTx = new DeploymentTransaction(newParams);
const outTx = await deploymentTx.signTransaction();
const out2 = signedTransaction.outs[1] as TxOutput;
const refundUTXO: UTXO = {
transactionId: signedTransaction.getId(),
outputIndex: 1,
scriptPubKey: {
hex: toHex(out2.script),
address: deploymentParameters.from as string,
},
value: BigInt(out2.value),
};
return {
transaction: [signedTransaction.toHex(), outTx.toHex()],
contractAddress: deploymentTx.getContractAddress(),
contractPubKey: deploymentTx.contractPubKey,
utxos: [refundUTXO],
challenge: challenge.toRaw(),
inputUtxos: deploymentParameters.utxos,
};
}
/**
* @description Creates a funding transaction.
* @param {IFundingTransactionParameters} parameters - The funding transaction parameters
* @returns {Promise<BitcoinTransferBase>} - The signed transaction
*/
public async createBTCTransfer(
parameters: IFundingTransactionParameters | IFundingTransactionParametersWithoutSigner,
): Promise<BitcoinTransferBase> {
if (!parameters.to) {
throw new Error('Field "to" not provided.');
}
if (!parameters.from) {
throw new Error('Field "from" not provided.');
}
const opWalletInteraction = await this.detectFundingOPWallet(parameters);
if (opWalletInteraction) {
return opWalletInteraction;
}
if (!('signer' in parameters)) {
throw new Error('Field "signer" not provided, OP_WALLET not detected.');
}
const resp = await this.createFundTransaction(parameters);
return {
estimatedFees: resp.estimatedFees,
tx: resp.tx.toHex(),
nextUTXOs: this.getAllNewUTXOs(resp.original, resp.tx, parameters.from),
inputUtxos: parameters.utxos,
};
}
/**
* Get all new UTXOs of a generated transaction.
* @param {TransactionBuilder<TransactionType>} original - The original transaction
* @param {Transaction} tx - The transaction
* @param {string} to - The address to filter
* @returns {UTXO[]} - The new UTXOs belonging to the specified address
*/
public getAllNewUTXOs(
original: TransactionBuilder<TransactionType>,
tx: Transaction,
to: string,
): UTXO[] {
const outputs = original.getOutputs();
const utxos: UTXO[] = [];
for (let i = 0; i < tx.outs.length; i++) {
const output = outputs[i] as PsbtOutputExtended;
if ('address' in output) {
if (output.address !== to) continue;
} else {
continue;
}
utxos.push(...this.getUTXOAsTransaction(tx, to, i));
}
return utxos;
}
/**
* Parse optional inputs and normalize nonWitnessUtxo format.
* @param {UTXO[]} optionalInputs - The optional inputs to parse
* @returns {UTXO[]} - The parsed inputs with normalized nonWitnessUtxo
*/
private parseOptionalInputs(optionalInputs?: UTXO[]): UTXO[] {
return (optionalInputs || []).map((input) => {
let nonWitness = input.nonWitnessUtxo;
if (
nonWitness &&
!(nonWitness instanceof Uint8Array) &&
typeof nonWitness === 'object'
) {
nonWitness = Uint8Array.from(Object.values(nonWitness as Record<string, number>));
}
return {
...input,
nonWitnessUtxo: nonWitness,
} as UTXO;
});
}
/**
* Detect and use OP_WALLET for funding transactions if available.
*
* @param {IFundingTransactionParameters | IFundingTransactionParametersWithoutSigner} fundingParams - The funding transaction parameters
* @return {Promise<BitcoinTransferBase | null>} - The funding transaction response or null if OP_WALLET not available
*/
private async detectFundingOPWallet(
fundingParams: IFundingTransactionParameters | IFundingTransactionParametersWithoutSigner,
): Promise<BitcoinTransferBase | null> {
if (typeof window === 'undefined') {
return null;
}
const _window = window as WindowWithWallets;
if (!_window || !_window.opnet || !_window.opnet.web3) {
return null;
}
const opnet = _window.opnet.web3;
const result = await opnet.sendBitcoin({
...fundingParams,
// @ts-expect-error signer is stripped by the wallet
signer: undefined,
});
if (!result) {
throw new Error('Could not sign funding transaction.');
}
return {
...result,
inputUtxos: result.inputUtxos ?? fundingParams.utxos,
};
}
/**
* Detect and use OP_WALLET for cancel transactions if available.
* @param {ICancelTransactionParameters | ICancelTransactionParametersWithoutSigner} interactionParameters - The cancel parameters
* @returns {Promise<CancelledTransaction | null>} - The cancelled transaction or null if OP_WALLET not available
*/
private async detectCancelOPWallet(
interactionParameters:
| ICancelTransactionParameters
| ICancelTransactionParametersWithoutSigner,
): Promise<CancelledTransaction | null> {
if (typeof window === 'undefined') {
return null;
}
const _window = window as WindowWithWallets;
if (!_window || !_window.opnet || !_window.opnet.web3) {
return null;
}
const opnet = _window.opnet.web3;
const interaction = await opnet.cancelTransaction({
...interactionParameters,
// @ts-expect-error no, this is ok
signer: undefined,
});
if (!interaction) {
throw new Error('Could not sign interaction transaction.');
}
return {
...interaction,
inputUtxos: interaction.inputUtxos ?? interactionParameters.utxos,
};
}
/**
* Detect and use OP_WALLET for interaction transactions if available.
* @param {IInteractionParameters | InteractionParametersWithoutSigner} interactionParameters - The interaction parameters
* @returns {Promise<InteractionResponse | null>} - The interaction response or null if OP_WALLET not available
*/
private async detectInteractionOPWallet(
interactionParameters: IInteractionParameters | InteractionParametersWithoutSigner,
): Promise<InteractionResponse | null> {
if (typeof window === 'undefined') {
return null;
}
const _window = window as WindowWithWallets;
if (!_window || !_window.opnet || !_window.opnet.web3) {
return null;
}
const opnet = _window.opnet.web3;
const interaction = await opnet.signInteraction({
...interactionParameters,
// @ts-expect-error no, this is ok
signer: undefined,
});
if (!interaction) {
throw new Error('Could not sign interaction transaction.');
}
return {
...interaction,
fundingInputUtxos: interaction.fundingInputUtxos ?? interactionParameters.utxos,
};
}
/**
* Detect and use OP_WALLET for deployment transactions if available.
* @param {IDeploymentParameters | IDeploymentParametersWithoutSigner} deploymentParameters - The deployment parameters
* @returns {Promise<DeploymentResult | null>} - The deployment result or null if OP_WALLET not available
*/
private async detectDeploymentOPWallet(
deploymentParameters: IDeploymentParameters | IDeploymentParametersWithoutSigner,
): Promise<DeploymentResult | null> {
if (typeof window === 'undefined') {
return null;
}
const _window = window as WindowWithWallets;
if (!_window || !_window.opnet || !_window.opnet.web3) {
return null;
}
const opnet = _window.opnet.web3;
const deployment = await opnet.deployContract({
...deploymentParameters,
// @ts-expect-error no, this is ok
signer: undefined,
});
if (!deployment) {
throw new Error('Could not sign interaction transaction.');
}
return {
...deployment,
inputUtxos: deployment.inputUtxos ?? deploymentParameters.utxos,
};
}
/**
* Create and sign a funding transaction.
* @param {IFundingTransactionParameters} parameters - The funding transaction parameters
* @returns {Promise<FundingTransactionResponse>} - The funding transaction response
*/
private async createFundTransaction(
parameters: IFundingTransactionParameters,
): Promise<FundingTransactionResponse> {
if (!parameters.to) throw new Error('Field "to" not provided.');
const fundingTransaction: FundingTransaction = new FundingTransaction(parameters);
const signedTransaction: Transaction = await fundingTransaction.signTransaction();
if (!signedTransaction) {
throw new Error('Could not sign funding transaction.');
}
return {
tx: signedTransaction,
original: fundingTransaction,
estimatedFees: fundingTransaction.estimatedFees,
nextUTXOs: this.getUTXOAsTransaction(signedTransaction, parameters.to, 0),
inputUtxos: parameters.utxos,
};
}
/**
* Check if the UTXOs contain any P2WDA inputs
*
* This method examines both main UTXOs and optional inputs to determine
* if any of them are P2WDA addresses. P2WDA detection is based on the
* witness script pattern: (OP_2DROP * 5) <pubkey> OP_CHECKSIG
*
* @param {UTXO[]} utxos - The main UTXOs to check
* @returns {boolean} - true if any UTXO is P2WDA, false otherwise
*/
private hasP2WDAInputs(utxos: UTXO[]): boolean {
return utxos.some((utxo) => P2WDADetector.isP2WDAUTXO(utxo));
}
/**
* Sign a P2WDA interaction transaction
*
* P2WDA interactions are fundamentally different from standard OP_NET interactions.
* Instead of using a two-transaction model (funding + interaction), P2WDA embeds
* the operation data directly in the witness field of a single transaction.
* This achieves significant cost savings through the witness discount.
*
* Key differences:
* - Single transaction instead of two
* - Operation data in witness field instead of taproot script
* - 75% cost reduction for data storage
* - No separate funding transaction needed
*
* @param {IInteractionParameters | InteractionParametersWithoutSigner} interactionParameters - The interaction parameters
* @returns {Promise<InteractionResponse>} - The signed P2WDA interaction response
*/
private async signP2WDAInteraction(
interactionParameters: IInteractionParameters | InteractionParametersWithoutSigner,
): Promise<InteractionResponse> {
if (!interactionParameters.from) {
throw new Error('Field "from" not provided.');
}
if (!('signer' in interactionParameters)) {
throw new Error(
'P2WDA interactions require a signer. OP_WALLET is not supported for P2WDA.',
);
}
const inputs = this.parseOptionalInputs(interactionParameters.optionalInputs);
const p2wdaTransaction = new InteractionTransactionP2WDA({
...interactionParameters,
optionalInputs: inputs,
});
const signedTx = await p2wdaTransaction.signTransaction();
const txHex = signedTx.toHex();
return {
interactionAddress: null,
fundingTransaction: null,
interactionTransaction: txHex,
estimatedFees: p2wdaTransaction.estimatedFees,
nextUTXOs: this.getUTXOAsTransaction(
signedTx,
interactionParameters.from,
signedTx.outs.length - 1,
),
fundingUTXOs: [...interactionParameters.utxos, ...inputs],
fundingInputUtxos: interactionParameters.utxos,
challenge: interactionParameters.challenge.toRaw(),
compiledTargetScript: null,
};
}
/**
* Get the priority fee from transaction parameters.
* @param {ITransactionParameters} params - The transaction parameters
* @returns {bigint} - The priority fee, minimum dust if below threshold
*/
private getPriorityFee(params: ITransactionParameters): bigint {
const totalFee = params.priorityFee + params.gasSatFee;
if (totalFee < TransactionBuilder.MINIMUM_DUST) {
return TransactionBuilder.MINIMUM_DUST;
}
return totalFee;
}
/**
* Common iteration logic for finding the correct funding amount.
*
* This method iteratively estimates the required funding amount by simulating
* transactions until the amount converges or max iterations is reached.
*
* @param {P extends IInteractionParameters | IDeploymentParameters | ICustomTransactionParameters} params - The transaction parameters
* @param {new (params: P) => T} TransactionClass - The transaction class constructor
* @param {(tx: T extends InteractionTransaction | DeploymentTransaction | CustomScriptTransaction) => Promise<bigint>} calculateAmount - Function to calculate required amount
* @param {string} debugPrefix - Prefix for debug logging
* @returns {Promise<{finalTransaction: T extends InteractionTransaction | DeploymentTransaction | CustomScriptTransaction, estimatedAmount: bigint, challenge: IChallengeSolution | null}>} - The final transaction and estimated amount
*/
private async iterateFundingAmount<
T extends InteractionTransaction | DeploymentTransaction | CustomScriptTransaction,
P extends IInteractionParameters | IDeploymentParameters | ICustomTransactionParameters,
>(
params: P,
TransactionClass: new (params: P) => T,
calculateAmount: (tx: T) => Promise<bigint>,
debugPrefix: string,
): Promise<{
finalTransaction: T;
estimatedAmount: bigint;
challenge: IChallengeSolution | null;
}> {
const randomBytes =
'randomBytes' in params
? (params.randomBytes ?? BitcoinUtils.rndBytes())
: BitcoinUtils.rndBytes();
const dummyAddress = Address.dead().p2tr(params.network);
let estimatedFundingAmount = this.INITIAL_FUNDING_ESTIMATE;
let previousAmount = 0n;
let iterations = 0;
let finalPreTransaction: T | null = null;
let challenge: IChallengeSolution | null = null;
while (iterations < this.MAX_ITERATIONS && estimatedFundingAmount !== previousAmount) {
previousAmount = estimatedFundingAmount;
const dummyTx = new Transaction();
dummyTx.addOutput(this.P2TR_SCRIPT, toSatoshi(estimatedFundingAmount));
const simulatedFundedUtxo: UTXO = {
transactionId: toHex(new Uint8Array(32)),
outputIndex: 0,
scriptPubKey: {
hex: toHex(this.P2TR_SCRIPT),
address: dummyAddress,
},
value: estimatedFundingAmount,
nonWitnessUtxo: dummyTx.toBuffer(),
};
let txParams: P;
if ('challenge' in params && params.challenge) {
const withChallenge = {
...params,
utxos: [simulatedFundedUtxo],
randomBytes: randomBytes,
challenge: challenge ?? params.challenge,
};
txParams = withChallenge as P;
} else {
const withoutChallenge = {
...params,
utxos: [simulatedFundedUtxo],
randomBytes: randomBytes,
};
txParams = withoutChallenge as P;
}
const preTransaction: T = new TransactionClass(txParams);
try {
await preTransaction.generateTransactionMinimalSignatures();
estimatedFundingAmount = await calculateAmount(preTransaction);
} catch (error: unknown) {
if (error instanceof Error) {
const match = error.message.match(/need (\d+) sats but only have (\d+) sats/);
if (match) {
estimatedFundingAmount = BigInt(match[1] as string);
if (this.debug) {
console.log(
`${debugPrefix}: Caught insufficient funds, updating to ${estimatedFundingAmount}`,
);
}
} else {
throw error;
}
} else {
throw new Error('Unknown error during fee estimation', { cause: error });
}
}
finalPreTransaction = preTransaction;
if (
'getChallenge' in preTransaction &&
typeof preTransaction.getChallenge === 'function'
) {
challenge = preTransaction.getChallenge();
}
iterations++;
if (this.debug) {
console.log(
`${debugPrefix} Iteration ${iterations}: Previous=${previousAmount}, New=${estimatedFundingAmount}`,
);
}
}
if (!finalPreTransaction) {
throw new Error(`Failed to converge on ${debugPrefix} funding amount`);
}
if (estimatedFundingAmount === 0n) {
throw new Error(`Impossible. Transaction cant be free.`);
}
return {
finalTransaction: finalPreTransaction,
estimatedAmount: estimatedFundingAmount,
challenge,
};
}
/**
* Convert a transaction output to a UTXO.
* @param {Transaction} tx - The transaction
* @param {string} to - The address
* @param {number} index - The output index
* @returns {UTXO[]} - The UTXO array (empty if output doesn't exist)
*/
private getUTXOAsTransaction(tx: Transaction, to: string, index: number): UTXO[] {
if (!tx.outs[index]) return [];
const out: TxOutput = tx.outs[index];
const newUtxo: UTXO = {
transactionId: tx.getId(),
outputIndex: index,
scriptPubKey: {
hex: toHex(out.script),
address: to,
},
value: BigInt(out.value),
};
return [newUtxo];
}
}