diff --git a/packages/relay/src/lib/clients/mirrorNodeClient.ts b/packages/relay/src/lib/clients/mirrorNodeClient.ts index 0ed72afd9d..c2d246fa94 100644 --- a/packages/relay/src/lib/clients/mirrorNodeClient.ts +++ b/packages/relay/src/lib/clients/mirrorNodeClient.ts @@ -783,6 +783,8 @@ export class MirrorNodeClient { let contractResult = await this[methodName](...args); for (let i = 0; i < mirrorNodeRequestRetryCount; i++) { + const isLastAttempt = i === mirrorNodeRequestRetryCount - 1; + if (contractResult) { const contractObjects = Array.isArray(contractResult) ? contractResult : [contractResult]; @@ -802,10 +804,15 @@ export class MirrorNodeClient { 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 `, + )}. ${!isLastAttempt ? `Retrying after a delay of ${mirrorNodeRetryDelay} ms.` : ``}`, ); } + // If immature records persist after the final polling attempt, throw the DEPENDENT_SERVICE_IMMATURE_RECORDS error. + if (isLastAttempt) { + throw predefined.DEPENDENT_SERVICE_IMMATURE_RECORDS; + } + foundImmatureRecord = true; break; } @@ -965,6 +972,7 @@ export class MirrorNodeClient { ); for (let i = 0; i < mirrorNodeRequestRetryCount; i++) { + const isLastAttempt = i === mirrorNodeRequestRetryCount - 1; if (logResults) { let foundImmatureRecord = false; @@ -981,12 +989,17 @@ export class MirrorNodeClient { 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( + } Contract result log contains nullable transaction_index, block_number, index, or block_hash is an empty hex (0x): log=${JSON.stringify( log, - )}. Retrying after a delay of ${mirrorNodeRetryDelay} ms.`, + )}. ${!isLastAttempt ? `Retrying after a delay of ${mirrorNodeRetryDelay} ms.` : ``}`, ); } + // If immature records persist after the final polling attempt, throw the DEPENDENT_SERVICE_IMMATURE_RECORDS error. + if (isLastAttempt) { + throw predefined.DEPENDENT_SERVICE_IMMATURE_RECORDS; + } + foundImmatureRecord = true; break; } diff --git a/packages/relay/src/lib/errors/JsonRpcError.ts b/packages/relay/src/lib/errors/JsonRpcError.ts index 89fd1680b4..5086d1b5de 100644 --- a/packages/relay/src/lib/errors/JsonRpcError.ts +++ b/packages/relay/src/lib/errors/JsonRpcError.ts @@ -47,6 +47,10 @@ export const predefined = { data, }); }, + DEPENDENT_SERVICE_IMMATURE_RECORDS: new JsonRpcError({ + code: -32015, + message: 'Dependent service returned immature records', + }), GAS_LIMIT_TOO_HIGH: (gasLimit, maxGas) => new JsonRpcError({ code: -32005, diff --git a/packages/relay/src/lib/eth.ts b/packages/relay/src/lib/eth.ts index abc64349b8..ccbed6e688 100644 --- a/packages/relay/src/lib/eth.ts +++ b/packages/relay/src/lib/eth.ts @@ -1937,8 +1937,6 @@ 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, @@ -2233,8 +2231,6 @@ export class EthImpl implements Eth { return this.createTransactionFromLog(syntheticLogs[0]); } - this.handleImmatureContractResultRecord(contractResult, requestDetails); - const fromAddress = await this.resolveEvmAddress(contractResult.from, requestDetails, [constants.TYPE_ACCOUNT]); const toAddress = await this.resolveEvmAddress(contractResult.to, requestDetails); contractResult.chain_id = contractResult.chain_id || this.chain; @@ -2327,8 +2323,6 @@ 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) => { @@ -2570,8 +2564,6 @@ 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)) { @@ -2841,32 +2833,4 @@ 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 3bf568ca1f..957ccac7c7 100644 --- a/packages/relay/src/lib/services/ethService/ethCommonService/index.ts +++ b/packages/relay/src/lib/services/ethService/ethCommonService/index.ts @@ -22,7 +22,7 @@ import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services' import * as _ from 'lodash'; import { Logger } from 'pino'; -import { nullableNumberTo0x, numberTo0x, parseNumericEnvVar, toHash32 } from '../../../../formatters'; +import { numberTo0x, parseNumericEnvVar, toHash32 } from '../../../../formatters'; import { MirrorNodeClient } from '../../../clients'; import constants from '../../../constants'; import { JsonRpcError, predefined } from '../../../errors/JsonRpcError'; @@ -346,26 +346,6 @@ export class CommonService implements ICommonService { const logs: Log[] = []; for (const log of logResults) { - 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( - `${ - requestDetails.formattedRequestId - } Log entry is missing required fields: block_number, index, or block_hash is an empty hex (0x). log=${JSON.stringify( - log, - )}`, - ); - } - throw predefined.INTERNAL_ERROR( - `The log entry from the remote Mirror Node server is missing required fields. `, - ); - } - logs.push( new Log({ address: log.address, diff --git a/packages/relay/tests/lib/eth/eth_getBlockByHash.spec.ts b/packages/relay/tests/lib/eth/eth_getBlockByHash.spec.ts index 22573e6baf..d114af6c3c 100644 --- a/packages/relay/tests/lib/eth/eth_getBlockByHash.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getBlockByHash.spec.ts @@ -393,7 +393,7 @@ describe('@ethGetBlockByHash using MirrorNode', async function () { 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'); + expect(error).to.eq(predefined.DEPENDENT_SERVICE_IMMATURE_RECORDS); } } }); diff --git a/packages/relay/tests/lib/eth/eth_getBlockByNumber.spec.ts b/packages/relay/tests/lib/eth/eth_getBlockByNumber.spec.ts index 84d0a44af5..7c340fffda 100644 --- a/packages/relay/tests/lib/eth/eth_getBlockByNumber.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getBlockByNumber.spec.ts @@ -638,7 +638,7 @@ describe('@ethGetBlockByNumber using MirrorNode', async function () { 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'); + expect(error).to.eq(predefined.DEPENDENT_SERVICE_IMMATURE_RECORDS); } } }); diff --git a/packages/relay/tests/lib/eth/eth_getLogs.spec.ts b/packages/relay/tests/lib/eth/eth_getLogs.spec.ts index 18c1f33e40..2ac8724023 100644 --- a/packages/relay/tests/lib/eth/eth_getLogs.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getLogs.spec.ts @@ -24,7 +24,7 @@ import chaiAsPromised from 'chai-as-promised'; import { ethers } from 'ethers'; import sinon from 'sinon'; -import { Eth } from '../../../src'; +import { Eth, predefined } from '../../../src'; import { SDKClient } from '../../../src/lib/clients'; import { CacheService } from '../../../src/lib/services/cacheService/cacheService'; import HAPIService from '../../../src/lib/services/hapiService/hapiService'; @@ -195,7 +195,7 @@ describe('@ethGetLogs using MirrorNode', async function () { 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.'); + expect(error).to.eq(predefined.DEPENDENT_SERVICE_IMMATURE_RECORDS); } }); diff --git a/packages/relay/tests/lib/eth/eth_getTransactionByHash.spec.ts b/packages/relay/tests/lib/eth/eth_getTransactionByHash.spec.ts index 52f3957aee..8564ce045d 100644 --- a/packages/relay/tests/lib/eth/eth_getTransactionByHash.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getTransactionByHash.spec.ts @@ -34,6 +34,7 @@ import { NO_TRANSACTIONS, } from './eth-config'; import { generateEthTestEnv } from './eth-helpers'; +import { predefined } from '../../../src'; use(chaiAsPromised); @@ -233,9 +234,7 @@ describe('@ethGetTransactionByHash eth_getTransactionByHash tests', async functi 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.', - ); + expect(error).to.eq(predefined.DEPENDENT_SERVICE_IMMATURE_RECORDS); } }); @@ -252,9 +251,7 @@ describe('@ethGetTransactionByHash eth_getTransactionByHash tests', async functi 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.', - ); + expect(error).to.eq(predefined.DEPENDENT_SERVICE_IMMATURE_RECORDS); } }); @@ -273,9 +270,7 @@ describe('@ethGetTransactionByHash eth_getTransactionByHash tests', async functi 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.', - ); + expect(error).to.eq(predefined.DEPENDENT_SERVICE_IMMATURE_RECORDS); } }); diff --git a/packages/relay/tests/lib/eth/eth_getTransactionReceipt.spec.ts b/packages/relay/tests/lib/eth/eth_getTransactionReceipt.spec.ts index a19aa6fbea..60e6f549ff 100644 --- a/packages/relay/tests/lib/eth/eth_getTransactionReceipt.spec.ts +++ b/packages/relay/tests/lib/eth/eth_getTransactionReceipt.spec.ts @@ -29,6 +29,7 @@ import RelayAssertions from '../../assertions'; import { defaultErrorMessageHex } from '../../helpers'; import { DEFAULT_BLOCK, EMPTY_LOGS_RESPONSE } from './eth-config'; import { generateEthTestEnv } from './eth-helpers'; +import { predefined } from '../../../src'; use(chaiAsPromised); @@ -313,9 +314,7 @@ describe('@ethGetTransactionReceipt eth_getTransactionReceipt tests', async func 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.', - ); + expect(error).to.eq(predefined.DEPENDENT_SERVICE_IMMATURE_RECORDS); } }); diff --git a/packages/relay/tests/lib/mirrorNodeClient.spec.ts b/packages/relay/tests/lib/mirrorNodeClient.spec.ts index 2fa2981158..c1c5472841 100644 --- a/packages/relay/tests/lib/mirrorNodeClient.spec.ts +++ b/packages/relay/tests/lib/mirrorNodeClient.spec.ts @@ -686,8 +686,8 @@ describe('MirrorNodeClient', async function () { 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) => { + // Mock 10 sequential calls that return immature records - equals to the default polling counts (10) - should throw an error at the last polling attempt + [...Array(10)].reduce((mockChain) => { return mockChain.onGet(`contracts/results/${hash}`).replyOnce(200, { ...detailedContractResult, transaction_index: null, @@ -696,17 +696,19 @@ describe('MirrorNodeClient', async function () { }); }, mock); - const result = await mirrorNodeInstance.getContractResultWithRetry( - mirrorNodeInstance.getContractResult.name, - [hash, requestDetails], - requestDetails, - ); + try { + await mirrorNodeInstance.getContractResultWithRetry( + mirrorNodeInstance.getContractResult.name, + [hash, requestDetails], + requestDetails, + ); + expect.fail('should have thrown an error'); + } catch (error) { + expect(error).to.exist; + expect(error).to.eq(predefined.DEPENDENT_SERVICE_IMMATURE_RECORDS); + } - 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); + expect(mock.history.get.length).to.eq(10); }); it('`getContractResults` detailed', async () => { @@ -861,22 +863,20 @@ describe('MirrorNodeClient', async function () { }); 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) => { + // Mock 10 sequential calls that return immature records - equals to the default polling counts (10) - should throw an error at the last polling attempt + [...Array(10)].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); + try { + await mirrorNodeInstance.getContractResultsLogsWithRetry(requestDetails); + } catch (error) { + expect(error).to.exist; + expect(error).to.eq(predefined.DEPENDENT_SERVICE_IMMATURE_RECORDS); + } + expect(mock.history.get.length).to.eq(10); }); it('`getContractResultsLogsByAddress` ', async () => { diff --git a/packages/server/src/koaJsonRpc/lib/HttpStatusCodeAndMessage.ts b/packages/server/src/koaJsonRpc/lib/HttpStatusCodeAndMessage.ts index 7dd7ff0f0c..b8a9c496d5 100644 --- a/packages/server/src/koaJsonRpc/lib/HttpStatusCodeAndMessage.ts +++ b/packages/server/src/koaJsonRpc/lib/HttpStatusCodeAndMessage.ts @@ -1,3 +1,22 @@ +/*- + * + * Hedera JSON RPC Relay + * + * Copyright (C) 2025 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ export class HttpStatusCodeAndMessage { public statusCode: number; public StatusName: string; @@ -15,10 +34,12 @@ const IP_RATE_LIMIT_EXCEEDED = 'IP RATE LIMIT EXCEEDED'; const JSON_RPC_ERROR = 'JSON RPC ERROR'; const CONTRACT_REVERT = 'CONTRACT REVERT'; const METHOD_NOT_FOUND = 'METHOD NOT FOUND'; +const DEPENDENT_SERVICE_IMMATURE_RECORDS = 'DEPENDENT SERVICE IMMATURE RECORDS'; export const RpcErrorCodeToStatusMap = { '3': new HttpStatusCodeAndMessage(200, CONTRACT_REVERT), '-32603': new HttpStatusCodeAndMessage(500, INTERNAL_ERROR), + '-32015': new HttpStatusCodeAndMessage(503, DEPENDENT_SERVICE_IMMATURE_RECORDS), '-32600': new HttpStatusCodeAndMessage(400, INVALID_REQUEST), '-32602': new HttpStatusCodeAndMessage(400, INVALID_PARAMS_ERROR), '-32601': new HttpStatusCodeAndMessage(400, METHOD_NOT_FOUND),