Skip to content

Commit

Permalink
feat: added DEPENDENT_SERVICE_IMMATURE_RECORDS predefined error for i…
Browse files Browse the repository at this point in the history
…mmature records scenarios (#3394)

* feat: added DEPENDENT_SERVICE_IMMATURE_RECORDS predefined error for immature records scenarios

Signed-off-by: Logan Nguyen <[email protected]>

* test: fixed unit tests for the new DEPENDENT_SERVICE_IMMATURE_RECORDS scenarios

Signed-off-by: Logan Nguyen <[email protected]>

---------

Signed-off-by: Logan Nguyen <[email protected]>
  • Loading branch information
quiet-node authored Jan 22, 2025
1 parent c194efe commit 0c6d059
Show file tree
Hide file tree
Showing 11 changed files with 75 additions and 99 deletions.
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
2 changes: 1 addition & 1 deletion packages/relay/tests/lib/eth/eth_getBlockByNumber.spec.ts
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 {
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

0 comments on commit 0c6d059

Please sign in to comment.