diff --git a/docs/configuration.md b/docs/configuration.md index dfd9d1645a..9851bf468f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -89,6 +89,7 @@ Unless you need to set a non-default value, it is recommended to only populate o | `OPCODELOGGER_ENABLED` | "true" | Whether the `opcodeLogger` tracer is enabled for a call to `debug_traceTransaction`. This setting should match the value `hiero.mirror.web3.opcode.tracer.enabled` in the Mirror Node used. See [HIP-801](https://hips.hedera.com/hip/hip-801) for more information. | | `JUMBO_TX_ENABLED` | "true" | Controls how large transactions are handled during `eth_sendRawTransaction`. When set to `true`, transactions up to 128KB can be sent directly to consensus nodes without using Hedera File Service (HFS), as long as contract bytecode doesn't exceed 24KB. When set to `false`, all transactions containing contract deployments use the traditional HFS approach. This feature leverages the increased transaction size limit to simplify processing of standard Ethereum transactions. | | `IP_RATE_LIMIT_STORE` | null | Specifies the rate limit store to use for IP-based rate limiting: valid values are "LRU", "REDIS", with the possibility to be extended with a custom implementation (see [Store Selection](rate-limiting.md#store-selection)). If unset, falls back to Redis when `REDIS_ENABLED=true`, otherwise uses in-memory LRU. | +| `READ_ONLY` | "false" | Starts the JSON-RPC Relay in Read-Only mode. In Read-Only mode, write operations, _e.g._, `eth_sendRawTransaction` or query operations to the Consensus Node will trigger an `Unsupported operation` error. | | `REDIS_ENABLED` | "true" | Enable usage of Redis as shared cache | | `REDIS_RECONNECT_DELAY_MS` | "1000" | Sets the delay between reconnect retries from the Redis client in ms | | `REDIS_URL` | "redis://127.0.0.1:6379" | Sets the url for the Redis shared cache | diff --git a/packages/config-service/src/services/globalConfig.ts b/packages/config-service/src/services/globalConfig.ts index fce1de6943..681670a78d 100644 --- a/packages/config-service/src/services/globalConfig.ts +++ b/packages/config-service/src/services/globalConfig.ts @@ -602,7 +602,7 @@ const _CONFIG = { OPERATOR_ID_MAIN: { envName: 'OPERATOR_ID_MAIN', type: 'string', - required: true, + required: false, defaultValue: null, }, OPERATOR_KEY_ETH_SENDRAWTRANSACTION: { @@ -620,7 +620,7 @@ const _CONFIG = { OPERATOR_KEY_MAIN: { envName: 'OPERATOR_KEY_MAIN', type: 'string', - required: true, + required: false, defaultValue: null, }, RATE_LIMIT_DISABLED: { @@ -629,6 +629,12 @@ const _CONFIG = { required: false, defaultValue: false, }, + READ_ONLY: { + envName: 'READ_ONLY', + type: 'boolean', + required: false, + defaultValue: false, + }, REDIS_ENABLED: { envName: 'REDIS_ENABLED', type: 'boolean', diff --git a/packages/config-service/src/services/index.ts b/packages/config-service/src/services/index.ts index 5e33aa646d..d87c88998a 100644 --- a/packages/config-service/src/services/index.ts +++ b/packages/config-service/src/services/index.ts @@ -62,6 +62,8 @@ export class ConfigService { for (const name in this.envs) { logger.info(LoggerService.maskUpEnv(name, this.envs[name])); } + + this.validateReadOnlyMode(); } /** @@ -95,18 +97,7 @@ export class ConfigService { * @throws Error if a required configuration value is missing. */ public static get(name: K): GetTypeOfConfigKey { - const configEntry = GlobalConfig.ENTRIES[name]; - let value = this.getInstance().envs[name] == undefined ? configEntry?.defaultValue : this.getInstance().envs[name]; - - if (value == undefined && configEntry?.required) { - throw new Error(`Configuration error: ${name} is a mandatory configuration for relay operation.`); - } - - if (name === 'CHAIN_ID' && value !== undefined) { - value = `0x${Number(value).toString(16)}`; - } - - return value as GetTypeOfConfigKey; + return this.getInstance().get(name); } /** @@ -124,4 +115,39 @@ export class ConfigService { return maskedEnvs; } + + private validateReadOnlyMode() { + const vars = ['OPERATOR_ID_MAIN', 'OPERATOR_KEY_MAIN'] as const; + if (this.get('READ_ONLY')) { + logger.info('Relay is in READ_ONLY mode. It will not send transactions.'); + vars.forEach((varName) => { + if (this.get(varName)) { + logger.warn( + `Relay is in READ_ONLY mode, but ${varName} is set. The Relay will not be able to send transactions.`, + ); + } + }); + } else { + vars.forEach((varName) => { + if (!this.get(varName)) { + throw new Error(`Configuration error: ${varName} is mandatory for Relay operations in Read-Write mode.`); + } + }); + } + } + + private get(name: K): GetTypeOfConfigKey { + const configEntry = GlobalConfig.ENTRIES[name]; + let value = this.envs[name] == undefined ? configEntry?.defaultValue : this.envs[name]; + + if (value == undefined && configEntry?.required) { + throw new Error(`Configuration error: ${name} is a mandatory configuration for relay operation.`); + } + + if (name === 'CHAIN_ID' && value !== undefined) { + value = `0x${Number(value).toString(16)}`; + } + + return value as GetTypeOfConfigKey; + } } diff --git a/packages/config-service/tests/src/services/configService.spec.ts b/packages/config-service/tests/src/services/configService.spec.ts index cc29dbeefc..a8e606d3b4 100644 --- a/packages/config-service/tests/src/services/configService.spec.ts +++ b/packages/config-service/tests/src/services/configService.spec.ts @@ -16,23 +16,61 @@ describe('ConfigService tests', async function () { process.env = {}; // fake invalid env file - // @ts-ignore + // @ts-expect-error: The operand of a 'delete' operator must be optional delete ConfigService.instance; - // @ts-ignore + // @ts-expect-error: Property 'envFileName' is private and only accessible within class 'ConfigService' ConfigService.envFileName = 'invalid'; - // @ts-ignore - expect(() => ConfigService.getInstance()).to.throw(); + // @ts-expect-error: Property 'getInstance' is private and only accessible within class 'ConfigService' + expect(() => ConfigService.getInstance()).to.throw( + /Configuration error: [A-Z_]+ is a mandatory configuration for relay operation./, + ); // reset normal behaviour - // @ts-ignore + // @ts-expect-error: The operand of a 'delete' operator must be optional delete ConfigService.instance; - // @ts-ignore + // @ts-expect-error: Property 'envFileName' is private and only accessible within class 'ConfigService' ConfigService.envFileName = '.env'; process.env = envBefore; }); + [ + { OPERATOR_ID_MAIN: undefined, OPERATOR_KEY_MAIN: '0x1234567890' }, + { OPERATOR_ID_MAIN: '0.0.123', OPERATOR_KEY_MAIN: undefined }, + ].forEach((env) => { + const key = env.OPERATOR_ID_MAIN === undefined ? 'OPERATOR_ID_MAIN' : 'OPERATOR_KEY_MAIN'; + it(`should prevent the Relay from starting when \`${key}\` is missing in Read-Write mode`, async () => { + const envBefore = process.env; + process.env = { ...process.env, READ_ONLY: 'false', ...env }; + + // @ts-expect-error: The operand of a 'delete' operator must be optional + delete ConfigService.instance; + + expect(() => ConfigService.get(undefined as unknown as ConfigKey)).to.throw( + `Configuration error: ${key} is mandatory for Relay operations in Read-Write mode.`, + ); + + // @ts-expect-error: The operand of a 'delete' operator must be optional + delete ConfigService.instance; + process.env = envBefore; + }); + + it(`should start the Relay even when \`${key}\` is missing in Read-Only mode`, async () => { + const envBefore = process.env; + process.env = { ...process.env, READ_ONLY: 'true', ...env }; + + // @ts-expect-error: The operand of a 'delete' operator must be optional + delete ConfigService.instance; + + expect(ConfigService.get('READ_ONLY')).to.be.true; + + // @ts-expect-error: The operand of a 'delete' operator must be optional + delete ConfigService.instance; + process.env = envBefore; + }); + }); + it('should be able to get existing env var', async () => { const res = ConfigService.get('CHAIN_ID'); expect(res).to.equal('0x12a'); @@ -62,7 +100,7 @@ describe('ConfigService tests', async function () { 'GET_RECORD_DEFAULT_TO_CONSENSUS_NODE', 'E2E_RELAY_HOST', 'ETH_CALL_ACCEPTED_ERRORS', - ] as ConfigKey[]; + ] as const; targetKeys.forEach((targetKey) => { const result = ConfigService.get(targetKey); @@ -90,7 +128,7 @@ describe('ConfigService tests', async function () { // Reset the ConfigService singleton instance to force a new initialization // This is necessary because ConfigService caches the env values when first instantiated, // so we need to clear that cache to test with our new CHAIN_ID value - // @ts-ignore - accessing private property for testing + // @ts-expect-error: The operand of a 'delete' operator must be optional delete ConfigService.instance; expect(ConfigService.get('CHAIN_ID')).to.equal(expected); }; @@ -103,7 +141,7 @@ describe('ConfigService tests', async function () { testChainId('0xhedera', '0xNaN'); // invalid number } finally { process.env = originalEnv; - // @ts-ignore - accessing private property for testing + // @ts-expect-error: The operand of a 'delete' operator must be optional delete ConfigService.instance; } }); diff --git a/packages/relay/src/lib/clients/sdkClient.ts b/packages/relay/src/lib/clients/sdkClient.ts index 1eef53c255..80da71b8ee 100644 --- a/packages/relay/src/lib/clients/sdkClient.ts +++ b/packages/relay/src/lib/clients/sdkClient.ts @@ -42,8 +42,6 @@ import constants from './../constants'; import { JsonRpcError, predefined } from './../errors/JsonRpcError'; import { SDKClientError } from './../errors/SDKClientError'; -const _ = require('lodash'); - export class SDKClient { /** * The client to use for connecting to the main consensus network. The account diff --git a/packages/relay/src/lib/services/ethService/transactionService/TransactionService.ts b/packages/relay/src/lib/services/ethService/transactionService/TransactionService.ts index d81d32fb9e..a3d556f562 100644 --- a/packages/relay/src/lib/services/ethService/transactionService/TransactionService.ts +++ b/packages/relay/src/lib/services/ethService/transactionService/TransactionService.ts @@ -262,6 +262,10 @@ export class TransactionService implements ITransactionService { * @returns {Promise} A promise that resolves to the transaction hash or a JsonRpcError if an error occurs */ async sendRawTransaction(transaction: string, requestDetails: RequestDetails): Promise { + if (ConfigService.get('READ_ONLY')) { + throw predefined.UNSUPPORTED_OPERATION('Relay is in read-only mode'); + } + const transactionBuffer = Buffer.from(this.prune0x(transaction), 'hex'); const parsedTx = Precheck.parseRawTransaction(transaction); const networkGasPriceInWeiBars = Utils.addPercentageBufferToGasPrice( diff --git a/packages/relay/src/utils.ts b/packages/relay/src/utils.ts index ba091526e2..df34c81c84 100644 --- a/packages/relay/src/utils.ts +++ b/packages/relay/src/utils.ts @@ -128,16 +128,13 @@ export class Utils { * @returns {Operator | null} The operator credentials or null if not found */ public static getOperator(logger: Logger, type: string | null = null): Operator | null { - let operatorId: string; - let operatorKey: string; - - if (type === 'eth_sendRawTransaction') { - operatorId = ConfigService.get('OPERATOR_ID_ETH_SENDRAWTRANSACTION') as string; - operatorKey = ConfigService.get('OPERATOR_KEY_ETH_SENDRAWTRANSACTION') as string; - } else { - operatorId = ConfigService.get('OPERATOR_ID_MAIN'); - operatorKey = ConfigService.get('OPERATOR_KEY_MAIN'); - } + const [operatorId, operatorKey] = + type === 'eth_sendRawTransaction' + ? [ + ConfigService.get('OPERATOR_ID_ETH_SENDRAWTRANSACTION'), + ConfigService.get('OPERATOR_KEY_ETH_SENDRAWTRANSACTION'), + ] + : [ConfigService.get('OPERATOR_ID_MAIN'), ConfigService.get('OPERATOR_KEY_MAIN')]; if (!operatorId || !operatorKey) { logger.warn(`Invalid operatorId or operatorKey for ${type ?? 'main'} client.`); diff --git a/packages/relay/tests/lib/eth/eth_sendRawTransaction.spec.ts b/packages/relay/tests/lib/eth/eth_sendRawTransaction.spec.ts index d085bb8e9f..70bd0835e6 100644 --- a/packages/relay/tests/lib/eth/eth_sendRawTransaction.spec.ts +++ b/packages/relay/tests/lib/eth/eth_sendRawTransaction.spec.ts @@ -16,7 +16,6 @@ import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; import { EventEmitter } from 'events'; import pino from 'pino'; -import { Counter } from 'prom-client'; import sinon, { useFakeTimers } from 'sinon'; import { Eth, JsonRpcError, predefined } from '../../../src'; @@ -184,14 +183,14 @@ describe('@ethSendRawTransaction eth_sendRawTransaction spec', async function () it('should return a predefined GAS_LIMIT_TOO_HIGH instead of NUMERIC_FAULT as precheck exception', async function () { // tx with 'gasLimit: BigNumber { value: "30678687678687676876786786876876876000" }' - const txHash = + const tx = '0x02f881820128048459682f0086014fa0186f00901714801554cbe52dd95512bedddf68e09405fba803be258049a27b820088bab1cad205887185174876e80080c080a0cab3f53602000c9989be5787d0db637512acdd2ad187ce15ba83d10d9eae2571a07802515717a5a1c7d6fa7616183eb78307b4657d7462dbb9e9deca820dd28f62'; await RelayAssertions.assertRejection( predefined.GAS_LIMIT_TOO_HIGH(null, null), ethImpl.sendRawTransaction, false, ethImpl, - [txHash, requestDetails], + [tx, requestDetails], ); }); @@ -455,5 +454,32 @@ describe('@ethSendRawTransaction eth_sendRawTransaction spec', async function () }); }); }); + + withOverriddenEnvsInMochaTest({ READ_ONLY: true }, () => { + [false, true].forEach((useAsyncTxProcessing) => { + withOverriddenEnvsInMochaTest({ USE_ASYNC_TX_PROCESSING: useAsyncTxProcessing }, () => { + [ + { title: 'ill-formatted', transaction: constants.INVALID_TRANSACTION }, + { + title: 'failed precheck', + transaction: + '0x02f881820128048459682f0086014fa0186f00901714801554cbe52dd95512bedddf68e09405fba803be258049a27b820088bab1cad205887185174876e80080c080a0cab3f53602000c9989be5787d0db637512acdd2ad187ce15ba83d10d9eae2571a07802515717a5a1c7d6fa7616183eb78307b4657d7462dbb9e9deca820dd28f62', + }, + { title: 'valid', transaction }, + ].forEach(({ title, transaction }) => { + it(`should throw \`UNSUPPORTED_OPERATION\` when Relay is in Read-Only mode for a '${title}' transaction`, async function () { + const signed = typeof transaction === 'string' ? transaction : await signTransaction(transaction); + await RelayAssertions.assertRejection( + predefined.UNSUPPORTED_OPERATION('Relay is in read-only mode'), + ethImpl.sendRawTransaction, + false, + ethImpl, + [signed, requestDetails], + ); + }); + }); + }); + }); + }); }); }); diff --git a/packages/relay/tests/lib/utils.spec.ts b/packages/relay/tests/lib/utils.spec.ts index 02401cd0ae..ba00fab49e 100644 --- a/packages/relay/tests/lib/utils.spec.ts +++ b/packages/relay/tests/lib/utils.spec.ts @@ -157,34 +157,6 @@ describe('Utils', () => { }, ); }); - - withOverriddenEnvsInMochaTest( - { - OPERATOR_ID_MAIN: accountId, - OPERATOR_KEY_MAIN: null, - }, - () => { - it('should throw error if OPERATOR_KEY_MAIN is missing', () => { - expect(() => Utils.getOperator(logger)).to.throw( - 'Configuration error: OPERATOR_KEY_MAIN is a mandatory configuration for relay operation.', - ); - }); - }, - ); - - withOverriddenEnvsInMochaTest( - { - OPERATOR_ID_MAIN: null, - OPERATOR_KEY_MAIN: privateKeys[0].keyValue, - }, - () => { - it('should throw error if OPERATOR_ID_MAIN is missing', () => { - expect(() => Utils.getOperator(logger)).to.throw( - 'Configuration error: OPERATOR_ID_MAIN is a mandatory configuration for relay operation.', - ); - }); - }, - ); }); describe('getNetworkNameByChainId', () => { diff --git a/packages/server/tests/acceptance/sendRawTransactionExtension.spec.ts b/packages/server/tests/acceptance/sendRawTransactionExtension.spec.ts index 595665e330..26a5b803e2 100644 --- a/packages/server/tests/acceptance/sendRawTransactionExtension.spec.ts +++ b/packages/server/tests/acceptance/sendRawTransactionExtension.spec.ts @@ -2,6 +2,7 @@ // External resources import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; +import { ConfigServiceTestHelper } from '@hashgraph/json-rpc-config-service/tests/configServiceTestHelper'; // Other imports import { numberTo0x } from '@hashgraph/json-rpc-relay/dist/formatters'; import Constants from '@hashgraph/json-rpc-relay/dist/lib/constants'; @@ -180,4 +181,26 @@ describe('@sendRawTransactionExtension Acceptance Tests', function () { expect(info).to.exist; }); }); + + describe('Read-Only mode', function () { + it('@release should fail to execute "eth_sendRawTransaction" in Read-Only mode', async function () { + const readOnly = ConfigService.get('READ_ONLY'); + ConfigServiceTestHelper.dynamicOverride('READ_ONLY', true); + + const transaction = { + type: 2, + chainId: Number(CHAIN_ID), + nonce: 1234, + gasLimit: defaultGasLimit, + to: accounts[0].address, + data: '0x00', + }; + + const signedTx = await accounts[1].wallet.signTransaction(transaction); + const error = predefined.UNSUPPORTED_OPERATION('Relay is in read-only mode'); + await Assertions.assertPredefinedRpcError(error, sendRawTransaction, false, relay, [signedTx, requestDetails]); + + ConfigServiceTestHelper.dynamicOverride('READ_ONLY', readOnly); + }); + }); });