UNPKG

@btc-vision/transaction

Version:

OPNet transaction library allows you to create and sign transactions for the OPNet network.

1,281 lines (1,085 loc) 41.7 kB
import type { Script } from '@btc-vision/bitcoin'; import bitcoin, { equals, fromHex, getFinalScripts, type Network, opcodes, Psbt, type PsbtInputExtended, type PsbtOutputExtended, script, type Signer, toSatoshi, toXOnly, Transaction, } from '@btc-vision/bitcoin'; import { witnessStackToScriptWitness } from '../utils/WitnessUtils.js'; import type { UpdateInput } from '../interfaces/Tap.js'; import { TransactionType } from '../enums/TransactionType.js'; import type { IFundingTransactionParameters, ITransactionParameters, } from '../interfaces/ITransactionParameters.js'; import { EcKeyPair } from '../../keypair/EcKeyPair.js'; import type { UTXO } from '../../utxo/interfaces/IUTXO.js'; import { type UniversalSigner } from '@btc-vision/ecpair'; import { AddressVerificator } from '../../keypair/AddressVerificator.js'; import { TweakedTransaction } from '../shared/TweakedTransaction.js'; import { UnisatSigner } from '../browser/extensions/UnisatSigner.js'; import type { IP2WSHAddress } from '../mineable/IP2WSHAddress.js'; import { P2WDADetector } from '../../p2wda/P2WDADetector.js'; import { type Feature, FeaturePriority, Features, type MLDSALinkRequest, } from '../../generators/Features.js'; import { BITCOIN_PROTOCOL_ID, getChainId } from '../../chain/ChainData.js'; import { BinaryWriter } from '../../buffer/BinaryWriter.js'; import { MLDSASecurityLevel } from '@btc-vision/bip32'; import { MessageSigner } from '../../keypair/MessageSigner.js'; import { getLevelFromPublicKeyLength } from '../../generators/MLDSAData.js'; export const MINIMUM_AMOUNT_REWARD: bigint = 330n; //540n; export const MINIMUM_AMOUNT_CA: bigint = 297n; export const ANCHOR_SCRIPT = fromHex('51024e73'); /** * Allows to build a transaction like you would on Ethereum. * @description The transaction builder class * @abstract * @class TransactionBuilder */ export abstract class TransactionBuilder<T extends TransactionType> extends TweakedTransaction { public static readonly MINIMUM_DUST: bigint = 330n; public abstract readonly type: T; public override readonly logColor: string = '#785def'; public debugFees: boolean = false; // Cancel script public LOCK_LEAF_SCRIPT: Script; /** * @description The overflow fees of the transaction * @public */ public overflowFees: bigint = 0n; /** * @description Cost in satoshis of the transaction fee */ public transactionFee: bigint = 0n; /** * @description The estimated fees of the transaction */ public estimatedFees: bigint = 0n; /** * @param {ITransactionParameters} parameters - The transaction parameters */ public optionalOutputs: PsbtOutputExtended[] | undefined; /** * @description The transaction itself. */ protected transaction: Psbt; /** * @description Inputs to update later on. */ protected readonly updateInputs: UpdateInput[] = []; /** * @description The outputs of the transaction */ protected readonly outputs: PsbtOutputExtended[] = []; /** * @description Output that will be used to pay the fees */ protected feeOutput: PsbtOutputExtended | null = null; /** * @description The total amount of satoshis in the inputs */ protected totalInputAmount: bigint; /** * @description The signer of the transaction */ protected override readonly signer: Signer | UniversalSigner | UnisatSigner; /** * @description The network where the transaction will be broadcasted */ protected override readonly network: Network; /** * @description The fee rate of the transaction */ protected readonly feeRate: number; /** * @description The opnet priority fee of the transaction */ protected priorityFee: bigint; protected gasSatFee: bigint; /** * @description The utxos used in the transaction */ protected utxos: UTXO[]; /** * @description The inputs of the transaction * @protected */ protected optionalInputs: UTXO[]; /** * @description The address where the transaction is sent to * @protected */ protected to: string | undefined; /** * @description The address where the transaction is sent from * @protected */ protected from: string; /** * @description The maximum fee rate of the transaction */ protected _maximumFeeRate: number = 100000000; /** * @description Is the destionation P2PK * @protected */ protected isPubKeyDestination: boolean; /** * @description If the transaction need an anchor output * @protected */ protected anchor: boolean; protected note?: Uint8Array; private optionalOutputsAdded: boolean = false; protected constructor(parameters: ITransactionParameters) { super(parameters); if (parameters.estimatedFees) { this.estimatedFees = parameters.estimatedFees; } this.signer = parameters.signer; this.network = parameters.network; this.feeRate = parameters.feeRate; this.priorityFee = parameters.priorityFee ?? 0n; this.gasSatFee = parameters.gasSatFee ?? 0n; this.utxos = parameters.utxos; this.optionalInputs = parameters.optionalInputs || []; this.to = parameters.to || undefined; this.debugFees = parameters.debugFees || false; this.LOCK_LEAF_SCRIPT = this.defineLockScript(); if (parameters.note) { if (typeof parameters.note === 'string') { this.note = new TextEncoder().encode(parameters.note); } else { this.note = parameters.note; } } this.anchor = parameters.anchor ?? false; this.isPubKeyDestination = this.to ? AddressVerificator.isValidPublicKey(this.to, this.network) : false; this.optionalOutputs = parameters.optionalOutputs; this.from = TransactionBuilder.getFrom(parameters.from, this.signer, this.network); this.totalInputAmount = this.calculateTotalUTXOAmount(); const totalVOut: bigint = this.calculateTotalVOutAmount(); if (totalVOut < this.totalInputAmount) { throw new Error(`Vout value is less than the value to send`); } this.transaction = new Psbt({ network: this.network, version: this.txVersion, }); } public static getFrom( from: string | undefined, keypair: UniversalSigner | Signer, network: Network, ): string { return from || EcKeyPair.getTaprootAddress(keypair, network); } /** * @description Converts the witness stack to a script witness * @param {Uint8Array[]} witness - The witness stack * @protected * @returns {Uint8Array} */ public static witnessStackToScriptWitness(witness: Uint8Array[]): Uint8Array { return witnessStackToScriptWitness(witness); } public override [Symbol.dispose](): void { super[Symbol.dispose](); this.updateInputs.length = 0; this.outputs.length = 0; this.feeOutput = null; this.optionalOutputs = undefined; this.utxos = []; this.optionalInputs = []; } public addOPReturn(buffer: Uint8Array): void { const compileScript = script.compile([opcodes.OP_RETURN, buffer]); this.addOutput({ value: toSatoshi(0n), script: compileScript, }); } public addAnchor(): void { this.addOutput({ value: toSatoshi(0n), script: ANCHOR_SCRIPT as Script, }); } public async getFundingTransactionParameters(): Promise<IFundingTransactionParameters> { if (!this.estimatedFees) { this.estimatedFees = await this.estimateTransactionFees(); } return { utxos: this.utxos, to: this.getScriptAddress(), signer: this.signer, network: this.network, feeRate: this.feeRate, priorityFee: this.priorityFee ?? 0n, gasSatFee: this.gasSatFee ?? 0n, from: this.from, amount: this.estimatedFees, optionalInputs: this.optionalInputs, mldsaSigner: null, ...(this.optionalOutputs !== undefined ? { optionalOutputs: this.optionalOutputs } : {}), } satisfies IFundingTransactionParameters; } /** * Set the destination address of the transaction * @param {string} address - The address to set */ public setDestinationAddress(address: string): void { this.to = address; // this.getScriptAddress() } /** * Set the maximum fee rate of the transaction in satoshis per byte * @param {number} feeRate - The fee rate to set * @public */ public setMaximumFeeRate(feeRate: number): void { this._maximumFeeRate = feeRate; } /** * @description Signs the transaction * @public * @returns {Promise<Transaction>} - The signed transaction in hex format * @throws {Error} - If something went wrong */ public async signTransaction(): Promise<Transaction> { if (!this.utxos.length) { throw new Error('No UTXOs specified'); } if ( this.to && !this.isPubKeyDestination && !EcKeyPair.verifyContractAddress(this.to, this.network) ) { throw new Error( 'Invalid contract address. The contract address must be a taproot address.', ); } if (this.signed) throw new Error('Transaction is already signed'); this.signed = true; await this.buildTransaction(); const builtTx = await this.internalBuildTransaction(this.transaction); if (builtTx) { if (this.regenerated) { throw new Error('Transaction was regenerated'); } return this.transaction.extractTransaction(true, true); } throw new Error('Could not sign transaction'); } /** * @description Generates the transaction minimal signatures * @public */ public async generateTransactionMinimalSignatures( checkPartialSigs: boolean = false, ): Promise<void> { if ( this.to && !this.isPubKeyDestination && !EcKeyPair.verifyContractAddress(this.to, this.network) ) { throw new Error( 'Invalid contract address. The contract address must be a taproot address.', ); } await this.buildTransaction(); if (this.transaction.data.inputs.length === 0) { const inputs: PsbtInputExtended[] = this.getInputs(); const outputs: PsbtOutputExtended[] = this.getOutputs(); this.transaction.setMaximumFeeRate(this._maximumFeeRate); this.transaction.addInputs(inputs, checkPartialSigs); for (let i = 0; i < this.updateInputs.length; i++) { this.transaction.updateInput(i, this.updateInputs[i] as UpdateInput); } this.transaction.addOutputs(outputs); } } /** * @description Signs the transaction * @public * @returns {Promise<Psbt>} - The signed transaction in hex format * @throws {Error} - If something went wrong */ public async signPSBT(): Promise<Psbt> { if (await this.signTransaction()) { return this.transaction; } throw new Error('Could not sign transaction'); } /** * Add an input to the transaction. * @param {PsbtInputExtended} input - The input to add * @public * @returns {void} */ public addInput(input: PsbtInputExtended): void { this.inputs.push(input); } /** * Add an output to the transaction. * @param {PsbtOutputExtended} output - The output to add * @param bypassMinCheck * @public * @returns {void} */ public addOutput(output: PsbtOutputExtended, bypassMinCheck: boolean = false): void { if (output.value === toSatoshi(0n)) { const scriptOutput = output as { script: Uint8Array; }; if (!scriptOutput.script || scriptOutput.script.length === 0) { throw new Error('Output value is 0 and no script provided'); } if (scriptOutput.script.length < 2) { throw new Error('Output script is too short'); } if ( scriptOutput.script[0] !== opcodes.OP_RETURN && !equals(scriptOutput.script, ANCHOR_SCRIPT) ) { throw new Error( 'Output script must start with OP_RETURN or be an ANCHOR when value is 0', ); } } else if (!bypassMinCheck && BigInt(output.value) < TransactionBuilder.MINIMUM_DUST) { throw new Error( `Output value is less than the minimum dust ${output.value} < ${TransactionBuilder.MINIMUM_DUST}`, ); } this.outputs.push(output); } /** * Returns the total value of all outputs added so far (excluding the fee/change output). * @public * @returns {bigint} */ public getTotalOutputValue(): bigint { return this.outputs.reduce((total, output) => total + BigInt(output.value), 0n); } /** * Receiver address. * @public * @returns {string} - The receiver address */ public toAddress(): string | undefined { return this.to; } /** * @description Returns the script address * @returns {string} - The script address */ public address(): string | undefined { return this.tapData?.address; } /** * Estimates the transaction fees with accurate size calculation. * * @note The P2TR estimation is made for a 2-leaf tree with both a tapScriptSig and a tapInternalKey input, which is a common case for many transactions. * This provides a more accurate fee estimation for typical P2TR transactions, but may not be perfectly accurate for all possible script configurations. * Adjustments may be needed for more complex scripts or different leaf structures. * * @public * @returns {Promise<bigint>} */ public async estimateTransactionFees(): Promise<bigint> { await Promise.resolve(); const fakeTx = new Psbt({ network: this.network }); const inputs = this.getInputs(); const outputs = this.getOutputs(); fakeTx.addInputs(inputs); fakeTx.addOutputs(outputs); const dummySchnorrSig = new Uint8Array(64); const dummyEcdsaSig = new Uint8Array(72); const dummyCompressedPubkey = new Uint8Array(33).fill(2); const finalizer = (inputIndex: number, input: PsbtInputExtended) => { if (input.isPayToAnchor || this.anchorInputIndices.has(inputIndex)) { return { finalScriptSig: undefined, finalScriptWitness: Uint8Array.from([0]), }; } if (input.witnessScript && P2WDADetector.isP2WDAWitnessScript(input.witnessScript)) { // Create dummy witness stack for P2WDA const dummyDataSlots: Uint8Array[] = []; for (let i = 0; i < 10; i++) { dummyDataSlots.push(new Uint8Array(0)); } const dummyEcdsaSig = new Uint8Array(72); return { finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness([ ...dummyDataSlots, dummyEcdsaSig, input.witnessScript, ]), }; } if (inputIndex === 0 && this.tapLeafScript) { const dummySecret = new Uint8Array(32); const dummyScript = this.tapLeafScript.script; // A control block for a 2-leaf tree contains one 32-byte hash. // P2TR: 33 (version + internal pubkey) + 32 (merkle path) = 65 bytes // P2MR: 1 (version) + 32 (merkle path) = 33 bytes (no internal pubkey) const controlBlockSize = this.useP2MR ? 1 + 32 : 1 + 32 + 32; const dummyControlBlock = new Uint8Array(controlBlockSize); return { finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness([ dummySecret, dummySchnorrSig, // It's a tapScriptSig, which is Schnorr dummySchnorrSig, // Second Schnorr signature dummyScript, dummyControlBlock, ]), }; } if (input.witnessUtxo) { const script = input.witnessUtxo.script; const decompiled = bitcoin.script.decompile(script); if ( decompiled && decompiled.length === 5 && decompiled[0] === opcodes.OP_DUP && decompiled[1] === opcodes.OP_HASH160 && decompiled[3] === opcodes.OP_EQUALVERIFY && decompiled[4] === opcodes.OP_CHECKSIG ) { return { finalScriptSig: bitcoin.script.compile([ dummyEcdsaSig, dummyCompressedPubkey, ]), finalScriptWitness: undefined, }; } } if (input.witnessScript) { if (this.csvInputIndices.has(inputIndex)) { // CSV P2WSH needs: [signature, witnessScript] return { finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness([ dummyEcdsaSig, input.witnessScript, ]), }; } if (input.redeemScript) { // P2SH-P2WSH needs redeemScript in scriptSig and witness data const dummyWitness = [dummyEcdsaSig, input.witnessScript]; return { finalScriptSig: input.redeemScript, finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness(dummyWitness), }; } const decompiled = bitcoin.script.decompile(input.witnessScript); if (decompiled && decompiled.length >= 4) { const firstOp = decompiled[0]; const lastOp = decompiled[decompiled.length - 1]; // Check if it's M-of-N multisig if ( typeof firstOp === 'number' && firstOp >= opcodes.OP_1 && lastOp === opcodes.OP_CHECKMULTISIG ) { const m = firstOp - opcodes.OP_1 + 1; const signatures: Uint8Array[] = []; for (let i = 0; i < m; i++) { signatures.push(dummyEcdsaSig); } return { finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness([ new Uint8Array(0), // OP_0 due to multisig bug ...signatures, input.witnessScript, ]), }; } } return { finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness([ dummyEcdsaSig, input.witnessScript, ]), }; } else if (input.redeemScript) { const decompiled = bitcoin.script.decompile(input.redeemScript); if ( decompiled && decompiled.length === 2 && decompiled[0] === opcodes.OP_0 && decompiled[1] instanceof Uint8Array && decompiled[1].length === 20 ) { // P2SH-P2WPKH return { finalScriptSig: input.redeemScript, finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness([ dummyEcdsaSig, dummyCompressedPubkey, ]), }; } } if (input.redeemScript && !input.witnessScript && !input.witnessUtxo) { // Pure P2SH needs signatures + redeemScript in scriptSig return { finalScriptSig: bitcoin.script.compile([dummyEcdsaSig, input.redeemScript]), finalScriptWitness: undefined, }; } const inputScript = input.witnessUtxo?.script; if (!inputScript) return { finalScriptSig: undefined, finalScriptWitness: undefined }; if (input.tapInternalKey) { return { finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness([ dummySchnorrSig, ]), }; } if (inputScript.length === 22 && inputScript[0] === opcodes.OP_0) { return { finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness([ dummyEcdsaSig, dummyCompressedPubkey, ]), }; } if (input.redeemScript?.length === 22 && input.redeemScript[0] === opcodes.OP_0) { return { finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness([ dummyEcdsaSig, dummyCompressedPubkey, ]), }; } return getFinalScripts( inputIndex, input, inputScript as Script, true, !!input.redeemScript, !!input.witnessScript, ); }; try { for (let i = 0; i < fakeTx.data.inputs.length; i++) { const fullInput = inputs[i]; if (fullInput) { fakeTx.finalizeInput(i, (idx: number) => finalizer(idx, fullInput)); } } } catch (e) { this.warn(`Could not finalize dummy tx: ${(e as Error).message}`); } const tx = fakeTx.extractTransaction(true, true); const size = tx.virtualSize(); const fee = this.feeRate * size; const finalFee = BigInt(Math.ceil(fee)); if (this.debugFees) { this.log( `Estimating fees: feeRate=${this.feeRate}, accurate_vSize=${size}, fee=${finalFee}n`, ); } return finalFee; } public async rebuildFromBase64(base64: string): Promise<Psbt> { this.transaction = Psbt.fromBase64(base64, { network: this.network, version: this.txVersion, }); this.signed = false; this.sighashTypes = [Transaction.SIGHASH_ANYONECANPAY, Transaction.SIGHASH_ALL]; return await this.signPSBT(); } public setPSBT(psbt: Psbt): void { this.transaction = psbt; } /** * Returns the inputs of the transaction. * @protected * @returns {PsbtInputExtended[]} */ public getInputs(): PsbtInputExtended[] { return this.inputs; } /** * Returns the outputs of the transaction. * @protected * @returns {PsbtOutputExtended[]} */ public getOutputs(): PsbtOutputExtended[] { const outputs: PsbtOutputExtended[] = [...this.outputs]; if (this.feeOutput) outputs.push(this.feeOutput); return outputs; } public getOptionalOutputValue(): bigint { if (!this.optionalOutputs) return 0n; let total = 0n; for (let i = 0; i < this.optionalOutputs.length; i++) { total += BigInt((this.optionalOutputs[i] as PsbtOutputExtended).value); } return total; } protected async addRefundOutput( amountSpent: bigint, expectRefund: boolean = false, ): Promise<void> { if (this.note) { this.addOPReturn(this.note); } if (this.anchor) { this.addAnchor(); } // Add a dummy change output to estimate fee with the change-output shape this.feeOutput = this.createChangeOutput(TransactionBuilder.MINIMUM_DUST); const feeWithChange = await this.estimateTransactionFees(); const sendBackAmount = this.totalInputAmount - amountSpent - feeWithChange; if (this.debugFees) { this.log( `Fee with change: ${feeWithChange} sats, inputAmount=${this.totalInputAmount}, amountSpent=${amountSpent}, sendBackAmount=${sendBackAmount}`, ); } if (sendBackAmount >= TransactionBuilder.MINIMUM_DUST) { // Change output is viable, set it to the real value this.feeOutput = this.createChangeOutput(sendBackAmount); this.overflowFees = sendBackAmount; this.transactionFee = feeWithChange; } else { // Change output not viable, remove it and re-estimate without it this.feeOutput = null; this.overflowFees = 0n; const feeWithoutChange = await this.estimateTransactionFees(); this.transactionFee = feeWithoutChange; if (this.debugFees) { this.warn( `Amount to send back (${sendBackAmount} sat) is less than minimum dust. Fee without change: ${feeWithoutChange} sats`, ); } if (this.totalInputAmount <= amountSpent) { throw new Error( `Insufficient funds: need ${amountSpent + feeWithoutChange} sats but only have ${this.totalInputAmount} sats`, ); } if (expectRefund && sendBackAmount < 0n) { throw new Error( `Insufficient funds: need at least ${-sendBackAmount} more sats to cover fees.`, ); } } if (this.debugFees) { this.log( `Final fee: ${this.transactionFee} sats, Change output: ${this.feeOutput ? `${this.feeOutput.value} sats` : 'none'}`, ); } } protected defineLockScript(): Script { return script.compile([toXOnly(this.signer.publicKey), opcodes.OP_CHECKSIG]); } /** * @description Adds the value to the output * @param {number | bigint} value - The value to add * @protected * @returns {void} */ protected addValueToToOutput(value: number | bigint): void { if (BigInt(value) < TransactionBuilder.MINIMUM_DUST) { throw new Error( `Value to send is less than the minimum dust ${value} < ${TransactionBuilder.MINIMUM_DUST}`, ); } for (let i = 0; i < this.outputs.length; i++) { const output = this.outputs[i] as PsbtOutputExtended; if ('address' in output && output.address === this.to) { this.outputs[i] = { ...output, value: toSatoshi(BigInt(output.value) + BigInt(value)), } as PsbtOutputExtended; return; } } throw new Error('Output not found'); } protected generateLegacySignature(): Uint8Array { this.tweakSigner(); if (!this.tweakedSigner) { throw new Error('Tweaked signer is not defined'); } const tweakedKey = toXOnly(this.tweakedSigner.publicKey); const originalKey = this.signer.publicKey; if (originalKey.length !== 33) { throw new Error('Original public key must be compressed (33 bytes)'); } const chainId = getChainId(this.network); const writer = new BinaryWriter(); // ONLY SUPPORT MLDSA-44 FOR NOW. writer.writeU8(MLDSASecurityLevel.LEVEL2); writer.writeBytes(this.hashedPublicKey); writer.writeBytes(tweakedKey); writer.writeBytes(originalKey); writer.writeBytes(BITCOIN_PROTOCOL_ID); writer.writeBytes(chainId); const message = writer.getBuffer(); const signature = MessageSigner.signMessage(this.tweakedSigner, message); const isValid = MessageSigner.verifySignature(tweakedKey, message, signature.signature); if (!isValid) { throw new Error('Could not verify generated legacy signature for MLDSA link request'); } return new Uint8Array(signature.signature); } protected generateMLDSASignature(): Uint8Array { if (!this.mldsaSigner) { throw new Error('MLDSA signer is not defined'); } this.tweakSigner(); if (!this.tweakedSigner) { throw new Error('Tweaked signer is not defined'); } const tweakedKey = toXOnly(this.tweakedSigner.publicKey); const originalKey = this.signer.publicKey; if (originalKey.length !== 33) { throw new Error('Original public key must be compressed (33 bytes)'); } const chainId = getChainId(this.network); const level = getLevelFromPublicKeyLength(this.mldsaSigner.publicKey.length); if (level !== MLDSASecurityLevel.LEVEL2) { throw new Error('Only MLDSA level 2 is supported for link requests'); } const writer = new BinaryWriter(); writer.writeU8(level); writer.writeBytes(this.hashedPublicKey); writer.writeBytes(this.mldsaSigner.publicKey); writer.writeBytes(tweakedKey); writer.writeBytes(originalKey); writer.writeBytes(BITCOIN_PROTOCOL_ID); writer.writeBytes(chainId); const message = writer.getBuffer(); const signature = MessageSigner.signMLDSAMessage(this.mldsaSigner, message); const isValid = MessageSigner.verifyMLDSASignature( this.mldsaSigner, message, signature.signature, ); if (!isValid) { throw new Error('Could not verify generated MLDSA signature for link request'); } return new Uint8Array(signature.signature); } protected generateMLDSALinkRequest( parameters: ITransactionParameters, features: Feature<Features>[], ): void { const mldsaSigner = this.mldsaSigner; const legacySignature = this.generateLegacySignature(); let mldsaSignature: Uint8Array | null = null; if (parameters.revealMLDSAPublicKey) { mldsaSignature = this.generateMLDSASignature(); } const mldsaRequest: MLDSALinkRequest = { priority: FeaturePriority.MLDSA_LINK_PUBKEY, opcode: Features.MLDSA_LINK_PUBKEY, data: { verifyRequest: !!parameters.revealMLDSAPublicKey, publicKey: mldsaSigner.publicKey, hashedPublicKey: this.hashedPublicKey, level: getLevelFromPublicKeyLength(mldsaSigner.publicKey.length), legacySignature: legacySignature, mldsaSignature: mldsaSignature, }, }; features.push(mldsaRequest); } /** * @description Returns the transaction opnet fee * @protected * @returns {bigint} */ protected getTransactionOPNetFee(): bigint { const totalFee = this.priorityFee + this.gasSatFee; if (totalFee > TransactionBuilder.MINIMUM_DUST) { return totalFee; } return TransactionBuilder.MINIMUM_DUST; } /** * @description Returns the total amount of satoshis in the inputs * @protected * @returns {bigint} */ protected calculateTotalUTXOAmount(): bigint { let total: bigint = 0n; for (const utxo of this.utxos) { total += utxo.value; } for (const utxo of this.optionalInputs) { total += utxo.value; } return total; } /** * @description Returns the total amount of satoshis in the outputs * @protected * @returns {bigint} */ protected calculateTotalVOutAmount(): bigint { let total: bigint = 0n; for (const utxo of this.utxos) { total += utxo.value; } for (const utxo of this.optionalInputs) { total += utxo.value; } return total; } /** * @description Adds optional outputs to transaction and returns their total value in satoshi to calculate refund transaction * @protected * @returns {bigint} */ protected addOptionalOutputsAndGetAmount(): bigint { if (!this.optionalOutputs || this.optionalOutputsAdded) return 0n; let refundedFromOptionalOutputs: bigint = 0n; for (let i = 0; i < this.optionalOutputs.length; i++) { this.addOutput(this.optionalOutputs[i] as PsbtOutputExtended); refundedFromOptionalOutputs += BigInt( (this.optionalOutputs[i] as PsbtOutputExtended).value, ); } this.optionalOutputsAdded = true; return refundedFromOptionalOutputs; } /** * @description Adds the inputs from the utxos * @protected * @returns {void} */ protected addInputsFromUTXO(): void { if (this.utxos.length) { //throw new Error('No UTXOs specified'); if (this.totalInputAmount < TransactionBuilder.MINIMUM_DUST) { throw new Error( `Total input amount is ${this.totalInputAmount} sat which is less than the minimum dust ${TransactionBuilder.MINIMUM_DUST} sat.`, ); } for (let i = 0; i < this.utxos.length; i++) { const utxo = this.utxos[i] as UTXO; // Register signer BEFORE generating input (needed for tapInternalKey) this.registerInputSigner(i, utxo); const input = this.generatePsbtInputExtended(utxo, i); this.addInput(input); } } if (this.optionalInputs) { for ( let i = this.utxos.length; i < this.optionalInputs.length + this.utxos.length; i++ ) { const utxo = this.optionalInputs[i - this.utxos.length] as UTXO; // Register signer BEFORE generating input (needed for tapInternalKey) this.registerInputSigner(i, utxo); const input = this.generatePsbtInputExtended(utxo, i, true); this.addInput(input); } } } /** * Internal init. * @protected */ protected override internalInit(): void { this.verifyUTXOValidity(); super.internalInit(); } /** * Builds the transaction. * @protected * @returns {Promise<void>} */ protected abstract buildTransaction(): Promise<void>; /** * Add an input update * @param {UpdateInput} input - The input to update * @protected * @returns {void} */ protected updateInput(input: UpdateInput): void { this.updateInputs.push(input); } /** * Adds the fee to the output. * @param amountSpent * @param contractAddress * @param epochChallenge * @param addContractOutput * @protected */ protected addFeeToOutput( amountSpent: bigint, contractAddress: string, epochChallenge: IP2WSHAddress, addContractOutput: boolean, ): void { if (addContractOutput) { let amountToCA: bigint; if (amountSpent > MINIMUM_AMOUNT_REWARD + MINIMUM_AMOUNT_CA) { amountToCA = MINIMUM_AMOUNT_CA; } else { amountToCA = amountSpent; } // ALWAYS THE FIRST INPUT. this.addOutput( { value: toSatoshi(amountToCA), address: contractAddress, }, true, ); // ALWAYS SECOND. if ( amountToCA === MINIMUM_AMOUNT_CA && amountSpent - MINIMUM_AMOUNT_CA > MINIMUM_AMOUNT_REWARD ) { this.addOutput( { value: toSatoshi(amountSpent - amountToCA), address: epochChallenge.address, }, true, ); } } else { // When SEND_AMOUNT_TO_CA is false, always send to epochChallenge // Use the maximum of amountSpent or MINIMUM_AMOUNT_REWARD const amountToEpoch = amountSpent < MINIMUM_AMOUNT_REWARD ? MINIMUM_AMOUNT_REWARD : amountSpent; this.addOutput( { value: toSatoshi(amountToEpoch), address: epochChallenge.address, }, true, ); } } /** * Returns the witness of the tap transaction. * @protected * @returns {Uint8Array} */ protected getWitness(): Uint8Array { if (!this.tapData || !this.tapData.witness) { throw new Error('Witness is required'); } if (this.tapData.witness.length === 0) { throw new Error('Witness is empty'); } return this.tapData.witness[this.tapData.witness.length - 1] as Uint8Array; } /** * Returns the tap output. * @protected * @returns {Uint8Array} */ protected getTapOutput(): Uint8Array { if (!this.tapData || !this.tapData.output) { throw new Error('Tap data is required'); } return this.tapData.output; } /** * Verifies that the utxos are valid. * @protected */ protected verifyUTXOValidity(): void { for (const utxo of this.utxos) { if (!utxo.scriptPubKey) { throw new Error('Address is required'); } } for (const utxo of this.optionalInputs) { if (!utxo.scriptPubKey) { throw new Error('Address is required'); } } } /** * Builds the transaction. * @param {Psbt} transaction - The transaction to build * @param checkPartialSigs * @protected * @returns {Promise<boolean>} * @throws {Error} - If something went wrong while building the transaction */ protected async internalBuildTransaction( transaction: Psbt, checkPartialSigs: boolean = false, ): Promise<boolean> { if (transaction.data.inputs.length === 0) { const inputs: PsbtInputExtended[] = this.getInputs(); const outputs: PsbtOutputExtended[] = this.getOutputs(); transaction.setMaximumFeeRate(this._maximumFeeRate); transaction.addInputs(inputs, checkPartialSigs); for (let i = 0; i < this.updateInputs.length; i++) { transaction.updateInput(i, this.updateInputs[i] as UpdateInput); } transaction.addOutputs(outputs); } try { await this.signInputs(transaction); if (this.finalized) { this.transactionFee = BigInt(transaction.getFee()); } return true; } catch (e) { const err: Error = e as Error; this.error( `[internalBuildTransaction] Something went wrong while getting building the transaction: ${err.stack}`, ); } return false; } private createChangeOutput(amount: bigint): PsbtOutputExtended { if (AddressVerificator.isValidP2TRAddress(this.from, this.network)) { return { value: toSatoshi(amount), address: this.from, tapInternalKey: this.internalPubKeyToXOnly(), }; } else if (AddressVerificator.isValidPublicKey(this.from, this.network)) { const pubKeyScript = script.compile([ fromHex(this.from.startsWith('0x') ? this.from.slice(2) : this.from), opcodes.OP_CHECKSIG, ]); return { value: toSatoshi(amount), script: pubKeyScript, }; } else { return { value: toSatoshi(amount), address: this.from, }; } } }