UNPKG

opnet

Version:

The perfect library for building Bitcoin-based applications.

560 lines (559 loc) 25 kB
import { fromBase64, fromHex, networks, toHex, } from '@btc-vision/bitcoin'; import { Address, BinaryReader, ChallengeSolution, NetEvent, TransactionFactory, } from '@btc-vision/transaction'; import { decodeRevertData } from '../utils/RevertDecoder.js'; import { CallResultSerializer, NetworkName } from './CallResultSerializer.js'; import { TransactionHelper } from './TransactionHelpper.js'; const factory = new TransactionFactory(); function extractPackageFailures(packageResult) { const failures = []; 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; } export class CallResult { result; accessList; revert; constant = false; payable = false; calldata; loadedStorage; estimatedGas; refundedGas; properties = {}; estimatedSatGas = 0n; estimatedRefundedGasInSat = 0n; events = []; to; address; fromAddress; csvAddress; #bitcoinFees; #rawEvents; #provider; #resultBase64; constructor(callResult, provider) { 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); } 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 { this.#resultBase64 = ''; this.result = callResult.result; } } get rawEvents() { return this.#rawEvents; } static decodeRevertData(revertDataBytes) { return decodeRevertData(revertDataBytes); } static fromOfflineBuffer(input) { const buffer = typeof input === 'string' ? fromHex(input) : input; const data = CallResultSerializer.deserialize(buffer); const network = CallResult.resolveNetwork(data.network); const challengeWithOriginalKey = { ...data.challenge, legacyPublicKey: '0x' + toHex(data.challengeOriginalPublicKey), }; const challengeSolution = new ChallengeSolution(challengeWithOriginalKey); const offlineProvider = { 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; }, }; const callResultData = { result: data.result, accessList: data.accessList, events: {}, revert: undefined, estimatedGas: data.estimatedGas?.toString(), specialGas: data.refundedGas?.toString(), }; const callResult = new CallResult(callResultData, offlineProvider); 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; } static resolveNetwork(networkName) { 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; } } setTo(to, address) { this.to = to; this.address = address; } setFromAddress(from) { this.fromAddress = from; this.csvAddress = this.fromAddress && this.fromAddress.originalPublicKey ? this.#provider.getCSV1ForAddress(this.fromAddress) : undefined; } async signTransaction(interactionParams, amountAddition = 0n) { 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 = 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 = interactionParams.priorityFee || 0n; const challenge = 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 = 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; 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 = { 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, }; } async sendPresignedTransaction(signedTx) { if (signedTx.utxoTracking.isP2WDA || !signedTx.fundingTransactionRaw) { 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, }; } 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'}`); } if (result.packageResult) { const failures = extractPackageFailures(result.packageResult); if (failures.length > 0) { throw new Error(`Transaction package failed:\n${failures.join('\n')}`); } } 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, }; } async sendTransaction(interactionParams, amountAddition = 0n) { try { const signedTx = await this.signTransaction(interactionParams, amountAddition); return await this.sendPresignedTransaction(signedTx); } catch (e) { const msgStr = e.message; if (msgStr.includes('Insufficient funds to pay the fees') && amountAddition === 0n) { return await this.sendTransaction(interactionParams, 200000n); } this.#provider.utxoManager.clean(); throw e; } } setGasEstimation(estimatedGas, refundedGas) { this.estimatedSatGas = estimatedGas; this.estimatedRefundedGasInSat = refundedGas; } setBitcoinFee(fees) { this.#bitcoinFees = fees; } setDecoded(decoded) { this.properties = Object.freeze(decoded.obj); } setEvents(events) { this.events = events; } setCalldata(calldata) { this.calldata = calldata; } async toOfflineBuffer(refundAddress, amount) { 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}`); } const utxos = await this.#provider.utxoManager.getUTXOsForAmount({ address: refundAddress, amount: amount + this.estimatedSatGas + 10000n, throwErrors: true, }); const challengeSolution = await this.#provider.getChallenge(); 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, }); } #getNetworkName() { 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; } #cloneUTXOWithWitnessScript(utxo, witnessScript) { const clone = Object.assign(Object.create(Object.getPrototypeOf(utxo)), utxo); clone.witnessScript = witnessScript; return clone; } #processUTXOTracking(signedTx) { 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); } } } max(a, b) { return a > b ? a : b; } ensureUTXOsAvailable(utxos) { 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.'); } } computeRequiredAmount(gasFee, priority, amountAddition, totalOuts, extraInputValue, miningCost = 0n, maximumAllowedSatToSpend = 0n) { const gross = this.max(gasFee + priority + amountAddition + totalOuts + miningCost, maximumAllowedSatToSpend); return gross > extraInputValue ? gross - extraInputValue : 1n; } async acquire(interactionParams, amountAddition = 0n) { 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; } bigintMax(a, b) { return a > b ? a : b; } async #fetchUTXOs(amount, interactionParams) { if (!interactionParams.sender && !interactionParams.refundTo) { throw new Error('Refund address not set'); } const utxoSetting = { 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 = await this.#provider.utxoManager.getUTXOsForAmount(utxoSetting); if (!utxos) { throw new Error('No UTXOs found'); } 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; } getValuesFromAccessList() { const storage = {}; for (const contract in this.accessList) { const contractData = this.accessList[contract]; storage[contract] = Object.keys(contractData); } return storage; } contractToString(contract) { const addressCa = Address.fromString(contract); return addressCa.p2op(this.#provider.network); } parseEvents(events) { const eventsList = {}; for (const [contract, value] of Object.entries(events)) { const events = []; for (const event of value) { const eventData = new NetEvent(event.type, fromBase64(event.data)); events.push(eventData); } eventsList[this.contractToString(contract)] = events; } return eventsList; } base64ToUint8Array(base64) { return fromBase64(base64); } }