@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
619 lines (521 loc) • 22.2 kB
text/typescript
import { Transaction, TxOutput } from '@btc-vision/bitcoin';
import { currentConsensus } from '../consensus/ConsensusConfig.js';
import { UTXO } from '../utxo/interfaces/IUTXO.js';
import {
CustomScriptTransaction,
ICustomTransactionParameters,
} 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 {
IDeploymentParameters,
IFundingTransactionParameters,
IInteractionParameters,
ITransactionParameters,
} from './interfaces/ITransactionParameters.js';
import { PSBTTypes } from './psbt/PSBTTypes.js';
import {
IDeploymentParametersWithoutSigner,
InteractionParametersWithoutSigner,
} from './browser/Web3Provider.js';
import { WindowWithWallets } from './browser/extensions/UnisatSigner.js';
import { RawChallenge } from '../epoch/interfaces/IChallengeSolution.js';
import { P2WDADetector } from '../p2wda/P2WDADetector.js';
import { InteractionTransactionP2WDA } from './builders/InteractionTransactionP2WDA.js';
export interface DeploymentResult {
readonly transaction: [string, string];
readonly contractAddress: string;
readonly contractPubKey: string;
readonly challenge: RawChallenge;
readonly utxos: UTXO[];
}
export interface FundingTransactionResponse {
readonly tx: Transaction;
readonly original: FundingTransaction;
readonly estimatedFees: bigint;
readonly nextUTXOs: UTXO[];
}
export interface BitcoinTransferBase {
readonly tx: string;
readonly estimatedFees: bigint;
readonly nextUTXOs: UTXO[];
}
export interface InteractionResponse {
readonly fundingTransaction: string | null;
readonly interactionTransaction: string;
readonly estimatedFees: bigint;
readonly nextUTXOs: UTXO[];
readonly challenge: RawChallenge;
}
export interface BitcoinTransferResponse extends BitcoinTransferBase {
readonly original: FundingTransaction;
}
export class TransactionFactory {
/**
* @description Generate a transaction with a custom script.
* @returns {Promise<[string, string]>} - The signed transaction
*/
public async createCustomScriptTransaction(
interactionParameters: ICustomTransactionParameters,
): Promise<[string, string, 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 preTransaction: CustomScriptTransaction = new CustomScriptTransaction({
...interactionParameters,
utxos: [interactionParameters.utxos[0]], // we simulate one input here.
optionalInputs: inputs,
});
// we don't sign that transaction, we just need the parameters.
await preTransaction.generateTransactionMinimalSignatures();
const parameters: IFundingTransactionParameters =
await preTransaction.getFundingTransactionParameters();
parameters.utxos = interactionParameters.utxos;
parameters.amount =
(await preTransaction.estimateTransactionFees()) +
this.getPriorityFee(interactionParameters) +
preTransaction.getOptionalOutputValue();
const feeEstimationFundingTransaction = await this.createFundTransaction({
...parameters,
optionalOutputs: [],
optionalInputs: [],
});
if (!feeEstimationFundingTransaction) {
throw new Error('Could not sign funding transaction.');
}
parameters.estimatedFees = feeEstimationFundingTransaction.estimatedFees;
const signedTransaction = await this.createFundTransaction({
...parameters,
optionalOutputs: [],
optionalInputs: [],
});
if (!signedTransaction) {
throw new Error('Could not sign funding transaction.');
}
interactionParameters.utxos = this.getUTXOAsTransaction(
signedTransaction.tx,
interactionParameters.to,
0,
);
const newParams: ICustomTransactionParameters = {
...interactionParameters,
utxos: [
...this.getUTXOAsTransaction(signedTransaction.tx, interactionParameters.to, 0),
], // always 0
randomBytes: preTransaction.getRndBytes(),
nonWitnessUtxo: signedTransaction.tx.toBuffer(),
estimatedFees: preTransaction.estimatedFees,
optionalInputs: inputs,
};
const finalTransaction: CustomScriptTransaction = new CustomScriptTransaction(newParams);
// We have to regenerate using the new utxo
const outTx: Transaction = await finalTransaction.signTransaction();
return [
signedTransaction.tx.toHex(),
outTx.toHex(),
this.getUTXOAsTransaction(signedTransaction.tx, interactionParameters.from, 1), // always 1
];
}
/**
* @description Generates the required transactions.
* @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.');
}
// If OP_WALLET is used...
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 preTransaction: InteractionTransaction = new InteractionTransaction({
...interactionParameters,
utxos: [interactionParameters.utxos[0]], // we simulate one input here.
optionalInputs: inputs,
});
// we don't sign that transaction, we just need the parameters.
await preTransaction.generateTransactionMinimalSignatures();
const parameters: IFundingTransactionParameters =
await preTransaction.getFundingTransactionParameters();
parameters.utxos = interactionParameters.utxos;
parameters.amount =
(await preTransaction.estimateTransactionFees()) +
this.getPriorityFee(interactionParameters) +
preTransaction.getOptionalOutputValue();
const feeEstimationFundingTransaction = await this.createFundTransaction({
...parameters,
optionalOutputs: [],
optionalInputs: [],
});
if (!feeEstimationFundingTransaction) {
throw new Error('Could not sign funding transaction.');
}
parameters.estimatedFees = feeEstimationFundingTransaction.estimatedFees;
const signedTransaction = await this.createFundTransaction({
...parameters,
optionalOutputs: [],
optionalInputs: [],
});
if (!signedTransaction) {
throw new Error('Could not sign funding transaction.');
}
interactionParameters.utxos = this.getUTXOAsTransaction(
signedTransaction.tx,
interactionParameters.to,
0,
);
const newParams: IInteractionParameters = {
...interactionParameters,
utxos: [
...this.getUTXOAsTransaction(signedTransaction.tx, interactionParameters.to, 0),
], // always 0
randomBytes: preTransaction.getRndBytes(),
challenge: preTransaction.getChallenge(),
nonWitnessUtxo: signedTransaction.tx.toBuffer(),
estimatedFees: preTransaction.estimatedFees,
optionalInputs: inputs,
};
const finalTransaction: InteractionTransaction = new InteractionTransaction(newParams);
// We have to regenerate using the new utxo
const outTx: Transaction = await finalTransaction.signTransaction();
return {
fundingTransaction: signedTransaction.tx.toHex(),
interactionTransaction: outTx.toHex(),
estimatedFees: preTransaction.estimatedFees,
nextUTXOs: this.getUTXOAsTransaction(
signedTransaction.tx,
interactionParameters.from,
1,
), // always 1
challenge: preTransaction.getChallenge().toRaw(),
};
}
/**
* @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 preTransaction: DeploymentTransaction = new DeploymentTransaction({
...deploymentParameters,
utxos: [deploymentParameters.utxos[0]], // we simulate one input here.
optionalInputs: inputs,
});
// we don't sign that transaction, we just need the parameters.
await preTransaction.generateTransactionMinimalSignatures();
const parameters: IFundingTransactionParameters =
await preTransaction.getFundingTransactionParameters();
parameters.utxos = deploymentParameters.utxos;
parameters.amount =
(await preTransaction.estimateTransactionFees()) +
this.getPriorityFee(deploymentParameters) +
preTransaction.getOptionalOutputValue();
const feeEstimationFundingTransaction = await this.createFundTransaction({
...parameters,
optionalOutputs: [],
optionalInputs: [],
});
if (!feeEstimationFundingTransaction) {
throw new Error('Could not sign funding transaction.');
}
parameters.estimatedFees = feeEstimationFundingTransaction.estimatedFees;
const fundingTransaction: FundingTransaction = new FundingTransaction({
...parameters,
optionalInputs: [],
optionalOutputs: [],
});
const signedTransaction: Transaction = await fundingTransaction.signTransaction();
if (!signedTransaction) {
throw new Error('Could not sign funding transaction.');
}
const out: TxOutput = signedTransaction.outs[0];
const newUtxo: UTXO = {
transactionId: signedTransaction.getId(),
outputIndex: 0, // always 0
scriptPubKey: {
hex: out.script.toString('hex'),
address: preTransaction.getScriptAddress(),
},
value: BigInt(out.value),
};
const newParams: IDeploymentParameters = {
...deploymentParameters,
utxos: [newUtxo], // always 0
randomBytes: preTransaction.getRndBytes(),
challenge: preTransaction.getChallenge(),
nonWitnessUtxo: signedTransaction.toBuffer(),
estimatedFees: preTransaction.estimatedFees,
optionalInputs: inputs,
};
const finalTransaction: DeploymentTransaction = new DeploymentTransaction(newParams);
// We have to regenerate using the new utxo
const outTx: Transaction = await finalTransaction.signTransaction();
const out2: TxOutput = signedTransaction.outs[1];
const refundUTXO: UTXO = {
transactionId: signedTransaction.getId(),
outputIndex: 1, // always 1
scriptPubKey: {
hex: out2.script.toString('hex'),
address: deploymentParameters.from,
},
value: BigInt(out2.value),
};
return {
transaction: [signedTransaction.toHex(), outTx.toHex()],
contractAddress: finalTransaction.getContractAddress(), //finalTransaction.contractAddress.p2tr(deploymentParameters.network),
contractPubKey: finalTransaction.contractPubKey,
utxos: [refundUTXO],
challenge: preTransaction.getChallenge().toRaw(),
};
}
/**
* @description Creates a funding transaction.
* @param {IFundingTransactionParameters} parameters - The funding transaction parameters
* @returns {Promise<{ estimatedFees: bigint; tx: string }>} - The signed transaction
*/
public async createBTCTransfer(
parameters: IFundingTransactionParameters,
): Promise<BitcoinTransferResponse> {
if (!parameters.from) {
throw new Error('Field "from" not provided.');
}
const resp = await this.createFundTransaction(parameters);
return {
estimatedFees: resp.estimatedFees,
original: resp.original,
tx: resp.tx.toHex(),
nextUTXOs: this.getAllNewUTXOs(resp.original, resp.tx, parameters.from),
};
}
/**
* 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
*/
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];
if ('address' in output) {
if (output.address !== to) continue;
} else {
continue;
}
utxos.push(...this.getUTXOAsTransaction(tx, to, i));
}
return utxos;
}
private parseOptionalInputs(optionalInputs?: UTXO[]): UTXO[] {
return (optionalInputs || []).map((input) => {
let nonWitness = input.nonWitnessUtxo;
if (
nonWitness &&
!(nonWitness instanceof Uint8Array) &&
typeof nonWitness === 'object'
) {
nonWitness = Buffer.from(
Uint8Array.from(
Object.values(input.nonWitnessUtxo as unknown as Record<number, number>),
),
);
}
return {
...input,
nonWitnessUtxo: nonWitness,
};
});
}
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;
}
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;
}
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),
};
}
/**
* 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 utxos The main UTXOs to check
* @returns true if any UTXO is P2WDA, false otherwise
*/
private hasP2WDAInputs(utxos: UTXO[]): boolean {
return utxos.some((utxo) => P2WDADetector.isP2WDAUTXO(utxo));
}
private writePSBTHeader(type: PSBTTypes, psbt: string): string {
const buf = Buffer.from(psbt, 'base64');
const header = Buffer.alloc(2);
header.writeUInt8(type, 0);
header.writeUInt8(currentConsensus, 1);
return Buffer.concat([header, buf]).toString('hex');
}
/**
* 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 interactionParameters The interaction parameters
* @returns The signed P2WDA interaction response
*/
private async signP2WDAInteraction(
interactionParameters: IInteractionParameters | InteractionParametersWithoutSigner,
): Promise<InteractionResponse> {
if (!interactionParameters.from) {
throw new Error('Field "from" not provided.');
}
// Ensure we have a signer for P2WDA
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 {
fundingTransaction: null,
interactionTransaction: txHex,
estimatedFees: p2wdaTransaction.estimatedFees,
nextUTXOs: this.getUTXOAsTransaction(
signedTx,
interactionParameters.from,
signedTx.outs.length - 1, // Last output is typically the change
),
challenge: interactionParameters.challenge.toRaw(),
};
}
private getPriorityFee(params: ITransactionParameters): bigint {
const totalFee = params.priorityFee + params.gasSatFee;
if (totalFee < TransactionBuilder.MINIMUM_DUST) {
return TransactionBuilder.MINIMUM_DUST;
}
return totalFee;
}
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: out.script.toString('hex'),
address: to,
},
value: BigInt(out.value),
};
return [newUtxo];
}
}