@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
text/typescript
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 };