UNPKG

@vechain/sdk-network

Version:

This module serves as the standard interface connecting decentralized applications (dApps) and users to the VeChainThor blockchain

1,214 lines (1,127 loc) 47.2 kB
import { ABI, ABIContract, type ABIFunction, Address, Clause, type ContractClause, dataUtils, Hex, HexUInt, Revision, ThorId, Transaction, type TransactionBody, type TransactionClause, Units, VET } from '@vechain/sdk-core'; import { InvalidDataType, InvalidTransactionField, HttpNetworkError } from '@vechain/sdk-errors'; import { ErrorFragment, Interface } from 'ethers'; import { HttpMethod } from '../../http'; import { blocksFormatter, getTransactionIndexIntoBlock } from '../../provider'; import type { VeChainSigner } from '../../signer'; import { buildQuery, BUILT_IN_CONTRACTS, ERROR_SELECTOR, PANIC_SELECTOR, thorest, vnsUtils } from '../../utils'; import { type BlocksModule, type ExpandedBlockDetail } from '../blocks'; import type { ContractCallOptions, ContractCallResult, ContractTransactionOptions } from '../contracts'; import { type CallNameReturnType, type DebugModule } from '../debug'; import { type ForkDetector } from '../fork'; import { type GasModule } from '../gas'; import { decodeRevertReason } from '../gas/helpers/decode-evm-error'; import type { EstimateGasOptions, EstimateGasResult } from '../gas/types'; import { type LogsModule } from '../logs'; import { type GetTransactionInputOptions, type GetTransactionReceiptInputOptions, type SendTransactionResult, type SimulateTransactionClause, type SimulateTransactionOptions, type TransactionBodyOptions, type TransactionDetailNoRaw, type TransactionDetailRaw, type TransactionReceipt, type TransactionSimulationResult, type WaitForTransactionOptions } from './types'; /** * The `TransactionsModule` handles transaction related operations and provides * convenient methods for sending transactions and waiting for transaction confirmation. */ class TransactionsModule { readonly blocksModule: BlocksModule; readonly debugModule: DebugModule; readonly logsModule: LogsModule; readonly gasModule: GasModule; readonly forkDetector: ForkDetector; constructor( blocksModule: BlocksModule, debugModule: DebugModule, logsModule: LogsModule, gasModule: GasModule, forkDetector: ForkDetector ) { this.blocksModule = blocksModule; this.debugModule = debugModule; this.logsModule = logsModule; this.gasModule = gasModule; this.forkDetector = forkDetector; } /** * Retrieves the details of a transaction. * * @param id - Transaction ID of the transaction to retrieve. * @param options - (Optional) Other optional parameters for the request. * @returns A promise that resolves to the details of the transaction. * @throws {InvalidDataType} */ public async getTransaction( id: string, options?: GetTransactionInputOptions ): Promise<TransactionDetailNoRaw | null> { // Invalid transaction ID if (!ThorId.isValid(id)) { throw new InvalidDataType( 'TransactionsModule.getTransaction()', 'Invalid transaction ID given as input. Input must be an hex string of length 64.', { id } ); } // Invalid head if (options?.head !== undefined && !ThorId.isValid(options.head)) throw new InvalidDataType( 'TransactionsModule.getTransaction()', 'Invalid head given as input. Input must be an hex string of length 64.', { head: options?.head } ); return (await this.blocksModule.httpClient.http( HttpMethod.GET, thorest.transactions.get.TRANSACTION(id), { query: buildQuery({ raw: false, head: options?.head, pending: options?.pending }) } )) as TransactionDetailNoRaw | null; } /** * Retrieves the details of a transaction. * * @param id - Transaction ID of the transaction to retrieve. * @param options - (Optional) Other optional parameters for the request. * @returns A promise that resolves to the details of the transaction. * @throws {InvalidDataType} */ public async getTransactionRaw( id: string, options?: GetTransactionInputOptions ): Promise<TransactionDetailRaw | null> { // Invalid transaction ID if (!ThorId.isValid(id)) { throw new InvalidDataType( 'TransactionsModule.getTransactionRaw()', 'Invalid transaction ID given as input. Input must be an hex string of length 64.', { id } ); } // Invalid head if (options?.head !== undefined && !ThorId.isValid(options.head)) throw new InvalidDataType( 'TransactionsModule.getTransaction()', 'Invalid head given as input. Input must be an hex string of length 64.', { head: options?.head } ); return (await this.blocksModule.httpClient.http( HttpMethod.GET, thorest.transactions.get.TRANSACTION(id), { query: buildQuery({ raw: true, head: options?.head, pending: options?.pending }) } )) as TransactionDetailRaw | null; } /** * Retrieves the receipt of a transaction. * * @param id - Transaction ID of the transaction to retrieve. * @param options - (Optional) Other optional parameters for the request. * If `head` is not specified, the receipt of the transaction at the best block is returned. * @returns A promise that resolves to the receipt of the transaction. * @throws {InvalidDataType} */ public async getTransactionReceipt( id: string, options?: GetTransactionReceiptInputOptions ): Promise<TransactionReceipt | null> { // Invalid transaction ID if (!ThorId.isValid(id)) { throw new InvalidDataType( 'TransactionsModule.getTransactionReceipt()', 'Invalid transaction ID given as input. Input must be an hex string of length 64.', { id } ); } // Invalid head if (options?.head !== undefined && !ThorId.isValid(options.head)) throw new InvalidDataType( 'TransactionsModule.getTransaction()', 'Invalid head given as input. Input must be an hex string of length 64.', { head: options?.head } ); try { return (await this.blocksModule.httpClient.http( HttpMethod.GET, thorest.transactions.get.TRANSACTION_RECEIPT(id), { query: buildQuery({ head: options?.head }) } )) as TransactionReceipt | null; } catch (error) { // Check if this is a network communication error if (error instanceof HttpNetworkError) { // For network errors, return null instead of throwing // This allows the polling mechanism to continue return null; } throw error; } } /** * Retrieves the receipt of a transaction. * * @param raw - The raw transaction. * @returns The transaction id of send transaction. * @throws {InvalidDataType} */ public async sendRawTransaction( raw: string ): Promise<SendTransactionResult> { // Validate raw transaction if (!Hex.isValid0x(raw)) { throw new InvalidDataType( 'TransactionsModule.sendRawTransaction()', 'Sending failed: Input must be a valid raw transaction in hex format.', { raw } ); } // Decode raw transaction to check if raw is ok try { Transaction.decode(HexUInt.of(raw.slice(2)).bytes, true); } catch (error) { throw new InvalidDataType( 'TransactionsModule.sendRawTransaction()', 'Sending failed: Input must be a valid raw transaction in hex format. Decoding error encountered.', { raw }, error ); } const transactionResult = (await this.blocksModule.httpClient.http( HttpMethod.POST, thorest.transactions.post.TRANSACTION(), { body: { raw } } )) as SendTransactionResult; return { id: transactionResult.id, wait: async (options?: WaitForTransactionOptions) => await this.waitForTransaction(transactionResult.id, options) }; } /** * Sends a signed transaction to the network. * * @param signedTx - the transaction to send. It must be signed. * @returns A promise that resolves to the transaction ID of the sent transaction. * @throws {InvalidDataType} */ public async sendTransaction( signedTx: Transaction ): Promise<SendTransactionResult> { // Assert transaction is signed or not if (!signedTx.isSigned) { throw new InvalidDataType( 'TransactionsModule.sendTransaction()', 'Invalid transaction given as input. Transaction must be signed.', { signedTx } ); } const rawTx = Hex.of(signedTx.encoded).toString(); return await this.sendRawTransaction(rawTx); } /** * Waits for a transaction to be included in a block. * * @param txID - The transaction ID of the transaction to wait for. * @param options - Optional parameters for the request. Includes the timeout and interval between requests. * Both parameters are in milliseconds. If the timeout is not specified, the request will not time out! * @returns A promise that resolves to the transaction receipt of the transaction. If the transaction is not included in a block before the timeout, * the promise will resolve to `null`. * @throws {InvalidDataType} */ public async waitForTransaction( txID: string, options?: WaitForTransactionOptions ): Promise<TransactionReceipt | null> { // Invalid transaction ID if (!ThorId.isValid(txID)) { throw new InvalidDataType( 'TransactionsModule.waitForTransaction()', 'Invalid transaction ID given as input. Input must be an hex string of length 64.', { txID } ); } // If no timeout is specified, use default timeout of 30 seconds const timeoutMs = options?.timeoutMs ?? 30000; const intervalMs = options?.intervalMs ?? 1000; const startTime = Date.now(); const deadline = startTime + timeoutMs; while (true) { // Check if timeout has been reached if (Date.now() >= deadline) { return null; } // Try to get the transaction receipt const receipt = await this.getTransactionReceipt(txID).catch( () => null ); if (receipt !== null) { return receipt; } // Wait for the specified interval before trying again await new Promise((resolve) => setTimeout(resolve, intervalMs)); } } /** * Builds a transaction body with the given clauses without having to * specify the chainTag, expiration, gasPriceCoef, gas, dependsOn and reserved fields. * * @param clauses - The clauses of the transaction. * @param gas - The gas to be used to perform the transaction. * @param options - Optional parameters for the request. Includes the expiration, gasPriceCoef, maxFeePerGas, maxPriorityFeePerGas, gas, dependsOn and isDelegated fields. * If the `expiration` is not specified, the transaction will expire after 32 blocks. * If the `gasPriceCoef` is not specified & galactica fork didn't happen yet, the transaction will use the default gas price coef of 0. * If the `gasPriceCoef` is not specified & galactica fork happened, the transaction will use the default maxFeePerGas and maxPriorityFeePerGas. * If the `gas` is specified in options, it will override the gas parameter. * If the `dependsOn` is not specified, the transaction will not depend on any other transaction. * If the `isDelegated` is not specified, the transaction will not be delegated. * * @returns A promise that resolves to the transaction body. * * @throws an error if the genesis block or the latest block cannot be retrieved. */ public async buildTransactionBody( clauses: TransactionClause[] | Clause[] | ContractClause['clause'], gas: number, options?: TransactionBodyOptions ): Promise<TransactionBody> { // Get the genesis block to get the chainTag const genesisBlock = await this.blocksModule.getBlockCompressed(0); if (genesisBlock === null) throw new InvalidTransactionField( 'TransactionsModule.buildTransactionBody()', 'Error while building transaction body: Cannot get genesis block.', { fieldName: 'genesisBlock', genesisBlock, clauses, options } ); const blockRef = options?.blockRef ?? (await this.blocksModule.getBestBlockRef()); if (blockRef === null) throw new InvalidTransactionField( 'TransactionsModule.buildTransactionBody()', 'Error while building transaction body: Cannot get blockRef.', { fieldName: 'blockRef', blockRef, clauses, options } ); const chainTag = options?.chainTag ?? Number(`0x${genesisBlock.id.slice(-2)}`); const filledOptions = await this.fillDefaultBodyOptions(options); // Process clauses - handle different clause types properly let processedClauses: TransactionClause[]; if (Array.isArray(clauses)) { // This is a TransactionClause[] or Clause[] - convert to TransactionClause[] processedClauses = clauses.map((clause) => ({ to: clause.to, data: clause.data, value: clause.value })); } else { // Single TransactionClause or Clause processedClauses = [ { to: clauses.to, data: clauses.data, value: clauses.value } ]; } return { blockRef, chainTag, clauses: await this.resolveNamesInClauses(processedClauses), dependsOn: options?.dependsOn ?? null, expiration: options?.expiration ?? 32, gas: options?.gas !== undefined ? Number(options.gas) : gas, gasPriceCoef: filledOptions?.gasPriceCoef, maxFeePerGas: filledOptions?.maxFeePerGas, maxPriorityFeePerGas: filledOptions?.maxPriorityFeePerGas, nonce: options?.nonce ?? Hex.random(8).toString(), reserved: options?.isDelegated === true ? { features: 1 } : undefined }; } /** * Fills the transaction body with the default options. * * @param body - The transaction body to fill. * @returns A promise that resolves to the filled transaction body. * @throws {InvalidDataType} */ public async fillTransactionBody( body: TransactionBody ): Promise<TransactionBody> { const extractedOptions: TransactionBodyOptions = { maxFeePerGas: body.maxFeePerGas, maxPriorityFeePerGas: body.maxPriorityFeePerGas, gasPriceCoef: body.gasPriceCoef }; const filledOptions = await this.fillDefaultBodyOptions(extractedOptions); return { ...body, ...filledOptions }; } /** * Fills the default body options for a transaction. * * @param options - The transaction body options to fill. * @returns A promise that resolves to the filled transaction body options. * @throws {InvalidDataType} */ public async fillDefaultBodyOptions( options?: TransactionBodyOptions ): Promise<TransactionBodyOptions> { options ??= {}; // Check for invalid parameter combinations first const hasMaxFeePerGas = options.maxFeePerGas !== undefined; const hasMaxPriorityFeePerGas = options.maxPriorityFeePerGas !== undefined; const hasGasPriceCoef = options.gasPriceCoef !== undefined; // Case 3: maxPriorityFeePerGas + gasPriceCoef (error) if (hasMaxPriorityFeePerGas && hasGasPriceCoef && !hasMaxFeePerGas) { throw new InvalidDataType( 'TransactionsModule.fillDefaultBodyOptions()', 'Invalid parameter combination: maxPriorityFeePerGas and gasPriceCoef cannot be used together without maxFeePerGas.', { options } ); } // Case 4: maxFeePerGas + gasPriceCoef (error) if (hasMaxFeePerGas && hasGasPriceCoef && !hasMaxPriorityFeePerGas) { throw new InvalidDataType( 'TransactionsModule.fillDefaultBodyOptions()', 'Invalid parameter combination: maxFeePerGas and gasPriceCoef cannot be used together without maxPriorityFeePerGas.', { options } ); } // Case 1: maxPriorityFeePerGas + maxFeePerGas + gasPriceCoef (only 1 and 2 are used) if (hasMaxPriorityFeePerGas && hasMaxFeePerGas && hasGasPriceCoef) { options.gasPriceCoef = undefined; // Continue with dynamic fee processing below } else if (hasMaxPriorityFeePerGas && hasMaxFeePerGas) { // Case 2: maxPriorityFeePerGas + maxFeePerGas (1 and 2 are used) options.gasPriceCoef = undefined; // Continue with dynamic fee processing below } else if ( hasGasPriceCoef && !hasMaxPriorityFeePerGas && !hasMaxFeePerGas ) { // Case 5: gasPriceCoef only (3 is used - legacy transaction) options.maxFeePerGas = undefined; options.maxPriorityFeePerGas = undefined; return options; } // check if fork happened const galacticaHappened = await this.forkDetector.isGalacticaForked('best'); if ( !galacticaHappened && (hasMaxFeePerGas || hasMaxPriorityFeePerGas) ) { // user has specified dynamic fee tx, but fork didn't happen yet throw new InvalidDataType( 'TransactionsModule.fillDefaultBodyOptions()', 'Invalid transaction body options. Dynamic fee tx is not allowed before Galactica fork.', { options } ); } if (!galacticaHappened && !hasGasPriceCoef) { // galactica hasn't happened yet, default is legacy fee options.gasPriceCoef = 0; return options; } if (galacticaHappened && hasMaxFeePerGas && hasMaxPriorityFeePerGas) { // galactica happened, user specified new fee type return options; } // default to dynamic fee tx options.gasPriceCoef = undefined; // Get next block base fee per gas const biNextBlockBaseFeePerGas = await this.gasModule.getNextBlockBaseFeePerGas(); if ( biNextBlockBaseFeePerGas === null || biNextBlockBaseFeePerGas === undefined ) { throw new InvalidDataType( 'TransactionsModule.fillDefaultBodyOptions()', 'Invalid transaction body options. Unable to get next block base fee per gas.', { options } ); } // set maxPriorityFeePerGas if not specified already if ( options.maxPriorityFeePerGas === undefined || options.maxPriorityFeePerGas === null ) { // Calculate maxPriorityFeePerGas based on fee history (75th percentile) // and the HIGH speed threshold (min(0.046*baseFee, 75_percentile)) const defaultMaxPriorityFeePerGas = await this.calculateDefaultMaxPriorityFeePerGas( biNextBlockBaseFeePerGas ); options.maxPriorityFeePerGas = defaultMaxPriorityFeePerGas; } // set maxFeePerGas if not specified already if ( options.maxFeePerGas === undefined || options.maxFeePerGas === null ) { // compute maxFeePerGas const biMaxPriorityFeePerGas = HexUInt.of( options.maxPriorityFeePerGas ).bi; // maxFeePerGas = 1.12 * baseFeePerGas + maxPriorityFeePerGas const biMaxFeePerGas = (112n * biNextBlockBaseFeePerGas) / 100n + biMaxPriorityFeePerGas; options.maxFeePerGas = HexUInt.of(biMaxFeePerGas).toString(); } return options; } /** * Calculates the default max priority fee per gas based on the current base fee * and historical 75th percentile rewards. * * Uses the FAST (HIGH) speed threshold: min(0.046*baseFee, 75_percentile) * * @param baseFee - The current base fee per gas * @returns A promise that resolves to the default max priority fee per gas as a hex string */ private async calculateDefaultMaxPriorityFeePerGas( baseFee: bigint ): Promise<string> { // Get fee history for recent blocks const feeHistory = await this.gasModule.getFeeHistory({ blockCount: 10, newestBlock: 'best', rewardPercentiles: [25, 50, 75] // Get 25th, 50th and 75th percentiles }); // Get the 75th percentile reward from the most recent block let percentile75: bigint; if ( feeHistory.reward !== null && feeHistory.reward !== undefined && feeHistory.reward.length > 0 ) { const latestBlockRewards = feeHistory.reward[feeHistory.reward.length - 1]; const equalRewardsOnLastBlock = new Set(latestBlockRewards).size === 3; // If rewards are equal in the last block, use the first one (75th percentile) // Otherwise, calculate the average of 75th percentiles across blocks if (equalRewardsOnLastBlock) { percentile75 = HexUInt.of(latestBlockRewards[2]).bi; // 75th percentile at index 2 } else { // Calculate average of 75th percentiles across blocks let sum = 0n; let count = 0; for (const blockRewards of feeHistory.reward) { if ( blockRewards.length !== null && blockRewards.length > 2 && blockRewards[2] !== null && blockRewards[2] !== undefined ) { sum += HexUInt.of(blockRewards[2]).bi; count++; } } percentile75 = count > 0 ? sum / BigInt(count) : 0n; } } else { // Fallback to getMaxPriorityFeePerGas if fee history is not available percentile75 = HexUInt.of( await this.gasModule.getMaxPriorityFeePerGas() ).bi; } // Calculate 4.6% of base fee (HIGH speed threshold) const baseFeeCap = (baseFee * 46n) / 1000n; // 0.046 * baseFee // Use the minimum of the two values const priorityFee = baseFeeCap < percentile75 ? baseFeeCap : percentile75; return HexUInt.of(priorityFee).toString(); } /** * Ensures that names in clauses are resolved to addresses * * @param clauses - The clauses of the transaction. * @returns A promise that resolves to clauses with resolved addresses */ public async resolveNamesInClauses( clauses: TransactionClause[] ): Promise<TransactionClause[]> { // find unique names in the clause list const uniqueNames = clauses.reduce((map, clause) => { if ( typeof clause.to === 'string' && !map.has(clause.to) && clause.to.includes('.') ) { map.set(clause.to, clause.to); } return map; }, new Map<string, string>()); const nameList = [...uniqueNames.keys()]; // no names, return the original clauses if (uniqueNames.size === 0) { return clauses; } // resolve the names to addresses const addresses = await vnsUtils.resolveNames( this.blocksModule, this, nameList ); // map unique names with resolved addresses addresses.forEach((address, index) => { if (address !== null) { uniqueNames.set(nameList[index], address); } }); // replace names with resolved addresses, or leave unchanged return clauses.map((clause) => { if (typeof clause.to !== 'string') { return clause; } return { to: uniqueNames.get(clause.to) ?? clause.to, data: clause.data, value: clause.value }; }); } /** * Simulates the execution of a transaction. * Allows to estimate the gas cost of a transaction without sending it, as well as to retrieve the return value(s) of the transaction. * * @param clauses - The clauses of the transaction to simulate. * @param options - (Optional) The options for simulating the transaction. * @returns A promise that resolves to an array of simulation results. * Each element of the array represents the result of simulating a clause. * @throws {InvalidDataType} */ public async simulateTransaction( clauses: SimulateTransactionClause[], options?: SimulateTransactionOptions ): Promise<TransactionSimulationResult[]> { const { revision, caller, gasPrice, gasPayer, gas, blockRef, expiration, provedWork } = options ?? {}; if ( revision !== undefined && revision !== null && !Revision.isValid(revision.toString()) ) { throw new InvalidDataType( 'TransactionsModule.simulateTransaction()', 'Invalid revision given as input. Input must be a valid revision (i.e., a block number or block ID).', { revision } ); } return (await this.blocksModule.httpClient.http( HttpMethod.POST, thorest.accounts.post.SIMULATE_TRANSACTION(revision?.toString()), { query: buildQuery({ revision: revision?.toString() }), body: { clauses: await this.resolveNamesInClauses( clauses.map((clause) => { return { to: clause.to, data: clause.data, value: BigInt(clause.value).toString() }; }) ), gas, gasPrice, caller, provedWork, gasPayer, expiration, blockRef } } )) as TransactionSimulationResult[]; } /** * Decode the revert reason from the encoded revert reason into a transaction. * * @param encodedRevertReason - The encoded revert reason to decode. * @param errorFragment - (Optional) The error fragment to use to decode the revert reason (For Solidity custom errors). * @returns A promise that resolves to the decoded revert reason. * Revert reason can be a string error or Panic(error_code) */ public decodeRevertReason( encodedRevertReason: string, errorFragment?: string ): string { // Error selector if (encodedRevertReason.startsWith(ERROR_SELECTOR)) return ABI.ofEncoded( 'string', `0x${encodedRevertReason.slice(ERROR_SELECTOR.length)}` ).getFirstDecodedValue(); // Panic selector else if (encodedRevertReason.startsWith(PANIC_SELECTOR)) { const decoded = ABI.ofEncoded( 'uint256', `0x${encodedRevertReason.slice(PANIC_SELECTOR.length)}` ).getFirstDecodedValue<string>(); return `Panic(0x${parseInt(decoded).toString(16).padStart(2, '0')})`; } // Solidity error, an error fragment is provided, so decode the revert reason using solidity error else if (errorFragment !== undefined) { const errorInterface = new Interface([ ErrorFragment.from(errorFragment) ]); return errorInterface .decodeErrorResult( ErrorFragment.from(errorFragment), encodedRevertReason ) .toArray()[0] as string; } // Unknown revert reason (we know ONLY that transaction is reverted) return ``; } /** * Get the revert reason of an existing transaction. * * @param transactionHash - The hash of the transaction to get the revert reason for. * @param errorFragment - (Optional) The error fragment to use to decode the revert reason (For Solidity custom errors). * @returns A promise that resolves to the revert reason of the transaction. */ public async getRevertReason( transactionHash: string, errorFragment?: string ): Promise<string | null> { // 1 - Init Blocks and Debug modules const blocksModule = this.blocksModule; const debugModule = this.debugModule; // 2 - Get the transaction details const transaction = await this.getTransaction(transactionHash); // 3 - Get the block details (to get the transaction index) const block = transaction !== null ? ((await blocksModule.getBlockExpanded( transaction.meta.blockID )) as ExpandedBlockDetail) : null; // Block or transaction not found if (block === null || transaction === null) return null; // 4 - Get the transaction index into the block (we know the transaction is in the block) const transactionIndex = getTransactionIndexIntoBlock( blocksFormatter.formatToRPCStandard(block, ''), transactionHash ); // 5 - Get the error or panic reason. By iterating over the clauses of the transaction for ( let transactionClauseIndex = 0; transactionClauseIndex < transaction.clauses.length; transactionClauseIndex++ ) { // 5.1 - Debug the clause const debuggedClause = (await debugModule.traceTransactionClause( { target: { blockId: ThorId.of(block.id), transaction: transactionIndex, clauseIndex: transactionClauseIndex }, // Optimized for top call config: { OnlyTopCall: true } }, 'call' )) as CallNameReturnType; // 5.2 - Error or panic present, so decode the revert reason if (debuggedClause.output !== undefined) { return this.decodeRevertReason( debuggedClause.output, errorFragment ); } } // No revert reason found return null; } /** * Estimates the amount of gas required to execute a set of transaction clauses. * * @param {SimulateTransactionClause[]} clauses - An array of clauses to be simulated. Must contain at least one clause. * @param {string} [caller] - The address initiating the transaction. Optional. * @param {EstimateGasOptions} [options] - Additional options for the estimation, including gas padding. * @return {Promise<EstimateGasResult>} - The estimated gas result, including total gas required, whether the transaction reverted, revert reasons, and any VM errors. * @throws {InvalidDataType} - If clauses array is empty or if gas padding is not within the range (0, 1]. * * @see {@link TransactionsModule#simulateTransaction} */ public async estimateGas( clauses: (SimulateTransactionClause | ContractClause)[], caller?: string, options?: EstimateGasOptions ): Promise<EstimateGasResult> { // Normalize to SimulateTransactionClause[] const clausesToEstimate: SimulateTransactionClause[] = clauses.map( (clause) => { if (clause === undefined) { throw new InvalidDataType( 'TransactionsModule.estimateGas()', 'Invalid ContractClause provided: missing inner clause.', { clause } ); } if ('clause' in clause) { return clause.clause; } return clause; } ); // Validate the normalized set is non-empty if (clausesToEstimate.length === 0) { throw new InvalidDataType( 'TransactionsModule.estimateGas()', 'Invalid clauses. Clauses must be an array with at least one clause.', { clauses, caller, options } ); } // gasPadding must be a number between (0, 1] if ( options?.gasPadding !== undefined && (options.gasPadding <= 0 || options.gasPadding > 1) ) { throw new InvalidDataType( 'GasModule.estimateGas()', 'Invalid gasPadding. gasPadding must be a number between (0, 1].', { gasPadding: options?.gasPadding } ); } // Simulate the transaction to get the simulations of each clause const simulations = await this.simulateTransaction(clausesToEstimate, { caller, ...options }); // If any of the clauses reverted, then the transaction reverted const isReverted = simulations.some((simulation) => { return simulation.reverted; }); // The intrinsic gas of the transaction const intrinsicGas = Number( Transaction.intrinsicGas(clausesToEstimate).wei ); // totalSimulatedGas represents the summation of all clauses' gasUsed const totalSimulatedGas = simulations.reduce((sum, simulation) => { return sum + simulation.gasUsed; }, 0); // The total gas of the transaction // If the transaction involves contract interaction, a constant 15000 gas is added to the total gas const totalGas = Math.ceil( (intrinsicGas + (totalSimulatedGas !== 0 ? totalSimulatedGas + 15000 : 0)) * (1 + (options?.gasPadding ?? 0)) ); // Add gasPadding if it is defined return isReverted ? { totalGas, reverted: true, revertReasons: simulations.map((simulation) => { /** * The decoded revert reason of the transaction. * Solidity may revert with Error(string) or Panic(uint256). * * @link see [Error handling: Assert, Require, Revert and Exceptions](https://docs.soliditylang.org/en/latest/control-structures.html#error-handling-assert-require-revert-and-exceptions) */ return decodeRevertReason(simulation.data) ?? ''; }), vmErrors: simulations.map((simulation) => { return simulation.vmError; }) } : { totalGas, reverted: false, revertReasons: [], vmErrors: [] }; } /** * Executes a read-only call to a smart contract function, simulating the transaction to obtain the result. * * The method simulates a transaction using the provided parameters * without submitting it to the blockchain, allowing read-only operations * to be tested without incurring gas costs or modifying the blockchain state. * * @param {string} contractAddress - The address of the smart contract. * @param {ABIFunction} functionAbi - The ABI definition of the smart contract function to be called. * @param {unknown[]} functionData - The arguments to be passed to the smart contract function. * @param {ContractCallOptions} [contractCallOptions] - Optional parameters for the contract call execution. * @return {Promise<ContractCallResult>} The result of the contract call. */ public async executeCall( contractAddress: string, functionAbi: ABIFunction, functionData: unknown[], contractCallOptions?: ContractCallOptions ): Promise<ContractCallResult> { // Simulate the transaction to get the result of the contract call const response = await this.simulateTransaction( [ { to: contractAddress, value: '0', data: functionAbi.encodeData(functionData).toString() } ], contractCallOptions ); return this.getContractCallResult( response[0].data, functionAbi, response[0].reverted ); } /** * Executes and simulates multiple read-only smart-contract clause calls, * simulating the transaction to obtain the results. * * @param {ContractClause[]} clauses - The array of contract clauses to be executed. * @param {SimulateTransactionOptions} [options] - Optional simulation transaction settings. * @return {Promise<ContractCallResult[]>} - The decoded results of the contract calls. */ public async executeMultipleClausesCall( clauses: ContractClause[], options?: SimulateTransactionOptions ): Promise<ContractCallResult[]> { // Simulate the transaction to get the result of the contract call const response = await this.simulateTransaction( clauses.map((clause) => { if (clause.clause === undefined) { throw new InvalidDataType( 'TransactionsModule.executeMultipleClausesCall()', 'Invalid ContractClause provided: missing inner clause.', { clause } ); } return clause.clause; }), options ); // Returning the decoded results both as plain and array. return response.map((res, index) => this.getContractCallResult( res.data, clauses[index].functionAbi, res.reverted ) ); } /** * Executes a transaction with a smart-contract on the VeChain blockchain. * * @param {VeChainSigner} signer - The signer instance to sign the transaction. * @param {string} contractAddress - The address of the smart contract. * @param {ABIFunction} functionAbi - The ABI of the contract function to be called. * @param {unknown[]} functionData - The input parameters for the contract function. * @param {ContractTransactionOptions} [options] - Optional transaction parameters. * @return {Promise<SendTransactionResult>} - A promise that resolves to the result of the transaction. * * @see {@link TransactionsModule.buildTransactionBody} */ public async executeTransaction( signer: VeChainSigner, contractAddress: string, functionAbi: ABIFunction, functionData: unknown[], options?: ContractTransactionOptions ): Promise<SendTransactionResult> { // Sign the transaction const id = await signer.sendTransaction({ clauses: [ // Build a clause to interact with the contract function Clause.callFunction( Address.of(contractAddress), functionAbi, functionData, VET.of(options?.value ?? 0, Units.wei) ) ], gas: options?.gas, gasLimit: options?.gasLimit, gasPrice: options?.gasPrice, gasPriceCoef: options?.gasPriceCoef, maxFeePerGas: options?.maxFeePerGas, maxPriorityFeePerGas: options?.maxPriorityFeePerGas, nonce: options?.nonce, value: options?.value, dependsOn: options?.dependsOn, expiration: options?.expiration, chainTag: options?.chainTag, blockRef: options?.blockRef, delegationUrl: options?.delegationUrl, comment: options?.comment }); return { id, wait: async (options?: WaitForTransactionOptions) => await this.waitForTransaction(id, options) }; } /** * Executes a transaction with multiple clauses on the VeChain blockchain. * * @param {ContractClause[]} clauses - Array of contract clauses to be included in the transaction. * @param {VeChainSigner} signer - A VeChain signer instance used to sign and send the transaction. * @param {ContractTransactionOptions} [options] - Optional parameters to customize the transaction. * @return {Promise<SendTransactionResult>} The result of the transaction, including transaction ID and a wait function. */ public async executeMultipleClausesTransaction( clauses: ContractClause[] | TransactionClause[], signer: VeChainSigner, options?: ContractTransactionOptions ): Promise<SendTransactionResult> { const id = await signer.sendTransaction({ clauses: clauses.map((clause) => { if (clause === undefined) { throw new InvalidDataType( 'TransactionsModule.executeMultipleClausesTransaction()', 'Invalid ContractClause[] | TransactionClause[] provided: missing clause.', { clause } ); } if ('clause' in clause) { return (clause as unknown as ContractClause).clause; } return clause; }), gas: options?.gas, gasLimit: options?.gasLimit, gasPrice: options?.gasPrice, gasPriceCoef: options?.gasPriceCoef, maxFeePerGas: options?.maxFeePerGas, maxPriorityFeePerGas: options?.maxPriorityFeePerGas, nonce: options?.nonce, value: options?.value, dependsOn: options?.dependsOn, expiration: options?.expiration, chainTag: options?.chainTag, blockRef: options?.blockRef, delegationUrl: options?.delegationUrl, comment: options?.comment }); return { id, wait: async (options?: WaitForTransactionOptions) => await this.waitForTransaction(id, options) }; } /** * Retrieves the base gas price from the blockchain parameters. * * This method sends a call to the blockchain parameters contract to fetch the current base gas price. * The base gas price is the minimum gas price that can be used for a transaction. * It is used to obtain the VTHO (energy) cost of a transaction. * @link [Total Gas Price](https://docs.vechain.org/core-concepts/transactions/transaction-calculation#total-gas-price) * * @return {Promise<ContractCallResult>} A promise that resolves to the result of the contract call, containing the base gas price. */ public async getLegacyBaseGasPrice(): Promise<ContractCallResult> { return await this.executeCall( BUILT_IN_CONTRACTS.PARAMS_ADDRESS, ABIContract.ofAbi(BUILT_IN_CONTRACTS.PARAMS_ABI).getFunction('get'), [dataUtils.encodeBytes32String('base-gas-price', 'left')] ); } /** * Decode the result of a contract call from the result of a simulated transaction. * * @param {string} encodedData - The encoded data received from the contract call. * @param {ABIFunction} functionAbi - The ABI function definition used for decoding the result. * @param {boolean} reverted - Indicates if the contract call reverted. * @return {ContractCallResult} An object containing the success status and the decoded result. */ private getContractCallResult( encodedData: string, functionAbi: ABIFunction, reverted: boolean ): ContractCallResult { if (reverted) { const errorMessage = decodeRevertReason(encodedData) ?? ''; return { success: false, result: { errorMessage } }; } // Returning the decoded result both as plain and array. const encodedResult = Hex.of(encodedData); const plain = functionAbi.decodeResult(encodedResult); const array = functionAbi.decodeOutputAsArray(encodedResult); return { success: true, result: { plain, array } }; } } export { TransactionsModule };