Skip to content
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
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
10 changes: 8 additions & 2 deletions packages/config-service/src/services/globalConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -620,7 +620,7 @@ const _CONFIG = {
OPERATOR_KEY_MAIN: {
envName: 'OPERATOR_KEY_MAIN',
type: 'string',
required: true,
required: false,
defaultValue: null,
},
RATE_LIMIT_DISABLED: {
Expand All @@ -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',
Expand Down
50 changes: 38 additions & 12 deletions packages/config-service/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
for (const name in this.envs) {
logger.info(LoggerService.maskUpEnv(name, this.envs[name]));
}

this.validateReadOnlyMode();
}

/**
Expand Down Expand Up @@ -95,18 +97,7 @@
* @throws Error if a required configuration value is missing.
*/
public static get<K extends ConfigKey>(name: K): GetTypeOfConfigKey<K> {
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<K>;
return this.getInstance().get(name);
}

/**
Expand All @@ -124,4 +115,39 @@

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<K extends ConfigKey>(name: K): GetTypeOfConfigKey<K> {
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.`);

Check warning on line 144 in packages/config-service/src/services/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/config-service/src/services/index.ts#L144

Added line #L144 was not covered by tests
}

if (name === 'CHAIN_ID' && value !== undefined) {
value = `0x${Number(value).toString(16)}`;
}

return value as GetTypeOfConfigKey<K>;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
};
Expand All @@ -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;
}
});
Expand Down
2 changes: 0 additions & 2 deletions packages/relay/src/lib/clients/sdkClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,10 @@ export class TransactionService implements ITransactionService {
* @returns {Promise<string | JsonRpcError>} A promise that resolves to the transaction hash or a JsonRpcError if an error occurs
*/
async sendRawTransaction(transaction: string, requestDetails: RequestDetails): Promise<string | JsonRpcError> {
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(
Expand Down
17 changes: 7 additions & 10 deletions packages/relay/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`);
Expand Down
32 changes: 29 additions & 3 deletions packages/relay/tests/lib/eth/eth_sendRawTransaction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
);
});

Expand Down Expand Up @@ -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],
);
});
});
});
});
});
});
});
28 changes: 0 additions & 28 deletions packages/relay/tests/lib/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
});
Loading