From e80299b04ef24aedc4ac6bc91503ff3a14e20fb4 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Fri, 10 Jan 2025 13:30:29 -0500 Subject: [PATCH] chore: cherry pick #3368 fix: added polling logic to ensure the retrieval of fully mature records from MN (#3370) fix: added polling logic to ensure the retrieval of fully mature records from MN (#3368) * fix: added polling logic to getContractResultWithRetry() and getContractResultsLogsWithRetry() * fix: strictly throw errors if immature records found --------- Signed-off-by: Logan Nguyen --- .../relay/src/lib/clients/mirrorNodeClient.ts | 169 ++++++++++++------ packages/relay/src/lib/eth.ts | 44 ++++- .../ethService/ethCommonService/index.ts | 9 +- .../relay/tests/lib/eth/eth_getLogs.spec.ts | 33 ++-- .../lib/eth/eth_getTransactionByHash.spec.ts | 58 +++--- .../lib/eth/eth_getTransactionReceipt.spec.ts | 27 +-- .../relay/tests/lib/mirrorNodeClient.spec.ts | 113 ++++++++++-- 7 files changed, 320 insertions(+), 133 deletions(-) diff --git a/packages/relay/src/lib/clients/mirrorNodeClient.ts b/packages/relay/src/lib/clients/mirrorNodeClient.ts index 2fa615301f..0ed72afd9d 100644 --- a/packages/relay/src/lib/clients/mirrorNodeClient.ts +++ b/packages/relay/src/lib/clients/mirrorNodeClient.ts @@ -754,45 +754,75 @@ export class MirrorNodeClient { } /** - * In some very rare cases the /contracts/results api is called before all the data is saved in - * the mirror node DB and `transaction_index` or `block_number` is returned as `undefined` or `block_hash` as `0x`. - * A single re-fetch is sufficient to resolve this problem. + * Retrieves contract results with a retry mechanism to handle immature records. + * When querying the /contracts/results api, there are cases where the records are "immature" - meaning + * some fields are not yet properly populated in the mirror node DB at the time of the request. + * + * An immature record can be characterized by: + * - `transaction_index` being null/undefined + * - `block_number` being null/undefined + * - `block_hash` being '0x' (empty hex) + * + * This method implements a retry mechanism to handle immature records by polling until either: + * - The record matures (all fields are properly populated) + * - The maximum retry count is reached * * @param {string} methodName - The name of the method used to fetch contract results. * @param {any[]} args - The arguments to be passed to the specified method for fetching contract results. * @param {RequestDetails} requestDetails - Details used for logging and tracking the request. - * @returns {Promise} - A promise resolving to the fetched contract result, either on the first attempt or after a retry. + * @returns {Promise} - A promise resolving to the fetched contract result, either mature or the last fetched result after retries. */ public async getContractResultWithRetry( methodName: string, args: any[], requestDetails: RequestDetails, ): Promise { - const shortDelay = 500; - const contractResult = await this[methodName](...args); - - if (contractResult) { - const contractObjects = Array.isArray(contractResult) ? contractResult : [contractResult]; - for (const contractObject of contractObjects) { - if ( - contractObject && - (contractObject.transaction_index == null || - contractObject.block_number == null || - contractObject.block_hash == EthImpl.emptyHex) - ) { - if (this.logger.isLevelEnabled('debug')) { - this.logger.debug( - `${requestDetails.formattedRequestId} Contract result contains undefined transaction_index, block_number, or block_hash is an empty hex (0x): transaction_hash:${contractObject.hash}, transaction_index:${contractObject.transaction_index}, block_number=${contractObject.block_number}, block_hash=${contractObject.block_hash}. Retrying after a delay of ${shortDelay} ms `, - ); + const mirrorNodeRetryDelay = this.getMirrorNodeRetryDelay(); + const mirrorNodeRequestRetryCount = this.getMirrorNodeRequestRetryCount(); + + let contractResult = await this[methodName](...args); + + for (let i = 0; i < mirrorNodeRequestRetryCount; i++) { + if (contractResult) { + const contractObjects = Array.isArray(contractResult) ? contractResult : [contractResult]; + + let foundImmatureRecord = false; + + for (const contractObject of contractObjects) { + if ( + contractObject && + (contractObject.transaction_index == null || + contractObject.block_number == null || + contractObject.block_hash == EthImpl.emptyHex) + ) { + // Found immature record, log the info, set flag and exit record traversal + if (this.logger.isLevelEnabled('debug')) { + this.logger.debug( + `${ + requestDetails.formattedRequestId + } Contract result contains nullable transaction_index or block_number, or block_hash is an empty hex (0x): contract_result=${JSON.stringify( + contractObject, + )}. Retrying after a delay of ${mirrorNodeRetryDelay} ms `, + ); + } + + foundImmatureRecord = true; + break; } - - // Backoff before repeating request - await new Promise((r) => setTimeout(r, shortDelay)); - return await this[methodName](...args); } + + // if foundImmatureRecord is still false after record traversal, it means no immature record was found. Simply return contractResult to stop the polling process + if (!foundImmatureRecord) return contractResult; + + // if immature record found, wait and retry and update contractResult + await new Promise((r) => setTimeout(r, mirrorNodeRetryDelay)); + contractResult = await this[methodName](...args); + } else { + break; } } + // Return final result after all retry attempts, regardless of record maturity return contractResult; } @@ -895,24 +925,36 @@ export class MirrorNodeClient { } /** - * In some very rare cases the /contracts/results/logs api is called before all the data is saved in - * the mirror node DB and `transaction_index`, `block_number`, `index` is returned as `undefined`, or block_hash is an empty hex (0x). - * A single re-fetch is sufficient to resolve this problem. + * Retrieves contract results log with a retry mechanism to handle immature records. + * When querying the /contracts/results/logs api, there are cases where the records are "immature" - meaning + * some fields are not yet properly populated in the mirror node DB at the time of the request. + * + * An immature record can be characterized by: + * - `transaction_index` being null/undefined + * - `log index` being null/undefined + * - `block_number` being null/undefined + * - `block_hash` being '0x' (empty hex) + * + * This method implements a retry mechanism to handle immature records by polling until either: + * - The record matures (all fields are properly populated) + * - The maximum retry count is reached * * @param {RequestDetails} requestDetails - Details used for logging and tracking the request. * @param {IContractLogsResultsParams} [contractLogsResultsParams] - Parameters for querying contract logs results. * @param {ILimitOrderParams} [limitOrderParams] - Parameters for limit and order when fetching the logs. - * @returns {Promise} - A promise resolving to the paginated contract logs results. + * @returns {Promise} - A promise resolving to the paginated contract logs results, either mature or the last fetched result after retries. */ public async getContractResultsLogsWithRetry( requestDetails: RequestDetails, contractLogsResultsParams?: IContractLogsResultsParams, limitOrderParams?: ILimitOrderParams, ): Promise { - const shortDelay = 500; + const mirrorNodeRetryDelay = this.getMirrorNodeRetryDelay(); + const mirrorNodeRequestRetryCount = this.getMirrorNodeRequestRetryCount(); + const queryParams = this.prepareLogsParams(contractLogsResultsParams, limitOrderParams); - const logResults = await this.getPaginatedResults( + let logResults = await this.getPaginatedResults( `${MirrorNodeClient.GET_CONTRACT_RESULT_LOGS_ENDPOINT}${queryParams}`, MirrorNodeClient.GET_CONTRACT_RESULT_LOGS_ENDPOINT, MirrorNodeClient.CONTRACT_RESULT_LOGS_PROPERTY, @@ -922,33 +964,50 @@ export class MirrorNodeClient { MirrorNodeClient.mirrorNodeContractResultsLogsPageMax, ); - if (logResults) { - for (const log of logResults) { - if ( - log && - (log.transaction_index == null || - log.block_number == null || - log.index == null || - log.block_hash === EthImpl.emptyHex) - ) { - if (this.logger.isLevelEnabled('debug')) { - this.logger.debug( - `${requestDetails.formattedRequestId} Contract result log contains undefined transaction_index, block_number, index, or block_hash is an empty hex (0x): transaction_hash:${log.transaction_hash}, transaction_index:${log.transaction_index}, block_number=${log.block_number}, log_index=${log.index}, block_hash=${log.block_hash}. Retrying after a delay of ${shortDelay} ms.`, - ); + for (let i = 0; i < mirrorNodeRequestRetryCount; i++) { + if (logResults) { + let foundImmatureRecord = false; + + for (const log of logResults) { + if ( + log && + (log.transaction_index == null || + log.block_number == null || + log.index == null || + log.block_hash === EthImpl.emptyHex) + ) { + // Found immature record, log the info, set flag and exit record traversal + if (this.logger.isLevelEnabled('debug')) { + this.logger.debug( + `${ + requestDetails.formattedRequestId + } Contract result log contains undefined transaction_index, block_number, index, or block_hash is an empty hex (0x): log=${JSON.stringify( + log, + )}. Retrying after a delay of ${mirrorNodeRetryDelay} ms.`, + ); + } + + foundImmatureRecord = true; + break; } - - // Backoff before repeating request - await new Promise((r) => setTimeout(r, shortDelay)); - return await this.getPaginatedResults( - `${MirrorNodeClient.GET_CONTRACT_RESULT_LOGS_ENDPOINT}${queryParams}`, - MirrorNodeClient.GET_CONTRACT_RESULT_LOGS_ENDPOINT, - MirrorNodeClient.CONTRACT_RESULT_LOGS_PROPERTY, - requestDetails, - [], - 1, - MirrorNodeClient.mirrorNodeContractResultsLogsPageMax, - ); } + + // if foundImmatureRecord is still false after record traversal, it means no immature record was found. Simply return logResults to stop the polling process + if (!foundImmatureRecord) return logResults; + + // if immature record found, wait and retry and update logResults + await new Promise((r) => setTimeout(r, mirrorNodeRetryDelay)); + logResults = await this.getPaginatedResults( + `${MirrorNodeClient.GET_CONTRACT_RESULT_LOGS_ENDPOINT}${queryParams}`, + MirrorNodeClient.GET_CONTRACT_RESULT_LOGS_ENDPOINT, + MirrorNodeClient.CONTRACT_RESULT_LOGS_PROPERTY, + requestDetails, + [], + 1, + MirrorNodeClient.mirrorNodeContractResultsLogsPageMax, + ); + } else { + break; } } diff --git a/packages/relay/src/lib/eth.ts b/packages/relay/src/lib/eth.ts index ff9d39b968..abc64349b8 100644 --- a/packages/relay/src/lib/eth.ts +++ b/packages/relay/src/lib/eth.ts @@ -1937,6 +1937,8 @@ export class EthImpl implements Eth { if (!contractResults[0]) return null; + this.handleImmatureContractResultRecord(contractResults[0], requestDetails); + const resolvedToAddress = await this.resolveEvmAddress(contractResults[0].to, requestDetails); const resolvedFromAddress = await this.resolveEvmAddress(contractResults[0].from, requestDetails, [ constants.TYPE_ACCOUNT, @@ -2231,11 +2233,7 @@ export class EthImpl implements Eth { return this.createTransactionFromLog(syntheticLogs[0]); } - if (!contractResult.block_number || (!contractResult.transaction_index && contractResult.transaction_index !== 0)) { - this.logger.warn( - `${requestIdPrefix} getTransactionByHash(hash=${hash}) mirror-node returned status 200 with missing properties in contract_results - block_number==${contractResult.block_number} and transaction_index==${contractResult.transaction_index}`, - ); - } + this.handleImmatureContractResultRecord(contractResult, requestDetails); const fromAddress = await this.resolveEvmAddress(contractResult.from, requestDetails, [constants.TYPE_ACCOUNT]); const toAddress = await this.resolveEvmAddress(contractResult.to, requestDetails); @@ -2329,6 +2327,8 @@ export class EthImpl implements Eth { ); return receipt; } else { + this.handleImmatureContractResultRecord(receiptResponse, requestDetails); + const effectiveGas = await this.getCurrentGasPriceForBlock(receiptResponse.block_hash, requestDetails); // support stricter go-eth client which requires the transaction hash property on logs const logs = receiptResponse.logs.map((log) => { @@ -2341,7 +2341,7 @@ export class EthImpl implements Eth { removed: false, topics: log.topics, transactionHash: toHash32(receiptResponse.hash), - transactionIndex: nullableNumberTo0x(receiptResponse.transaction_index), + transactionIndex: numberTo0x(receiptResponse.transaction_index), }); }); @@ -2357,7 +2357,7 @@ export class EthImpl implements Eth { logs: logs, logsBloom: receiptResponse.bloom === EthImpl.emptyHex ? EthImpl.emptyBloom : receiptResponse.bloom, transactionHash: toHash32(receiptResponse.hash), - transactionIndex: nullableNumberTo0x(receiptResponse.transaction_index), + transactionIndex: numberTo0x(receiptResponse.transaction_index), effectiveGasPrice: effectiveGas, root: receiptResponse.root || constants.DEFAULT_ROOT_HASH, status: receiptResponse.status, @@ -2570,6 +2570,8 @@ export class EthImpl implements Eth { // prepare transactionArray let transactionArray: any[] = []; for (const contractResult of contractResults) { + this.handleImmatureContractResultRecord(contractResult, requestDetails); + // there are several hedera-specific validations that occur right before entering the evm // if a transaction has reverted there, we should not include that tx in the block response if (Utils.isRevertedDueToHederaSpecificValidation(contractResult)) { @@ -2839,4 +2841,32 @@ export class EthImpl implements Eth { const exchangeRateInCents = currentNetworkExchangeRate.cent_equivalent / currentNetworkExchangeRate.hbar_equivalent; return exchangeRateInCents; } + + /** + * Checks if a contract result record is immature by validating required fields. + * An immature record can be characterized by: + * - `transaction_index` being null/undefined + * - `block_number` being null/undefined + * - `block_hash` being '0x' (empty hex) + * + * @param {any} record - The contract result record to validate + * @param {RequestDetails} requestDetails - Details used for logging and tracking the request + * @throws {Error} If the record is missing required fields + */ + private handleImmatureContractResultRecord(record: any, requestDetails: RequestDetails) { + if (record.transaction_index == null || record.block_number == null || record.block_hash === EthImpl.emptyHex) { + if (this.logger.isLevelEnabled('debug')) { + this.logger.debug( + `${ + requestDetails.formattedRequestId + } Contract result is missing required fields: block_number, transaction_index, or block_hash is an empty hex (0x). contractResult=${JSON.stringify( + record, + )}`, + ); + } + throw predefined.INTERNAL_ERROR( + `The contract result response from the remote Mirror Node server is missing required fields. `, + ); + } + } } diff --git a/packages/relay/src/lib/services/ethService/ethCommonService/index.ts b/packages/relay/src/lib/services/ethService/ethCommonService/index.ts index da37065176..3bf568ca1f 100644 --- a/packages/relay/src/lib/services/ethService/ethCommonService/index.ts +++ b/packages/relay/src/lib/services/ethService/ethCommonService/index.ts @@ -346,7 +346,12 @@ export class CommonService implements ICommonService { const logs: Log[] = []; for (const log of logResults) { - if (log.block_number == null || log.index == null || log.block_hash === EthImpl.emptyHex) { + if ( + log.transaction_index == null || + log.block_number == null || + log.index == null || + log.block_hash === EthImpl.emptyHex + ) { if (this.logger.isLevelEnabled('debug')) { this.logger.debug( `${ @@ -371,7 +376,7 @@ export class CommonService implements ICommonService { removed: false, topics: log.topics, transactionHash: toHash32(log.transaction_hash), - transactionIndex: nullableNumberTo0x(log.transaction_index), + transactionIndex: numberTo0x(log.transaction_index), }), ); } diff --git a/packages/relay/tests/lib/eth/eth_getLogs.spec.ts b/packages/relay/tests/lib/eth/eth_getLogs.spec.ts index a9954c13c0..18c1f33e40 100644 --- a/packages/relay/tests/lib/eth/eth_getLogs.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getLogs.spec.ts @@ -18,9 +18,17 @@ * */ +import MockAdapter from 'axios-mock-adapter'; import { expect, use } from 'chai'; -import sinon from 'sinon'; import chaiAsPromised from 'chai-as-promised'; +import { ethers } from 'ethers'; +import sinon from 'sinon'; + +import { Eth } from '../../../src'; +import { SDKClient } from '../../../src/lib/clients'; +import { CacheService } from '../../../src/lib/services/cacheService/cacheService'; +import HAPIService from '../../../src/lib/services/hapiService/hapiService'; +import { RequestDetails } from '../../../src/lib/types'; import { defaultDetailedContractResults, defaultDetailedContractResults2, @@ -35,7 +43,6 @@ import { overrideEnvsInMochaDescribe, withOverriddenEnvsInMochaTest, } from '../../helpers'; -import { SDKClient } from '../../../src/lib/clients'; import { BLOCK_HASH, BLOCKS_LIMIT_ORDER_URL, @@ -56,13 +63,7 @@ import { DEFAULT_NULL_LOG_TOPICS, NOT_FOUND_RES, } from './eth-config'; -import { ethers } from 'ethers'; import { generateEthTestEnv } from './eth-helpers'; -import { RequestDetails } from '../../../src/lib/types'; -import MockAdapter from 'axios-mock-adapter'; -import HAPIService from '../../../src/lib/services/hapiService/hapiService'; -import { Eth } from '../../../src'; -import { CacheService } from '../../../src/lib/services/cacheService/cacheService'; use(chaiAsPromised); @@ -174,7 +175,7 @@ describe('@ethGetLogs using MirrorNode', async function () { expectLogData(result[3], filteredLogs.logs[3], defaultDetailedContractResults3); }); - it('no filters but undefined transaction_index', async function () { + it('should throw an error if transaction_index is falsy', async function () { const filteredLogs = { logs: [ { ...DEFAULT_LOGS.logs[0], transaction_index: undefined }, @@ -189,13 +190,13 @@ describe('@ethGetLogs using MirrorNode', async function () { restMock.onGet(`contracts/${log.address}`).reply(200, { ...DEFAULT_CONTRACT, contract_id: `0.0.105${index}` }); }); - const result = await ethImpl.getLogs(null, 'latest', 'latest', null, null, requestDetails); - expect(result).to.exist; - - expect(result.length).to.eq(4); - result.forEach((log, _index) => { - expect(log.transactionIndex).to.be.null; - }); + try { + await ethImpl.getLogs(null, 'latest', 'latest', null, null, requestDetails); + expect.fail('should have thrown an error'); + } catch (error) { + expect(error).to.exist; + expect(error.message).to.include('The log entry from the remote Mirror Node server is missing required fields.'); + } }); withOverriddenEnvsInMochaTest({ MIRROR_NODE_LIMIT_PARAM: '2' }, () => { diff --git a/packages/relay/tests/lib/eth/eth_getTransactionByHash.spec.ts b/packages/relay/tests/lib/eth/eth_getTransactionByHash.spec.ts index f590a63bb7..52f3957aee 100644 --- a/packages/relay/tests/lib/eth/eth_getTransactionByHash.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getTransactionByHash.spec.ts @@ -20,8 +20,11 @@ import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; -import { Transaction, Transaction2930, Transaction1559 } from '../../../src/lib/model'; + +import { Transaction, Transaction1559, Transaction2930 } from '../../../src/lib/model'; +import { RequestDetails } from '../../../src/lib/types'; import RelayAssertions from '../../assertions'; +import { defaultDetailedContractResultByHash, defaultFromLongZeroAddress } from '../../helpers'; import { DEFAULT_DETAILED_CONTRACT_RESULT_BY_HASH_REVERTED, DEFAULT_TRANSACTION, @@ -30,14 +33,13 @@ import { EMPTY_LOGS_RESPONSE, NO_TRANSACTIONS, } from './eth-config'; -import { defaultDetailedContractResultByHash, defaultFromLongZeroAddress } from '../../helpers'; import { generateEthTestEnv } from './eth-helpers'; -import { RequestDetails } from '../../../src/lib/types'; use(chaiAsPromised); describe('@ethGetTransactionByHash eth_getTransactionByHash tests', async function () { - let { restMock, ethImpl } = generateEthTestEnv(); + this.timeout(100000); + const { restMock, ethImpl } = generateEthTestEnv(); const from = '0x00000000000000000000000000000000000003f7'; const evm_address = '0xc37f417fa09933335240fca72dd257bfbde9c275'; const contractResultMock = { @@ -217,7 +219,7 @@ describe('@ethGetTransactionByHash eth_getTransactionByHash tests', async functi if (result) expect(result.v).to.eq('0x0'); }); - it('handles transactions with undefined transaction_index', async function () { + it('should throw an error if transaction_index is falsy', async function () { const detailedResultsWithNullNullableValues = { ...defaultDetailedContractResultByHash, transaction_index: undefined, @@ -225,14 +227,19 @@ describe('@ethGetTransactionByHash eth_getTransactionByHash tests', async functi const uniqueTxHash = '0x14aad7b827375d12d73af57b6a3e84353645fd31305ea58ff52dda53ec640534'; restMock.onGet(`contracts/results/${uniqueTxHash}`).reply(200, detailedResultsWithNullNullableValues); - const result = await ethImpl.getTransactionByHash(uniqueTxHash, requestDetails); - expect(result).to.not.be.null; - expect(result).to.exist; - if (result) expect(result.transactionIndex).to.be.null; + try { + await ethImpl.getTransactionByHash(uniqueTxHash, requestDetails); + expect.fail('should have thrown an error'); + } catch (error) { + expect(error).to.exist; + expect(error.message).to.include( + 'The contract result response from the remote Mirror Node server is missing required fields.', + ); + } }); - it('handles transactions with undefined block_number', async function () { + it('should throw an error if block_number is falsy', async function () { const detailedResultsWithNullNullableValues = { ...defaultDetailedContractResultByHash, block_number: undefined, @@ -240,14 +247,18 @@ describe('@ethGetTransactionByHash eth_getTransactionByHash tests', async functi const uniqueTxHash = '0x14aad7b827375d12d73af57b6a3e84353645fd31305ea58ff52dda53ec640511'; restMock.onGet(`contracts/results/${uniqueTxHash}`).reply(200, detailedResultsWithNullNullableValues); - const result = await ethImpl.getTransactionByHash(uniqueTxHash, requestDetails); - expect(result).to.not.be.null; - - expect(result).to.exist; - if (result) expect(result.blockNumber).to.be.null; + try { + await ethImpl.getTransactionByHash(uniqueTxHash, requestDetails); + expect.fail('should have thrown an error'); + } catch (error) { + expect(error).to.exist; + expect(error.message).to.include( + 'The contract result response from the remote Mirror Node server is missing required fields.', + ); + } }); - it('handles transactions with undefined transaction_index and block_number', async function () { + it('should throw an error if transaction_index and block_number are falsy', async function () { const detailedResultsWithNullNullableValues = { ...defaultDetailedContractResultByHash, block_number: undefined, @@ -257,13 +268,14 @@ describe('@ethGetTransactionByHash eth_getTransactionByHash tests', async functi const uniqueTxHash = '0x14aad7b827375d12d73af57b6a3e84353645fd31305ea58ff52d1a53ec640511'; restMock.onGet(`contracts/results/${uniqueTxHash}`).reply(200, detailedResultsWithNullNullableValues); - const result = await ethImpl.getTransactionByHash(uniqueTxHash, requestDetails); - expect(result).to.not.be.null; - - expect(result).to.exist; - if (result) { - expect(result.blockNumber).to.be.null; - expect(result.transactionIndex).to.be.null; + try { + await ethImpl.getTransactionByHash(uniqueTxHash, requestDetails); + expect.fail('should have thrown an error'); + } catch (error) { + expect(error).to.exist; + expect(error.message).to.include( + 'The contract result response from the remote Mirror Node server is missing required fields.', + ); } }); diff --git a/packages/relay/tests/lib/eth/eth_getTransactionReceipt.spec.ts b/packages/relay/tests/lib/eth/eth_getTransactionReceipt.spec.ts index 3874169022..a19aa6fbea 100644 --- a/packages/relay/tests/lib/eth/eth_getTransactionReceipt.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getTransactionReceipt.spec.ts @@ -19,21 +19,22 @@ */ import { expect, use } from 'chai'; -import sinon, { createSandbox } from 'sinon'; import chaiAsPromised from 'chai-as-promised'; -import { EthImpl } from '../../../src/lib/eth'; +import sinon, { createSandbox } from 'sinon'; + import constants from '../../../src/lib/constants'; +import { EthImpl } from '../../../src/lib/eth'; +import { RequestDetails } from '../../../src/lib/types'; import RelayAssertions from '../../assertions'; -import { DEFAULT_BLOCK, EMPTY_LOGS_RESPONSE } from './eth-config'; import { defaultErrorMessageHex } from '../../helpers'; +import { DEFAULT_BLOCK, EMPTY_LOGS_RESPONSE } from './eth-config'; import { generateEthTestEnv } from './eth-helpers'; -import { RequestDetails } from '../../../src/lib/types'; use(chaiAsPromised); describe('@ethGetTransactionReceipt eth_getTransactionReceipt tests', async function () { this.timeout(10000); - let { restMock, ethImpl, cacheService } = generateEthTestEnv(); + const { restMock, ethImpl, cacheService } = generateEthTestEnv(); let sandbox: sinon.SinonSandbox; const requestDetails = new RequestDetails({ requestId: 'eth_getTransactionReceiptTest', ipAddress: '0.0.0.0' }); @@ -291,7 +292,7 @@ describe('@ethGetTransactionReceipt eth_getTransactionReceipt tests', async func expect(receipt.gasUsed).to.eq('0x0'); }); - it('handles missing transaction index', async function () { + it('should throw an error if transaction index is falsy', async function () { // fake unique hash so request dont re-use the cached value but the mock defined const uniqueTxHash = '0x17cad7b827375d12d73af57b6a3e84353645fd31305ea58ff52dda53ec640533'; @@ -306,12 +307,16 @@ describe('@ethGetTransactionReceipt eth_getTransactionReceipt tests', async func evm_address: contractEvmAddress, }); stubBlockAndFeesFunc(sandbox); - const receipt = await ethImpl.getTransactionReceipt(uniqueTxHash, requestDetails); - expect(receipt).to.exist; - - expect(receipt.logs[0].transactionIndex).to.eq(null); - expect(receipt.transactionIndex).to.eq(null); + try { + await ethImpl.getTransactionReceipt(uniqueTxHash, requestDetails); + expect.fail('should have thrown an error'); + } catch (error) { + expect(error).to.exist; + expect(error.message).to.include( + 'The contract result response from the remote Mirror Node server is missing required fields.', + ); + } }); it('valid receipt on cache match', async function () { diff --git a/packages/relay/tests/lib/mirrorNodeClient.spec.ts b/packages/relay/tests/lib/mirrorNodeClient.spec.ts index 1d168eb582..2fa2981158 100644 --- a/packages/relay/tests/lib/mirrorNodeClient.spec.ts +++ b/packages/relay/tests/lib/mirrorNodeClient.spec.ts @@ -658,14 +658,18 @@ describe('MirrorNodeClient', async function () { expect(mock.history.get.length).to.eq(2); }); - it('`getContractResultsWithRetry` by hash retries once because of missing transaction_index, block_number and block_hash equals 0x', async () => { + it('`getContractResultsWithRetry` should retry multiple times when records are immature and eventually return mature records', async () => { const hash = '0x2a563af33c4871b51a8b108aa2fe1dd5280a30dfb7236170ae5e5e7957eb6393'; - mock.onGet(`contracts/results/${hash}`).replyOnce(200, { - ...detailedContractResult, - transaction_index: undefined, - block_number: undefined, - block_hash: '0x', - }); + // Mock 3 sequential calls that return immature records - less than default polling counts (10) + [...Array(3)].reduce((mockChain) => { + return mockChain.onGet(`contracts/results/${hash}`).replyOnce(200, { + ...detailedContractResult, + transaction_index: null, + block_number: null, + block_hash: '0x', + }); + }, mock); + mock.onGet(`contracts/results/${hash}`).reply(200, detailedContractResult); const result = await mirrorNodeInstance.getContractResultWithRetry( @@ -677,7 +681,32 @@ describe('MirrorNodeClient', async function () { expect(result.transaction_index).equal(detailedContractResult.transaction_index); expect(result.block_number).equal(detailedContractResult.block_number); expect(result.block_hash).equal(detailedContractResult.block_hash); - expect(mock.history.get.length).to.eq(2); + expect(mock.history.get.length).to.eq(4); + }); + + it('`getContractResultsWithRetry` should return immature records after exhausting maximum retry attempts', async () => { + const hash = '0x2a563af33c4871b51a8b108aa2fe1dd5280a30dfb7236170ae5e5e7957eb6393'; + // Mock 11 sequential calls that return immature records - as default polling counts (10) + [...Array(11)].reduce((mockChain) => { + return mockChain.onGet(`contracts/results/${hash}`).replyOnce(200, { + ...detailedContractResult, + transaction_index: null, + block_number: null, + block_hash: '0x', + }); + }, mock); + + const result = await mirrorNodeInstance.getContractResultWithRetry( + mirrorNodeInstance.getContractResult.name, + [hash, requestDetails], + requestDetails, + ); + + expect(result).to.exist; + expect(result.transaction_index).equal(null); + expect(result.block_number).equal(null); + expect(result.block_hash).equal('0x'); + expect(mock.history.get.length).to.eq(11); }); it('`getContractResults` detailed', async () => { @@ -783,25 +812,71 @@ describe('MirrorNodeClient', async function () { }); const log = { - address: '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', - bloom: '0x549358c4c2e573e02410ef7b5a5ffa5f36dd7398', - contract_id: '0.1.2', - data: '0x00000000000000000000000000000000000000000000000000000000000000fa', + address: '0x0000000000000000000000000000000000163b59', + bloom: + '0x00000000000000100001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000100001000000000000000000000000020000000000000000000800000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000020000000000000000000000000000000000000000000000000100000000000000000', + contract_id: '0.0.1456985', + data: '0x0000000000000000000000000000000000000000000000000000000ba43b7400', index: 0, - topics: ['0xf4757a49b326036464bec6fe419a4ae38c8a02ce3e68bf0809674f6aab8ad300'], - root_contract_id: '0.1.2', - timestamp: '1586567700.453054000', + topics: [ + '0x831ac82b07fb396dafef0077cea6e002235d88e63f35cbd5df2c065107f1e74a', + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x00000000000000000000000000000000000000000000000000000000007ada90', + ], + block_hash: '0xd773ec74b26ace67ee3924879a6bd35f3c4653baaa19f6c9baec7fac1269c55e103287a2d07084778957d21704a92fd3', + block_number: 73884554, + root_contract_id: '0.0.3045981', + timestamp: '1736446204.610059000', + transaction_hash: '0x0494665a6d3aa32f51f79ad2c75053c9a51ae84927e4924e77773d834b85ec86', + transaction_index: 3, }; + it('`getContractResultsLogs` ', async () => { + mock.onGet(`contracts/results/logs?limit=100&order=asc`).replyOnce(200, { logs: [log] }); + + const results = await mirrorNodeInstance.getContractResultsLogsWithRetry(requestDetails); + expect(results).to.exist; + expect(results.length).to.gt(0); + const logObject = results[0]; + expect(logObject).to.deep.eq(log); + }); + + it('`getContractResultsLogsWithRetry` should retry multiple times when records are immature and eventually return mature records', async () => { + // Mock 3 sequential calls that return immature records - less than default polling counts (10) + [...Array(3)].reduce((mockChain) => { + return mockChain.onGet(`contracts/results/logs?limit=100&order=asc`).replyOnce(200, { + logs: [{ ...log, transaction_index: null, block_number: null, index: null, block_hash: '0x' }], + }); + }, mock); + mock.onGet(`contracts/results/logs?limit=100&order=asc`).reply(200, { logs: [log] }); const results = await mirrorNodeInstance.getContractResultsLogsWithRetry(requestDetails); + expect(results).to.exist; expect(results.length).to.gt(0); - const firstResult = results[0]; - expect(firstResult.address).equal(log.address); - expect(firstResult.contract_id).equal(log.contract_id); - expect(firstResult.index).equal(log.index); + const logObject = results[0]; + expect(logObject).to.deep.eq(log); + expect(mock.history.get.length).to.eq(4); + }); + + it('`getContractResultsLogsWithRetry` should return immature records after exhausting maximum retry attempts', async () => { + // Mock 11 sequential calls that return immature records - greater than default polling counts (10) + [...Array(11)].reduce((mockChain) => { + return mockChain.onGet(`contracts/results/logs?limit=100&order=asc`).replyOnce(200, { + logs: [{ ...log, transaction_index: null, block_number: null, index: null, block_hash: '0x' }], + }); + }, mock); + + const expectedLog = { ...log, transaction_index: null, block_number: null, index: null, block_hash: '0x' }; + + const results = await mirrorNodeInstance.getContractResultsLogsWithRetry(requestDetails); + + expect(results).to.exist; + expect(results.length).to.gt(0); + const logObject = results[0]; + expect(logObject).to.deep.eq(expectedLog); + expect(mock.history.get.length).to.eq(11); }); it('`getContractResultsLogsByAddress` ', async () => {