Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added DEPENDENT_SERVICE_IMMATURE_RECORDS predefined error for immature records scenarios #3394

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions packages/relay/src/lib/clients/mirrorNodeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand All @@ -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;
}
Expand Down Expand Up @@ -965,6 +972,7 @@ export class MirrorNodeClient {
);

for (let i = 0; i < mirrorNodeRequestRetryCount; i++) {
const isLastAttempt = i === mirrorNodeRequestRetryCount - 1;
if (logResults) {
let foundImmatureRecord = false;

Expand All @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/relay/src/lib/errors/JsonRpcError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 0 additions & 36 deletions packages/relay/src/lib/eth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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. `,
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/relay/tests/lib/eth/eth_getBlockByHash.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
});
Expand Down
4 changes: 2 additions & 2 deletions packages/relay/tests/lib/eth/eth_getLogs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
});

Expand Down
13 changes: 4 additions & 9 deletions packages/relay/tests/lib/eth/eth_getTransactionByHash.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
NO_TRANSACTIONS,
} from './eth-config';
import { generateEthTestEnv } from './eth-helpers';
import { predefined } from '../../../src';

use(chaiAsPromised);

Expand Down Expand Up @@ -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);
}
});

Expand All @@ -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);
}
});

Expand All @@ -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);
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
});

Expand Down
46 changes: 23 additions & 23 deletions packages/relay/tests/lib/mirrorNodeClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -696,17 +696,19 @@ describe('MirrorNodeClient', async function () {
});
}, mock);

const result = await mirrorNodeInstance.getContractResultWithRetry(
mirrorNodeInstance.getContractResult.name,
[hash, requestDetails],
requestDetails,
);
try {
quiet-node marked this conversation as resolved.
Show resolved Hide resolved
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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
21 changes: 21 additions & 0 deletions packages/server/src/koaJsonRpc/lib/HttpStatusCodeAndMessage.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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),
Expand Down
Loading