import { QuantumBIP32Interface } from '@btc-vision/bip32'; import { fromBase64, fromHex, Network, networks, PsbtOutputExtended, Signer, toHex, } from '@btc-vision/bitcoin'; import { UniversalSigner } from '@btc-vision/ecpair'; import { Address, BinaryReader, ChallengeSolution, IInteractionParameters, InteractionParametersWithoutSigner, IP2WSHAddress, LoadedStorage, NetEvent, RawChallenge, SupportedTransactionVersion, TransactionFactory, } from '@btc-vision/transaction'; import { UTXO } from '../bitcoin/UTXOs.js'; import { BitcoinFees } from '../block/BlockGasParameters.js'; import { PackageResult } from '../transactions/interfaces/BroadcastedTransactionPackage.js'; import { decodeRevertData } from '../utils/RevertDecoder.js'; import { RequestUTXOsParamsWithAmount } from '../utxos/interfaces/IUTXOsManager.js'; import { CallResultSerializer, NetworkName } from './CallResultSerializer.js'; import { IAccessList } from './interfaces/IAccessList.js'; import { EventList, ICallResultData, RawEventList } from './interfaces/ICallResult.js'; import { IProviderForCallResult } from './interfaces/IProviderForCallResult.js'; import { OPNetEvent } from './OPNetEvent.js'; import { TransactionHelper } from './TransactionHelpper.js'; import { ContractDecodedObjectResult, DecodedOutput } from './types/ContractTypes.js'; const factory = new TransactionFactory(); export interface TransactionParameters { readonly signer: Signer | UniversalSigner | null; readonly mldsaSigner: QuantumBIP32Interface | null; readonly refundTo: string; readonly sender?: string; readonly priorityFee?: bigint; feeRate?: number; readonly utxos?: UTXO[]; readonly maximumAllowedSatToSpend: bigint; readonly network: Network; readonly extraInputs?: UTXO[]; readonly extraOutputs?: PsbtOutputExtended[]; readonly minGas?: bigint; readonly note?: string | Uint8Array; readonly p2wda?: boolean; readonly from?: Address; readonly txVersion?: SupportedTransactionVersion; readonly anchor?: boolean; readonly dontUseCSVUtxos?: boolean; readonly maxUTXOs?: number; readonly throwIfUTXOsLimitReached?: boolean; readonly linkMLDSAPublicKeyToAddress?: boolean; readonly revealMLDSAPublicKey?: boolean; readonly challenge?: ChallengeSolution; readonly subtractExtraUTXOFromAmountRequired?: boolean; } export interface UTXOTrackingInfo { readonly csvUTXOs: UTXO[]; readonly p2wdaUTXOs: UTXO[]; readonly regularUTXOs: UTXO[]; readonly refundAddress: string; readonly refundToAddress: string; readonly csvAddress?: IP2WSHAddress; readonly p2wdaAddress?: { readonly address: string; readonly witnessScript: Uint8Array }; readonly isP2WDA: boolean; } export interface SignedInteractionTransactionReceipt { readonly fundingTransactionRaw: string | null; readonly interactionTransactionRaw: string; readonly nextUTXOs: UTXO[]; readonly estimatedFees: bigint; readonly challengeSolution: RawChallenge; readonly interactionAddress: string | null; readonly fundingUTXOs: UTXO[]; readonly fundingInputUtxos: UTXO[]; readonly compiledTargetScript: string | null; readonly utxoTracking: UTXOTrackingInfo; } export interface InteractionTransactionReceipt { readonly transactionId: string; readonly newUTXOs: UTXO[]; readonly peerAcknowledgements: number; readonly estimatedFees: bigint; readonly challengeSolution: RawChallenge; readonly rawTransaction: string; readonly interactionAddress: string | null; readonly fundingUTXOs: UTXO[]; readonly fundingInputUtxos: UTXO[]; readonly compiledTargetScript: string | null; } function extractPackageFailures(packageResult: PackageResult): string[] { const failures: string[] = []; const results = packageResult.txResults; for (const [submittedTxid, result] of Object.entries(results)) { if (result.error) { failures.push(`tx ${submittedTxid} failed: ${result.error}`); } } if (failures.length === 0 && packageResult.packageMsg !== 'success') { failures.push(`package rejected: ${packageResult.packageMsg}`); } return failures; } /** * Represents the result of a contract call. * @category Contracts */ export class CallResult< T extends ContractDecodedObjectResult = {}, U extends OPNetEvent[] = OPNetEvent[], > implements Omit { public readonly result: BinaryReader; public readonly accessList: IAccessList; public revert: string | undefined; public constant: boolean = false; public payable: boolean = false; public calldata: Uint8Array | undefined; public loadedStorage: LoadedStorage | undefined; public readonly estimatedGas: bigint | undefined; public readonly refundedGas: bigint | undefined; public properties: T = {} as T; public estimatedSatGas: bigint = 0n; public estimatedRefundedGasInSat: bigint = 0n; public events: U = [] as unknown as U; public to: string | undefined; public address: Address | undefined; public fromAddress: Address | undefined; public csvAddress: IP2WSHAddress | undefined; #bitcoinFees: BitcoinFees | undefined; readonly #rawEvents: EventList; readonly #provider: IProviderForCallResult; readonly #resultBase64: string; constructor(callResult: ICallResultData, provider: IProviderForCallResult) { this.#provider = provider; this.#rawEvents = this.parseEvents(callResult.events); this.accessList = callResult.accessList; this.loadedStorage = callResult.loadedStorage; if (callResult.estimatedGas) { this.estimatedGas = BigInt(callResult.estimatedGas); } if (callResult.specialGas) { this.refundedGas = BigInt(callResult.specialGas); } const revert = typeof callResult.revert === 'string' ? this.base64ToUint8Array(callResult.revert) : callResult.revert; if (revert) { this.revert = CallResult.decodeRevertData(revert); } // Store original result for serialization if (typeof callResult.result === 'string') { this.#resultBase64 = callResult.result; this.result = new BinaryReader(this.base64ToUint8Array(callResult.result)); } else if (callResult.result instanceof Uint8Array) { this.#resultBase64 = ''; this.result = new BinaryReader(callResult.result); } else { // If already a BinaryReader, we can't easily get the base64 back // This shouldn't happen in normal flow this.#resultBase64 = ''; this.result = callResult.result; } } public get rawEvents(): EventList { return this.#rawEvents; } public static decodeRevertData(revertDataBytes: Uint8Array): string { return decodeRevertData(revertDataBytes); } /** * Reconstructs a CallResult from offline serialized buffer. * Use this on a device to sign transactions offline. * @param {Uint8Array | string} input - The serialized offline data as Uint8Array or hex string. * @returns {CallResult} A CallResult instance ready for offline signing. * * @example * ```typescript * // Offline device: reconstruct from buffer * const buffer = fs.readFileSync('offline-tx.bin'); * const simulation = CallResult.fromOfflineBuffer(buffer); * * // Now sign offline * const signedTx = await simulation.signTransaction({ * signer: wallet.keypair, * // ... other params * }); * ``` */ public static fromOfflineBuffer(input: Uint8Array | string): CallResult { const buffer = typeof input === 'string' ? fromHex(input) : input; const data = CallResultSerializer.deserialize(buffer); // Resolve network const network = CallResult.resolveNetwork(data.network); // Create ChallengeSolution from serialized RawChallenge // Use the original public key (33 bytes) instead of the tweaked key (32 bytes) const challengeWithOriginalKey = { ...data.challenge, legacyPublicKey: '0x' + toHex(data.challengeOriginalPublicKey), }; const challengeSolution = new ChallengeSolution(challengeWithOriginalKey); // Create offline provider const offlineProvider: IProviderForCallResult = { network, utxoManager: { getUTXOsForAmount: () => Promise.resolve(data.utxos), spentUTXO: () => {}, clean: () => {}, }, getChallenge: () => Promise.resolve(challengeSolution), sendRawTransaction: () => { return Promise.reject( new Error( 'Cannot broadcast from offline CallResult. Export signed transaction and broadcast online.', ), ); }, sendRawTransactionPackage: () => { return Promise.reject( new Error( 'Cannot broadcast from offline CallResult. Export signed transaction and broadcast online.', ), ); }, getCSV1ForAddress: () => { if (!data.csvAddress) { throw new Error('CSV address not available in offline data'); } return data.csvAddress; }, }; // Create ICallResultData // Note: revert is set directly below to avoid double-decoding const callResultData: ICallResultData = { result: data.result, accessList: data.accessList, events: {}, revert: undefined, estimatedGas: data.estimatedGas?.toString(), specialGas: data.refundedGas?.toString(), }; const callResult = new CallResult(callResultData, offlineProvider); // Restore state // Set revert directly since it's already decoded (not base64 bytes) callResult.revert = data.revert; callResult.calldata = data.calldata; callResult.to = data.to; callResult.address = Address.fromString(data.contractAddress); callResult.estimatedSatGas = data.estimatedSatGas; callResult.estimatedRefundedGasInSat = data.estimatedRefundedGasInSat; callResult.csvAddress = data.csvAddress; if (data.bitcoinFees) { callResult.setBitcoinFee(data.bitcoinFees); } return callResult; } /** * Resolves a NetworkName enum to a Network object. */ private static resolveNetwork(networkName: NetworkName): Network { switch (networkName) { case NetworkName.Mainnet: return networks.bitcoin; case NetworkName.Testnet: return networks.testnet; case NetworkName.OpnetTestnet: return networks.opnetTestnet; case NetworkName.Regtest: return networks.regtest; default: return networks.regtest; } } public setTo(to: string, address: Address): void { this.to = to; this.address = address; } public setFromAddress(from?: Address): void { this.fromAddress = from; this.csvAddress = this.fromAddress && this.fromAddress.originalPublicKey ? this.#provider.getCSV1ForAddress(this.fromAddress) : undefined; } /** * Signs a bitcoin interaction transaction from a simulated contract call without broadcasting. * @param {TransactionParameters} interactionParams - The parameters for the transaction. * @param {bigint} amountAddition - Additional satoshis to request when acquiring UTXOs. * @returns {Promise} The signed transaction data and UTXO tracking info. */ public async signTransaction( interactionParams: TransactionParameters, amountAddition: bigint = 0n, ): Promise { if (!this.address) { throw new Error('Contract address not set'); } if (!this.calldata) { throw new Error('Calldata not set'); } if (!this.to) { throw new Error('To address not set'); } if (this.revert) { throw new Error(`Can not send transaction! Simulation reverted: ${this.revert}`); } if (this.constant) { throw new Error( 'Cannot send a transaction on a constant (view) function. Use the returned CallResult directly.', ); } if (this.payable) { const hasExtraInputs = interactionParams.extraInputs && interactionParams.extraInputs.length > 0; const hasExtraOutputs = interactionParams.extraOutputs && interactionParams.extraOutputs.length > 0; if (!hasExtraInputs && !hasExtraOutputs) { throw new Error( 'Payable function requires extraInputs or extraOutputs in the transaction parameters.', ); } } let UTXOs: UTXO[] = interactionParams.utxos || (await this.acquire(interactionParams, amountAddition)); if (interactionParams.extraInputs) { UTXOs = UTXOs.filter((utxo) => { return ( interactionParams.extraInputs?.find((input) => { return ( input.outputIndex === utxo.outputIndex && input.transactionId === utxo.transactionId ); }) === undefined ); }); } if (!UTXOs || UTXOs.length === 0) { throw new Error('No UTXOs found'); } const priorityFee: bigint = interactionParams.priorityFee || 0n; const challenge: ChallengeSolution = interactionParams.challenge || (await this.#provider.getChallenge()); const sharedParams = { contract: this.address.toHex(), calldata: this.calldata, priorityFee: priorityFee, gasSatFee: this.bigintMax(this.estimatedSatGas, interactionParams.minGas || 0n), feeRate: interactionParams.feeRate || this.#bitcoinFees?.conservative || 10, from: interactionParams.refundTo, utxos: UTXOs, to: this.to, network: interactionParams.network, optionalInputs: interactionParams.extraInputs || [], optionalOutputs: interactionParams.extraOutputs || [], note: interactionParams.note, anchor: interactionParams.anchor || false, txVersion: interactionParams.txVersion || 2, linkMLDSAPublicKeyToAddress: interactionParams.linkMLDSAPublicKeyToAddress ?? true, revealMLDSAPublicKey: interactionParams.revealMLDSAPublicKey ?? false, subtractExtraUTXOFromAmountRequired: interactionParams.subtractExtraUTXOFromAmountRequired ?? false, }; const params: IInteractionParameters | InteractionParametersWithoutSigner = interactionParams.signer !== null ? { ...sharedParams, signer: interactionParams.signer, challenge: challenge, mldsaSigner: interactionParams.mldsaSigner, } : sharedParams; const transaction = await factory.signInteraction(params); const csvUTXOs = UTXOs.filter((u) => u.isCSV === true); const p2wdaUTXOs = UTXOs.filter((u) => u.witnessScript && u.isCSV !== true); const regularUTXOs = UTXOs.filter((u) => !u.witnessScript && u.isCSV !== true); const refundAddress = interactionParams.sender || interactionParams.refundTo; const p2wdaAddress = interactionParams.from?.p2wda(this.#provider.network); let refundToAddress: string; if (this.csvAddress && refundAddress === this.csvAddress.address) { refundToAddress = this.csvAddress.address; } else if (p2wdaAddress && refundAddress === p2wdaAddress.address) { refundToAddress = p2wdaAddress.address; } else { refundToAddress = refundAddress; } const utxoTracking: UTXOTrackingInfo = { csvUTXOs, p2wdaUTXOs, regularUTXOs, refundAddress, refundToAddress, csvAddress: this.csvAddress, p2wdaAddress: p2wdaAddress ? { address: p2wdaAddress.address, witnessScript: p2wdaAddress.witnessScript } : undefined, isP2WDA: interactionParams.p2wda || false, }; return { fundingTransactionRaw: transaction.fundingTransaction, interactionTransactionRaw: transaction.interactionTransaction, nextUTXOs: transaction.nextUTXOs, estimatedFees: transaction.estimatedFees, challengeSolution: transaction.challenge, interactionAddress: transaction.interactionAddress, fundingUTXOs: transaction.fundingUTXOs, fundingInputUtxos: transaction.fundingInputUtxos, compiledTargetScript: transaction.compiledTargetScript, utxoTracking, }; } /** * Broadcasts a pre-signed interaction transaction. * Uses sendRawTransactionPackage for atomic broadcast when a funding tx is present, * falls back to sendRawTransaction for P2WDA (interaction-only) transactions. * @param {SignedInteractionTransactionReceipt} signedTx - The signed transaction data. * @returns {Promise} The transaction receipt with broadcast results. */ public async sendPresignedTransaction( signedTx: SignedInteractionTransactionReceipt, ): Promise { if (signedTx.utxoTracking.isP2WDA || !signedTx.fundingTransactionRaw) { // P2WDA or no funding tx — broadcast interaction tx alone const tx = await this.#provider.sendRawTransaction( signedTx.interactionTransactionRaw, false, ); if (!tx || tx.error) { throw new Error(`Error sending transaction: ${tx?.error || 'Unknown error'}`); } if (!tx.result) { throw new Error('No transaction ID returned'); } if (!tx.success) { throw new Error(`Error sending transaction: ${tx.result || 'Unknown error'}`); } this.#processUTXOTracking(signedTx); return { interactionAddress: signedTx.interactionAddress, transactionId: tx.result, peerAcknowledgements: tx.peers || 0, newUTXOs: signedTx.nextUTXOs, estimatedFees: signedTx.estimatedFees, challengeSolution: signedTx.challengeSolution, rawTransaction: signedTx.interactionTransactionRaw, fundingUTXOs: signedTx.fundingUTXOs, fundingInputUtxos: signedTx.fundingInputUtxos, compiledTargetScript: signedTx.compiledTargetScript, }; } // Package broadcast: [funding, interaction] const result = await this.#provider.sendRawTransactionPackage( [signedTx.fundingTransactionRaw, signedTx.interactionTransactionRaw], true, ); if (!result.success) { throw new Error( `Error sending transaction package: ${result.error || 'Unknown error'}`, ); } // Check submitPackage per-tx failures if packageResult is present if (result.packageResult) { const failures = extractPackageFailures(result.packageResult); if (failures.length > 0) { throw new Error(`Transaction package failed:\n${failures.join('\n')}`); } } // Extract the interaction tx result (second tx in the package) const interactionSeqResult = result.sequentialResults?.[1]; if (interactionSeqResult && !interactionSeqResult.success) { throw new Error( `Interaction transaction failed: ${interactionSeqResult.error || 'Unknown error'}`, ); } const interactionTxId = interactionSeqResult?.txid || signedTx.interactionTransactionRaw; const peers = interactionSeqResult?.peers || 0; this.#processUTXOTracking(signedTx); return { interactionAddress: signedTx.interactionAddress, transactionId: interactionTxId, peerAcknowledgements: peers, newUTXOs: signedTx.nextUTXOs, estimatedFees: signedTx.estimatedFees, challengeSolution: signedTx.challengeSolution, rawTransaction: signedTx.interactionTransactionRaw, fundingUTXOs: signedTx.fundingUTXOs, fundingInputUtxos: signedTx.fundingInputUtxos, compiledTargetScript: signedTx.compiledTargetScript, }; } /** * Signs and broadcasts a bitcoin interaction transaction from a simulated contract call. * @param {TransactionParameters} interactionParams - The parameters for the transaction. * @param {bigint} amountAddition - Additional satoshis to request when acquiring UTXOs. * @returns {Promise} The transaction receipt with broadcast results. */ public async sendTransaction( interactionParams: TransactionParameters, amountAddition: bigint = 0n, ): Promise { try { const signedTx = await this.signTransaction(interactionParams, amountAddition); return await this.sendPresignedTransaction(signedTx); } catch (e) { const msgStr = (e as Error).message; if (msgStr.includes('Insufficient funds to pay the fees') && amountAddition === 0n) { return await this.sendTransaction(interactionParams, 200_000n); } this.#provider.utxoManager.clean(); throw e; } } /** * Set the gas estimation values. * @param {bigint} estimatedGas - The estimated gas in satoshis. * @param {bigint} refundedGas - The refunded gas in satoshis. */ public setGasEstimation(estimatedGas: bigint, refundedGas: bigint): void { this.estimatedSatGas = estimatedGas; this.estimatedRefundedGasInSat = refundedGas; } /** * Set the Bitcoin fee rates. * @param {BitcoinFees} fees - The Bitcoin fee rates. */ public setBitcoinFee(fees: BitcoinFees): void { this.#bitcoinFees = fees; } /** * Set the decoded contract output properties. * @param {DecodedOutput} decoded - The decoded output. */ public setDecoded(decoded: DecodedOutput): void { this.properties = Object.freeze(decoded.obj) as T; } /** * Set the contract events. * @param {U} events - The contract events. */ public setEvents(events: U): void { this.events = events; } /** * Set the calldata for the transaction. * @param {Uint8Array} calldata - The calldata. */ public setCalldata(calldata: Uint8Array): void { this.calldata = calldata; } /** * Serializes this CallResult to a Uint8Array. * Call this on an online device after simulation, then transfer the result * to an offline device for signing. * * @param {string} refundAddress - The address to fetch UTXOs from (your p2tr address). * @param {bigint} amount - The amount of satoshis needed for the transaction. * @returns {Promise} Serialized buffer ready for offline signing. * * @example * ```typescript * // Online device: prepare for offline signing * const simulation = await contract.transfer(recipient, amount); * const offlineBuffer = await simulation.toOfflineBuffer(wallet.p2tr, 50000n); * * // Save to file or encode as base64 for QR code * fs.writeFileSync('offline-tx.bin', offlineBuffer); * // Or: const qrData = offlineBuffer.toString('base64'); * ``` */ public async toOfflineBuffer(refundAddress: string, amount: bigint): Promise { if (!this.calldata) { throw new Error('Calldata not set'); } if (!this.to) { throw new Error('Contract address not set'); } if (!this.address) { throw new Error('Contract Address object not set'); } if (this.revert) { throw new Error(`Cannot serialize reverted simulation: ${this.revert}`); } // Fetch UTXOs and challenge while online const utxos = await this.#provider.utxoManager.getUTXOsForAmount({ address: refundAddress, amount: amount + this.estimatedSatGas + 10000n, // Add buffer for fees throwErrors: true, }); const challengeSolution = await this.#provider.getChallenge(); // Get network name const networkName = this.#getNetworkName(); return CallResultSerializer.serialize({ calldata: this.calldata, to: this.to, contractAddress: this.address.toHex(), estimatedSatGas: this.estimatedSatGas, estimatedRefundedGasInSat: this.estimatedRefundedGasInSat, revert: this.revert, result: fromBase64(this.#resultBase64), accessList: this.accessList, bitcoinFees: this.#bitcoinFees, network: networkName, estimatedGas: this.estimatedGas, refundedGas: this.refundedGas, challenge: challengeSolution.toRaw(), challengeOriginalPublicKey: challengeSolution.publicKey.originalPublicKeyBuffer(), utxos, csvAddress: this.csvAddress, }); } /** * Gets the NetworkName enum from the provider's network. * @returns {NetworkName} The network name enum value. */ #getNetworkName(): NetworkName { const network = this.#provider.network; if (network === networks.bitcoin) return NetworkName.Mainnet; if (network === networks.testnet) return NetworkName.Testnet; if (network === networks.opnetTestnet) return NetworkName.OpnetTestnet; if (network === networks.regtest) return NetworkName.Regtest; return NetworkName.Regtest; // Default fallback } /** * Clone a UTXO and attach a witness script. * @param {UTXO} utxo - The UTXO to clone. * @param {Uint8Array} witnessScript - The witness script to attach. * @returns {UTXO} The cloned UTXO with witness script. */ #cloneUTXOWithWitnessScript(utxo: UTXO, witnessScript: Uint8Array): UTXO { const clone = Object.assign( Object.create(Object.getPrototypeOf(utxo) as object) as UTXO, utxo, ); clone.witnessScript = witnessScript; return clone; } /** * Process UTXO tracking after transaction broadcast. * @param {SignedInteractionTransactionReceipt} signedTx - The signed transaction receipt. */ #processUTXOTracking(signedTx: SignedInteractionTransactionReceipt): void { const { csvUTXOs, p2wdaUTXOs, regularUTXOs, refundAddress, refundToAddress, csvAddress, p2wdaAddress, } = signedTx.utxoTracking; if (csvAddress && csvUTXOs.length) { const finalUTXOs = signedTx.nextUTXOs.map((u) => this.#cloneUTXOWithWitnessScript(u, csvAddress.witnessScript), ); this.#provider.utxoManager.spentUTXO( csvAddress.address, csvUTXOs, refundToAddress === csvAddress.address ? finalUTXOs : [], ); } if (p2wdaAddress && p2wdaUTXOs.length) { const finalUTXOs = signedTx.nextUTXOs.map((u) => this.#cloneUTXOWithWitnessScript(u, p2wdaAddress.witnessScript), ); this.#provider.utxoManager.spentUTXO( p2wdaAddress.address, p2wdaUTXOs, refundToAddress === p2wdaAddress.address ? finalUTXOs : [], ); } if (regularUTXOs.length) { this.#provider.utxoManager.spentUTXO( refundAddress, regularUTXOs, refundToAddress === refundAddress ? signedTx.nextUTXOs : [], ); } if (csvAddress && refundToAddress === csvAddress.address && !csvUTXOs.length) { const finalUTXOs = signedTx.nextUTXOs.map((u) => this.#cloneUTXOWithWitnessScript(u, csvAddress.witnessScript), ); this.#provider.utxoManager.spentUTXO(csvAddress.address, [], finalUTXOs); } else if (p2wdaAddress && refundToAddress === p2wdaAddress.address && !p2wdaUTXOs.length) { const finalUTXOs = signedTx.nextUTXOs.map((u) => this.#cloneUTXOWithWitnessScript(u, p2wdaAddress.witnessScript), ); this.#provider.utxoManager.spentUTXO(p2wdaAddress.address, [], finalUTXOs); } else if (refundToAddress === refundAddress && !regularUTXOs.length) { const isSpecialAddress = (csvAddress && refundToAddress === csvAddress.address) || (p2wdaAddress && refundToAddress === p2wdaAddress.address); if (!isSpecialAddress) { this.#provider.utxoManager.spentUTXO(refundAddress, [], signedTx.nextUTXOs); } } } private max(a: bigint, b: bigint): bigint { return a > b ? a : b; } private ensureUTXOsAvailable( utxos: UTXO[] | undefined | null, ): asserts utxos is UTXO[] & { length: number } { if (!utxos || utxos.length === 0) { throw new Error( 'Wallet optimization required. No UTXOs available. You may need to split your wallet UTXOs so at ' + 'least one non-extra-input UTXO is available for the funding transaction.', ); } } private computeRequiredAmount( gasFee: bigint, priority: bigint, amountAddition: bigint, totalOuts: bigint, extraInputValue: bigint, miningCost: bigint = 0n, maximumAllowedSatToSpend: bigint = 0n, ): bigint { const gross = this.max( gasFee + priority + amountAddition + totalOuts + miningCost, maximumAllowedSatToSpend, ); return gross > extraInputValue ? gross - extraInputValue : 1n; } /** * Acquire UTXOs for the transaction. * @param {TransactionParameters} interactionParams - The transaction parameters. * @param {bigint} amountAddition - Additional amount to request. * @returns {Promise} The acquired UTXOs. */ private async acquire( interactionParams: TransactionParameters, amountAddition: bigint = 0n, ): Promise { if (!this.calldata) { throw new Error('Calldata not set'); } if (!interactionParams.feeRate) { interactionParams.feeRate = 1.5; } const feeRate = interactionParams.feeRate; const priority = interactionParams.priorityFee ?? 0n; const addedOuts = interactionParams.extraOutputs ?? []; const totalOuts = addedOuts.reduce((s, o) => s + BigInt(o.value), 0n); const gasFee = this.bigintMax(this.estimatedSatGas, interactionParams.minGas ?? 0n); const extraInputValue = (interactionParams.extraInputs ?? []).reduce( (s, u) => s + u.value, 0n, ); const preWant = this.computeRequiredAmount( gasFee, priority, amountAddition, totalOuts, extraInputValue, 0n, interactionParams.maximumAllowedSatToSpend, ); let utxos = interactionParams.utxos ?? (await this.#fetchUTXOs(preWant, interactionParams)); this.ensureUTXOsAvailable(utxos); let refetched = false; while (true) { const miningCost = TransactionHelper.estimateMiningCost( utxos, addedOuts, this.calldata.length + 200, interactionParams.network, feeRate, ); const want = this.computeRequiredAmount( gasFee, priority, amountAddition, totalOuts, extraInputValue, miningCost, interactionParams.maximumAllowedSatToSpend, ); const have = utxos.reduce((s, u) => s + u.value, 0n); if (have >= want) break; if (refetched) { throw new Error('Not enough sat to complete transaction'); } utxos = await this.#fetchUTXOs(want, interactionParams); refetched = true; this.ensureUTXOsAvailable(utxos); const haveAfter = utxos.reduce((s, u) => s + u.value, 0n); if (haveAfter === have) { throw new Error('Not enough sat to complete transaction'); } } return utxos; } /** * Return the maximum of two bigints. * @param {bigint} a - First value. * @param {bigint} b - Second value. * @returns {bigint} The maximum value. */ private bigintMax(a: bigint, b: bigint): bigint { return a > b ? a : b; } /** * Fetch UTXOs from the provider. * @param {bigint} amount - The amount needed. * @param {TransactionParameters} interactionParams - The transaction parameters. * @returns {Promise} The fetched UTXOs. */ async #fetchUTXOs(amount: bigint, interactionParams: TransactionParameters): Promise { if (!interactionParams.sender && !interactionParams.refundTo) { throw new Error('Refund address not set'); } const utxoSetting: RequestUTXOsParamsWithAmount = { address: interactionParams.sender || interactionParams.refundTo, amount: amount, throwErrors: true, maxUTXOs: interactionParams.maxUTXOs, throwIfUTXOsLimitReached: interactionParams.throwIfUTXOsLimitReached, csvAddress: !interactionParams.p2wda && !interactionParams.dontUseCSVUtxos ? this.csvAddress?.address : undefined, }; let utxos: UTXO[] = await this.#provider.utxoManager.getUTXOsForAmount(utxoSetting); if (!utxos) { throw new Error('No UTXOs found'); } // Remove any UTXOs that overlap with extraInputs so they never count // toward the funding balance. Without this, acquire's `have` sum // includes value that signTransaction will strip out, causing the // funding transaction to be short or to double-spend the extra inputs. if (interactionParams.extraInputs && interactionParams.extraInputs.length > 0) { utxos = utxos.filter((utxo) => { if (!interactionParams.extraInputs) { throw new Error('extraInputs should be defined here'); } return !interactionParams.extraInputs.some( (extra) => extra.transactionId === utxo.transactionId && extra.outputIndex === utxo.outputIndex, ); }); } if (this.csvAddress) { const csvUtxos = utxos.filter((u) => u.isCSV === true); if (csvUtxos.length > 0) { for (const utxo of csvUtxos) { utxo.witnessScript = this.csvAddress.witnessScript; } } } if (interactionParams.p2wda) { if (!interactionParams.from) { throw new Error('From address not set in interaction parameters'); } const p2wda = interactionParams.from.p2wda(this.#provider.network); if ( interactionParams.sender ? p2wda.address === interactionParams.sender : p2wda.address === interactionParams.refundTo ) { utxos.forEach((utxo) => { utxo.witnessScript = p2wda.witnessScript; }); } } return utxos; } /** * Get storage keys from access list. * @returns {LoadedStorage} The loaded storage map. */ private getValuesFromAccessList(): LoadedStorage { const storage: LoadedStorage = {}; for (const contract in this.accessList) { const contractData = this.accessList[contract]; storage[contract] = Object.keys(contractData); } return storage; } /** * Convert contract address to p2op string. * @param {string} contract - The contract address hex. * @returns {string} The p2op address string. */ private contractToString(contract: string): string { const addressCa = Address.fromString(contract); return addressCa.p2op(this.#provider.network); } /** * Parse raw events into EventList format. * @param {RawEventList} events - The raw events. * @returns {EventList} The parsed events. */ private parseEvents(events: RawEventList): EventList { const eventsList: EventList = {}; for (const [contract, value] of Object.entries(events)) { const events: NetEvent[] = []; for (const event of value) { const eventData = new NetEvent(event.type, fromBase64(event.data)); events.push(eventData); } eventsList[this.contractToString(contract)] = events; } return eventsList; } /** * Convert base64 string to Uint8Array. * @param {string} base64 - The base64 encoded string. * @returns {Uint8Array} The decoded bytes. */ private base64ToUint8Array(base64: string): Uint8Array { return fromBase64(base64); } }