diff --git a/.env.http.example b/.env.http.example index a7b8cd7f0a..877e24fb83 100644 --- a/.env.http.example +++ b/.env.http.example @@ -23,6 +23,7 @@ OPERATOR_KEY_MAIN= # Operator private key used to sign transaction # TXPOOL_API_ENABLED=true # Enables txpool related methods # FILTER_API_ENABLED=true # Enables filter related methods # ESTIMATE_GAS_THROWS=true # If true, throws actual error reason during contract reverts +# VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE=true # If true, returns HTTP 200 for JSON-RPC errors # ========== BATCH REQUESTS ========== # BATCH_REQUESTS_ENABLED=true # Enable or disable batch requests diff --git a/.github/workflows/acceptance-workflow.yml b/.github/workflows/acceptance-workflow.yml index 01e93f4a10..2385b7d018 100644 --- a/.github/workflows/acceptance-workflow.yml +++ b/.github/workflows/acceptance-workflow.yml @@ -10,6 +10,10 @@ on: required: false default: false type: boolean + valid_json_rpc_http_requests_status_code: + required: false + default: false + type: boolean envfile: required: false default: localAcceptance.env @@ -104,6 +108,7 @@ jobs: GITHUB_PR_NUMBER: ${{ github.event.number }} GITHUB_REPOSITORY: ${{ github.repository }} OPERATOR_KEY: ${{ secrets.operator_key }} + VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE: "${{ inputs.valid_json_rpc_http_requests_status_code }}" - name: Upload Heap Snapshots if: ${{ !cancelled() }} @@ -117,7 +122,7 @@ jobs: if: always() uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: - name: Test Results (${{ inputs.testfilter }}) + name: Test Results (${{ inputs.testfilter }})${{ inputs.valid_json_rpc_http_requests_status_code && ' 200' || '' }} path: test-*.xml - name: Upload coverage report diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 897bbde37d..192b92b03c 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -38,10 +38,15 @@ jobs: - { name: 'Websocket Batch 3', testfilter: 'ws_batch3', test_ws_server: true } - { name: 'Cache Service', testfilter: 'cache-service' } - { name: 'Server Config', testfilter: 'serverconfig' } + - { name: 'Semantics and varying response status', testfilter: 'json_rpc_compliance' } + - name: "Semantics and default OK response status" + testfilter: "json_rpc_compliance" + valid_json_rpc_http_requests_status_code: true uses: ./.github/workflows/acceptance-workflow.yml with: testfilter: ${{ matrix.test.testfilter }} test_ws_server: ${{ matrix.test.test_ws_server || false }} + valid_json_rpc_http_requests_status_code: ${{ matrix.test.valid_json_rpc_http_requests_status_code || false }} secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/docs/configuration.md b/docs/configuration.md index b9698d0f5e..27db5a627f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -133,6 +133,7 @@ Unless you need to set a non-default value, it is recommended to only populate o | `SERVER_HOST` | undefined | The hostname or IP address on which the server listens for incoming connections. If `SERVER_HOST` is not configured or left undefined (same as `0.0.0.0`), it permits external connections by default, offering more flexibility. | | `SERVER_PORT` | "7546" | The RPC server port number to listen for requests on. Currently a static value defaulting to 7546. See [#955](https://github.com/hiero-ledger/hiero-json-rpc-relay/issues/955) | | `SERVER_REQUEST_TIMEOUT_MS` | "60000" | The time of inactivity allowed before a timeout is triggered and the socket is closed. See [NodeJs Server Timeout](https://nodejs.org/api/http.html#serversettimeoutmsecs-callback) | +| `VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE` | "false" | Setting this flag to "true" will cause the JSON-RPC relay server to always return an HTTP 200 status code for errors that comply with the JSON-RPC API standard, when a correctly formed request following the standard is received. | ## WS-Server diff --git a/package.json b/package.json index fec4c97981..d42a6b3dcb 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "acceptancetest:rpc_api_schema_conformity": "c8 ts-mocha packages/server/tests/acceptance/index.spec.ts -g '@api-conformity' --exit", "acceptancetest:serverconfig": "c8 ts-mocha packages/server/tests/acceptance/index.spec.ts -g '@server-config' --exit", "acceptancetest:send_raw_transaction_extension": "c8 ts-mocha packages/server/tests/acceptance/index.spec.ts -g '@sendRawTransactionExtension' --exit", + "acceptancetest:json_rpc_compliance": "c8 ts-mocha packages/server/tests/acceptance/index.spec.ts -g '@json-rpc-compliance' --exit", "acceptancetest:debug": "c8 ts-mocha packages/server/tests/acceptance/index.spec.ts -g '@debug' --exit", "acceptancetest:xts": "c8 ts-mocha packages/server/tests/acceptance/index.spec.ts -g '@xts' --exit", "build": "npx lerna run build", diff --git a/packages/config-service/src/services/globalConfig.ts b/packages/config-service/src/services/globalConfig.ts index 711e6d906d..7486dc81ab 100644 --- a/packages/config-service/src/services/globalConfig.ts +++ b/packages/config-service/src/services/globalConfig.ts @@ -739,6 +739,11 @@ const _CONFIG = { required: false, defaultValue: 10, }, + VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE: { + type: 'boolean', + required: false, + defaultValue: false, + }, } as const satisfies { [key: string]: ConfigProperty }; // Ensures _CONFIG is read-only and conforms to the ConfigProperty structure export type ConfigKey = keyof typeof _CONFIG; diff --git a/packages/server/src/compliance.ts b/packages/server/src/compliance.ts new file mode 100644 index 0000000000..5b297753e1 --- /dev/null +++ b/packages/server/src/compliance.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; +import { ParameterizedContext } from 'koa'; + +interface IResponseContext { + body: { + jsonrpc: unknown; + id: unknown; + result?: unknown; + error?: { code: unknown; message: unknown }; + }; + status: number | undefined; +} + +const VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE = ConfigService.get('VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE'); + +const FALLBACK_RESPONSE_BODY = { + jsonrpc: '2.0', + id: null, + error: { code: -32600, message: 'Request body is empty; expected a JSON-RPC 2.0 request' }, +}; + +export const INVALID_METHOD_RESPONSE_BODY = { + ...FALLBACK_RESPONSE_BODY, + error: { code: -32600, message: 'Invalid HTTP method: only POST is allowed' }, +}; + +const makeSureBodyExistsAndCanBeChecked = (ctx: IResponseContext) => { + if (ctx.status === 200) return false; + + if (!ctx.body) { + ctx.status = 400; + ctx.body = structuredClone(FALLBACK_RESPONSE_BODY); + return false; + } + + if (Array.isArray(ctx.body)) { + ctx.status = 200; + return false; + } + + if (typeof ctx.body !== 'object') { + ctx.status = 400; + ctx.body = structuredClone(FALLBACK_RESPONSE_BODY); + return false; + } + if (!ctx.body.jsonrpc) ctx.body.jsonrpc = FALLBACK_RESPONSE_BODY.jsonrpc; + if (!ctx.body.id) ctx.body.id = FALLBACK_RESPONSE_BODY.id; + + return true; +}; + +/** + * Ensures a JSON-RPC response uses a valid JSON-RPC 2.0 structure. + * Normalizes missing or invalid fields for both single and batch responses. + * May update HTTP status depending on VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE. + * + * @param {IResponseContext & ParameterizedContext} ctx - Koa context containing status and body. + */ +export const jsonRpcComplianceLayer = (ctx: IResponseContext & ParameterizedContext) => { + if (!makeSureBodyExistsAndCanBeChecked(ctx)) return; + if (ctx.status === 400) { + if (!ctx.body.error?.code) ctx.body.error = structuredClone(FALLBACK_RESPONSE_BODY.error); + if (VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE) ctx.status = 200; + } +}; diff --git a/packages/server/src/koaJsonRpc/index.ts b/packages/server/src/koaJsonRpc/index.ts index a1afd790aa..b37e0309bb 100644 --- a/packages/server/src/koaJsonRpc/index.ts +++ b/packages/server/src/koaJsonRpc/index.ts @@ -124,11 +124,13 @@ export default class KoaJsonRpc { // verify max batch size if (body.length > this.batchRequestsMaxSize) { - ctx.body = jsonRespError( - null, - predefined.BATCH_REQUESTS_AMOUNT_MAX_EXCEEDED(body.length, this.batchRequestsMaxSize), - requestId, - ); + ctx.body = [ + jsonRespError( + null, + predefined.BATCH_REQUESTS_AMOUNT_MAX_EXCEEDED(body.length, this.batchRequestsMaxSize), + requestId, + ), + ]; ctx.status = 400; ctx.state.status = `${ctx.status} (${INVALID_REQUEST})`; return; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 570880b3ff..3014ee190a 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -12,6 +12,7 @@ import pino from 'pino'; import { collectDefaultMetrics, Histogram, Registry } from 'prom-client'; import { v4 as uuid } from 'uuid'; +import { INVALID_METHOD_RESPONSE_BODY, jsonRpcComplianceLayer } from './compliance'; import { formatRequestIdMessage } from './formatters'; import KoaJsonRpc from './koaJsonRpc'; import { spec } from './koaJsonRpc/lib/RpcError'; @@ -277,7 +278,8 @@ export async function initializeServer() { // support CORS preflight ctx.status = 200; } else { - logger.warn(`skipping HTTP method: [${ctx.method}], url: ${ctx.url}, status: ${ctx.status}`); + ctx.status = ConfigService.get('VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE') ? 200 : 400; + ctx.body = structuredClone(INVALID_METHOD_RESPONSE_BODY); } }); @@ -309,6 +311,7 @@ export async function initializeServer() { app.use(async (ctx) => { await rpcApp(ctx); + jsonRpcComplianceLayer(ctx); }); process.on('unhandledRejection', (reason, p) => { diff --git a/packages/server/tests/acceptance/jsonRpcCompliance.spec.ts b/packages/server/tests/acceptance/jsonRpcCompliance.spec.ts new file mode 100644 index 0000000000..de11511410 --- /dev/null +++ b/packages/server/tests/acceptance/jsonRpcCompliance.spec.ts @@ -0,0 +1,530 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'; +import axios, { AxiosInstance, AxiosResponse } from 'axios'; +import { expect } from 'chai'; + +import { RELAY_URL } from './data/conformity/utils/constants'; +import { JsonRpcResponse } from './data/conformity/utils/interfaces'; + +describe('@json-rpc-compliance HTTP/JSON-RPC semantics acceptance tests', function () { + this.timeout(60000); + + const baseURL = RELAY_URL; + + let client: AxiosInstance; + + before(function () { + client = axios.create({ + baseURL, + validateStatus: () => true, + }); + }); + + async function sendRaw( + method: 'GET' | 'POST' | 'PUT', + path: string, + body?: any, + headers?: Record, + ): Promise { + return client.request({ + url: path, + method, + headers, + data: body, + }); + } + + async function sendJsonRpc( + payload: any, + headers?: Record, + ): Promise> { + return sendRaw('POST', '/', payload, { + 'Content-Type': 'application/json', + ...headers, + }); + } + + function expectValidJsonRpc(response: AxiosResponse, { allowEmptyBody = false } = {}) { + if (allowEmptyBody) { + expect(response.data === '' || response.data === undefined || response.data === null).to.be.true; + return; + } + + const body = response.data; + expect(body).to.not.equal(undefined); + + const responses = Array.isArray(body) ? body : [body]; + for (const singleResponse of responses) { + expect(singleResponse).to.have.property('jsonrpc', '2.0'); + expect(singleResponse).to.have.property('id'); + const hasResult = Object.prototype.hasOwnProperty.call(singleResponse, 'result'); + const hasError = Object.prototype.hasOwnProperty.call(singleResponse, 'error'); + expect(hasResult || hasError).to.be.true; + if (hasError) { + expect(singleResponse.error).to.have.property('code').that.is.a('number'); + expect(singleResponse.error).to.have.property('message').that.is.a('string').and.has.length.greaterThan(5); + } + } + } + + function expectCorrectResult(response: AxiosResponse) { + expect(response.status).to.equal(200); + expect(response.data).to.have.property('result'); + expect(response.data).to.not.have.property('error'); + expect(response.data).to.have.property('jsonrpc', '2.0'); + } + + function expectNoHttp500(response: AxiosResponse) { + expect(response.status).to.not.equal(500); + } + + function expectBatchLimitExceeded(response: AxiosResponse) { + expect(response.status).to.equal(200); + expect(Array.isArray(response.data)).to.be.true; + + const body = response.data as JsonRpcResponse[]; + + for (const entry of body) { + expect(entry).to.have.property('error'); + const err = entry.error!; + expect(err.code).to.equal(-32203); + expect(err.message.toLowerCase()).to.include('batch'); + } + + expectNoHttp500(response); + } + + describe('With VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE = false', function () { + before(function () { + if (ConfigService.get('VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE')) this.skip(); + }); + + it('Malformed HTTP method/body -> 400 when flag is false', async function () { + const getWithBody = await sendRaw('GET', '/', { + jsonrpc: '2.0', + id: 1, + method: 'eth_blockNumber', + params: [], + }); + expect(getWithBody.status).to.equal(400); + expectValidJsonRpc(getWithBody); + const getNoBody = await sendRaw('GET', '/'); + expect(getNoBody.status).to.equal(400); + expectValidJsonRpc(getNoBody); + const putEmptyBody = await sendRaw('PUT', '/', ''); + expect(putEmptyBody.status).to.equal(400); + expectValidJsonRpc(putEmptyBody); + }); + + it('Malformed/missing Content-Type but valid JSON body is still processed as JSON-RPC', async function () { + const noContentType = await sendRaw( + 'POST', + '/', + { + jsonrpc: '2.0', + id: 1, + method: 'eth_blockNumber', + params: [], + }, + {}, + ); + expectCorrectResult(noContentType); + + const wrongContentType = await sendRaw( + 'POST', + '/', + { + jsonrpc: '2.0', + id: 2, + method: 'eth_blockNumber', + params: [], + }, + { 'Content-Type': 'application/not-json' }, + ); + expectCorrectResult(wrongContentType); + }); + + it('Invalid JSON payload -> 400 + JSON-RPC error -32700 when flag is false', async function () { + const brokenJson = '{"jsonrpc":"2.0",'; + + const response = await sendRaw('POST', '/', brokenJson, { + 'Content-Type': 'application/json', + }); + + expect(response.status).to.equal(400); + expect(response.data).to.have.property('jsonrpc', '2.0'); + expect(response.data).to.have.property('error'); + expect(response.data.error.code).to.equal(-32700); + expectNoHttp500(response); + }); + + it('Valid JSON but invalid JSON-RPC -> 400 + -32600 when flag is false', async function () { + const response = await sendJsonRpc({ id: 1 }); + + expect(response.status).to.equal(400); + expect(response.data).to.have.property('error'); + expect((response.data as JsonRpcResponse).error!.code).to.equal(-32600); + expectNoHttp500(response); + }); + + it('Unknown/unsupported method -> 400 + -32601 when flag is false', async function () { + const response = await sendJsonRpc({ + jsonrpc: '2.0', + id: 1, + method: 'eth_doesNotExist', + params: [], + }); + + expect(response.status).to.equal(400); + expect(response.data).to.have.property('error'); + expect((response.data as JsonRpcResponse).error!.code).to.equal(-32601); + expectNoHttp500(response); + }); + + it('Invalid parameters -> 400 + -32602 when flag is false', async function () { + const response = await sendJsonRpc({ + jsonrpc: '2.0', + id: 1, + method: 'eth_getBalance', + params: [42, true], + }); + + expect(response.status).to.equal(400); + expect(response.data).to.have.property('error'); + const error = (response.data as JsonRpcResponse).error!; + expect(error.code).to.equal(-32602); + expect(error.message.toLowerCase()).to.not.equal('invalid request'); + expectNoHttp500(response); + }); + + it('Limit exceeded -> 400 + JSON-RPC error when flag is false', async function () { + const addresses = Array.from({ length: 1000000 }, (_, i) => { + const hex = i.toString(16).padStart(40, '0'); + return `0x${hex}`; + }); + + const response = await sendJsonRpc({ + jsonrpc: '2.0', + id: 'limit-test', + method: 'eth_getLogs', + params: [ + { + fromBlock: '0x1', + toBlock: '0x1000000', + address: addresses, + }, + ], + }); + + expect(response.status).to.eq(400); + expect(response.data).to.have.property('error'); + const error = (response.data as JsonRpcResponse).error!; + expect(error.code).to.be.a('number'); + expectNoHttp500(response); + }); + + it('Only fully valid requests return 200; all client errors use HTTP 400 when flag is false', async function () { + const ok = await sendJsonRpc({ + jsonrpc: '2.0', + id: 1, + method: 'eth_blockNumber', + params: [], + }); + + expect(ok.status).to.equal(200); + expect(ok.data).to.have.property('result'); + expect(ok.data).to.not.have.property('error'); + expectNoHttp500(ok); + + const clientError = await sendJsonRpc({ + jsonrpc: '2.0', + id: 2, + method: 'eth_doesNotExist', + params: [], + }); + + expect(clientError.status).to.equal(400); + expect(clientError.data).to.have.property('error'); + expect((clientError.data as JsonRpcResponse).error!.code).to.equal(-32601); + expectNoHttp500(clientError); + }); + + it('Flag is configuration-driven: behavior corresponds to VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE=false', async function () { + const response = await sendJsonRpc({ + jsonrpc: '2.0', + id: 'flag-off', + method: 'eth_getBalance', + params: [42, true], + }); + + expect(response.status).to.equal(400); + expect(response.data).to.have.property('error'); + expect((response.data as JsonRpcResponse).error!.code).to.equal(-32602); + expectNoHttp500(response); + }); + + it('Valid JSON-RPC response bodies for malformed requests', async function () { + const cases = [ + { + payload: { + jsonrpc: '2.0', + id: 'ok', + method: 'eth_blockNumber', + params: [], + }, + }, + { + payload: { id: 'invalid-rpc' }, + }, + { + payload: { + jsonrpc: '2.0', + id: 'unknown-method', + method: 'eth_doesNotExist', + params: [], + }, + }, + ]; + + for (const singleCase of cases) { + const response = await sendJsonRpc(singleCase.payload); + if (response.status === 413) continue; + expectValidJsonRpc(response); + expectNoHttp500(response); + } + }); + + it('Too many requests in a batch -> array with -32005 errors (flag=false -> HTTP 400)', async function () { + const batchSize = 5000; + const batch = Array.from({ length: batchSize }, (_, i) => ({ + jsonrpc: '2.0', + id: `batch-${i}`, + method: 'eth_blockNumber', + params: [], + })); + expectBatchLimitExceeded(await sendJsonRpc(batch)); + }); + }); + + describe('With VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE = true', function () { + before(function () { + if (!ConfigService.get('VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE')) this.skip(); + }); + + it('Malformed HTTP method/body -> 200 + JSON-RPC error when flag is true', async function () { + const getWithBody = await sendRaw('GET', '/', { + jsonrpc: '2.0', + id: 1, + method: 'eth_blockNumber', + params: [], + }); + + expect(getWithBody.status).to.equal(200); + expect(getWithBody.data).to.have.property('error'); + const getNoBody = await sendRaw('GET', '/'); + expect(getNoBody.status).to.equal(200); + expect(getNoBody.data).to.have.property('error'); + const putEmptyBody = await sendRaw('PUT', '/', ''); + expect(putEmptyBody.status).to.equal(200); + expect(putEmptyBody.data).to.have.property('error'); + }); + + it('Malformed/missing Content-Type but valid JSON body is still processed as JSON-RPC (flag=true)', async function () { + const noContentType = await sendRaw( + 'POST', + '/', + { + jsonrpc: '2.0', + id: 1, + method: 'eth_blockNumber', + params: [], + }, + {}, + ); + + expect(noContentType.status).to.equal(200); + expect(noContentType.data).to.have.property('result'); + expect(noContentType.data).to.not.have.property('error'); + + const wrongContentType = await sendRaw( + 'POST', + '/', + { + jsonrpc: '2.0', + id: 2, + method: 'eth_blockNumber', + params: [], + }, + { 'Content-Type': 'application/not-json' }, + ); + + expect(wrongContentType.status).to.equal(200); + expect(wrongContentType.data).to.have.property('result'); + expect(wrongContentType.data).to.not.have.property('error'); + }); + + it('Invalid JSON payload -> 200 + JSON-RPC error -32700 when flag is true', async function () { + const brokenJson = '{"jsonrpc":"2.0",'; + + const response = await sendRaw('POST', '/', brokenJson, { 'Content-Type': 'application/json' }); + + expect(response.status).to.equal(200); + expect(response.data).to.have.property('jsonrpc', '2.0'); + expect(response.data).to.have.property('error'); + expect(response.data.error.code).to.equal(-32700); + expectNoHttp500(response); + }); + + it('Valid JSON but invalid JSON-RPC -> 200 + -32600 when flag is true', async function () { + const response = await sendJsonRpc({ id: 1 }); + + expect(response.status).to.equal(200); + expect(response.data).to.have.property('error'); + expect((response.data as JsonRpcResponse).error!.code).to.equal(-32600); + expectNoHttp500(response); + }); + + it('Unknown/unsupported method -> 200 + -32601 when flag is true', async function () { + const response = await sendJsonRpc({ + jsonrpc: '2.0', + id: 1, + method: 'eth_doesNotExist', + params: [], + }); + + expect(response.status).to.equal(200); + expect(response.data).to.have.property('error'); + expect((response.data as JsonRpcResponse).error!.code).to.equal(-32601); + expectNoHttp500(response); + }); + + it('Invalid parameters -> 200 + -32602 when flag is true', async function () { + const response = await sendJsonRpc({ + jsonrpc: '2.0', + id: 1, + method: 'eth_getBalance', + params: [42, true], + }); + + expect(response.status).to.equal(200); + expect(response.data).to.have.property('error'); + expect((response.data as JsonRpcResponse).error!.code).to.equal(-32602); + expectNoHttp500(response); + }); + + it('Limit exceeded -> 200 + JSON-RPC error when flag is true', async function () { + const addresses = Array.from({ length: 1000000 }, (_, i) => { + const hex = i.toString(16).padStart(40, '0'); + return `0x${hex}`; + }); + + const response = await sendJsonRpc({ + jsonrpc: '2.0', + id: 'limit-test', + method: 'eth_getLogs', + params: [ + { + fromBlock: '0x1', + toBlock: '0x1000000', + address: addresses, + }, + ], + }); + + expect(response.status).to.equal(200); + expect(response.data).to.have.property('error'); + const error = (response.data as JsonRpcResponse).error!; + expect(error.code).to.be.a('number'); + expectNoHttp500(response); + }); + + it('All syntactically valid JSON-RPC payloads return HTTP 200 (errors only via JSON-RPC error objects)', async function () { + const cases: any[] = [ + { + jsonrpc: '2.0', + id: 'unknown', + method: 'eth_doesNotExist', + params: [], + }, + { + jsonrpc: '2.0', + id: 'invalid-params', + method: 'eth_getBalance', + params: [42, true], + }, + { id: 'invalid-rpc' }, + ]; + + for (const payload of cases) { + const response = await sendJsonRpc(payload); + expect(response.status).to.equal(200); + expect(response.data).to.have.property('jsonrpc', '2.0'); + expect(response.data).to.have.property('error'); + expectNoHttp500(response); + } + }); + + it('Flag is configuration-driven: behavior corresponds to VALID_JSON_RPC_HTTP_REQUESTS_STATUS_CODE=true', async function () { + const response = await sendJsonRpc({ + jsonrpc: '2.0', + id: 'flag-on', + method: 'eth_getBalance', + params: [42, true], + }); + + expect(response.status).to.equal(200); + expect(response.data).to.have.property('error'); + expect((response.data as JsonRpcResponse).error!.code).to.equal(-32602); + expectNoHttp500(response); + }); + + it('General JSON-RPC shape, clear errors, and no HTTP 500 with flag=true', async function () { + const cases = [ + { + payload: { + jsonrpc: '2.0', + id: 'ok', + method: 'eth_blockNumber', + params: [], + }, + }, + { payload: { id: 'invalid-rpc' } }, + { + payload: { + jsonrpc: '2.0', + id: 'unknown-method', + method: 'eth_doesNotExist', + params: [], + }, + }, + { + payload: { + jsonrpc: '2.0', + id: 'invalid-params', + method: 'eth_getBalance', + params: [42, true], + }, + }, + ]; + + for (const singleCase of cases) { + const response = await sendJsonRpc(singleCase.payload); + if (response.status === 413) continue; + expectValidJsonRpc(response); + expectNoHttp500(response); + } + }); + + it('Too many requests in a batch -> array with -32005 errors and HTTP 200 when flag=true', async function () { + const batchSize = 5000; + const batch = Array.from({ length: batchSize }, (_, i) => ({ + jsonrpc: '2.0', + id: `batch-${i}`, + method: 'eth_blockNumber', + params: [], + })); + expectBatchLimitExceeded(await sendJsonRpc(batch)); + }); + }); +}); diff --git a/packages/server/tests/integration/server.spec.ts b/packages/server/tests/integration/server.spec.ts index 7db403a096..de2043a708 100644 --- a/packages/server/tests/integration/server.spec.ts +++ b/packages/server/tests/integration/server.spec.ts @@ -885,14 +885,8 @@ describe('RPC Server', function () { for (let i = 0; i < 101; i++) { requests.push(getEthChainIdRequest(i + 1)); } - - // execute batch request - try { - await testClient.post('/', requests); - Assertions.expectedError(); - } catch (error: any) { - BaseTest.batchRequestLimitError(error.response, requests.length, 100); - } + const response = await testClient.post('/', requests); + BaseTest.batchRequestLimitError(response, requests.length, 100); }); withOverriddenEnvsInMochaTest({ BATCH_REQUESTS_ENABLED: false }, async function () { @@ -3410,10 +3404,12 @@ class BaseTest { } static batchRequestLimitError(response: any, amount: number, max: number) { - expect(response.status).to.eq(400); - expect(response.statusText).to.be.equal('Bad Request'); - expect(response.data.error.message).to.match(requestIdRegex(`Batch request amount ${amount} exceeds max ${max}`)); - expect(response.data.error.code).to.eq(-32203); + expect(response.status).to.eq(200); + expect(response.statusText).to.be.equal('OK'); + expect(response.data[0].error.message).to.match( + requestIdRegex(`Batch request amount ${amount} exceeds max ${max}`), + ); + expect(response.data[0].error.code).to.eq(-32203); } static invalidParamError(response: any, code: number, message: string) {