diff --git a/package-lock.json b/package-lock.json index bf83baeca7..c4b8ffb039 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5503,13 +5503,13 @@ "dev": true }, "node_modules/@nestjs/axios": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.0.0.tgz", - "integrity": "sha512-ULdH03jDWkS5dy9X69XbUVbhC+0pVnrRcj7bIK/ytTZ76w7CgvTZDJqsIyisg3kNOiljRW/4NIjSf3j6YGvl+g==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.3.tgz", + "integrity": "sha512-RZ/63c1tMxGLqyG3iOCVt7A72oy4x1eM6QEhd4KzCYpaVWW0igq0WSREeRoEZhIxRcZfDfIIkvsOMiM7yfVGZQ==", + "license": "MIT", "peerDependencies": { "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", "axios": "^1.3.1", - "reflect-metadata": "^0.1.12", "rxjs": "^6.0.0 || ^7.0.0" } }, @@ -19048,11 +19048,12 @@ } }, "node_modules/axios": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", - "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -26015,15 +26016,16 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -54387,12 +54389,14 @@ "version": "0.0.0", "license": "MIT", "dependencies": { + "@nestjs/axios": "^3.1.3", "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.0.0", "@nestjs/platform-express": "^9.0.0", "@nestjs/terminus": "^9.1.2", "@willsoto/nestjs-prometheus": "^4.7.0", + "axios": "^1.7.9", "ethers": "6.13.4", "nest-winston": "^1.7.0", "prom-client": "^14.1.0", diff --git a/packages/app/src/components/contract/InfoTable.vue b/packages/app/src/components/contract/InfoTable.vue index 27405d72f1..a9cab847f0 100644 --- a/packages/app/src/components/contract/InfoTable.vue +++ b/packages/app/src/components/contract/InfoTable.vue @@ -9,7 +9,7 @@ - + {{ t("contract.table.creator") }} diff --git a/packages/app/src/composables/useTransactionData.ts b/packages/app/src/composables/useTransactionData.ts index 341eb482e2..8cbf4121da 100644 --- a/packages/app/src/composables/useTransactionData.ts +++ b/packages/app/src/composables/useTransactionData.ts @@ -1,6 +1,6 @@ import { ref } from "vue"; -import { AbiCoder, Interface } from "ethers"; +import { Interface } from "ethers"; import useAddress from "./useAddress"; import useContext from "./useContext"; @@ -10,22 +10,26 @@ import type { AbiFragment } from "./useAddress"; import type { InputType } from "./useEventLog"; import type { Address } from "@/types"; -const defaultAbiCoder: AbiCoder = AbiCoder.defaultAbiCoder(); +import { decodeInputData } from "@/utils/helpers"; +export type MethodData = { + name: string; + inputs: InputData[]; +}; + +export type InputData = { + name: string; + type: InputType | string; + value: string; + inputs: InputData[]; + encodedValue: string; +}; export type TransactionData = { calldata: string; contractAddress: Address | null; value: string; sighash: string; - method?: { - name: string; - inputs: { - name: string; - type: InputType; - value: string; - encodedValue: string; - }[]; - }; + method?: MethodData; }; export function decodeDataWithABI( @@ -33,7 +37,6 @@ export function decodeDataWithABI( abi: AbiFragment[] ): TransactionData["method"] | undefined { const contractInterface = new Interface(abi); - try { const decodedData = contractInterface.parseTransaction({ data: transactionData.calldata, @@ -41,12 +44,7 @@ export function decodeDataWithABI( })!; return { name: decodedData.name, - inputs: decodedData.fragment.inputs.map((input) => ({ - name: input.name, - type: input.type as InputType, - value: decodedData.args[input.name]?.toString(), - encodedValue: defaultAbiCoder.encode([input.type], [decodedData.args[input.name]]).split("0x")[1], - })), + inputs: decodedData.fragment.inputs.flatMap((input) => decodeInputData(input, decodedData.args[input.name])), }; } catch { return undefined; diff --git a/packages/app/src/utils/helpers.ts b/packages/app/src/utils/helpers.ts index fe8bc94e0d..b3a9c6a282 100644 --- a/packages/app/src/utils/helpers.ts +++ b/packages/app/src/utils/helpers.ts @@ -1,5 +1,5 @@ import { format } from "date-fns"; -import { Interface } from "ethers"; +import { AbiCoder, Interface } from "ethers"; import { utils } from "zksync-ethers"; import { DEPLOYER_CONTRACT_ADDRESS } from "./constants"; @@ -8,9 +8,13 @@ import type { DecodingType } from "@/components/transactions/infoTable/HashViewe import type { AbiFragment } from "@/composables/useAddress"; import type { InputType, TransactionEvent, TransactionLogEntry } from "@/composables/useEventLog"; import type { TokenTransfer } from "@/composables/useTransaction"; +import type { InputData } from "@/composables/useTransactionData"; +import type { ParamType, Result } from "ethers"; const { BOOTLOADER_FORMAL_ADDRESS } = utils; +export const DefaultAbiCoder: AbiCoder = AbiCoder.defaultAbiCoder(); + export function utcStringFromUnixTimestamp(timestamp: number) { const isoDate = new Date(+`${timestamp}000`).toISOString(); return format(new Date(isoDate.slice(0, -1)), "yyyy-MM-dd HH:mm 'UTC'"); @@ -116,6 +120,56 @@ export function decodeLogWithABI(log: TransactionLogEntry, abi: AbiFragment[]): } } +export function decodeInputData(input: ParamType, args: Result): InputData[] { + if (input.isArray()) { + return decodeArrayInputData(input, args); + } + + if (input.isTuple()) { + return decodeTupleInputData(input, args); + } + + return [ + { + name: input.name, + type: input.type as InputType, + value: args.toString(), + encodedValue: DefaultAbiCoder.encode([input.type], [args]).split("0x")[1], + inputs: [], + }, + ]; +} + +function decodeArrayInputData(input: ParamType, args: Result): InputData[] { + const inputs = args.flatMap((arg) => decodeInputData(input.arrayChildren!, arg)); + + return [ + { + name: input.name, + type: `${inputs[0]?.type ? `${inputs[0]?.type}[]` : input.type}`, + value: `[${inputs.map((input) => input.value).join(",")}]`, + inputs: inputs, + encodedValue: `[${inputs.map((input) => input.encodedValue).join(",")}]`, + }, + ]; +} + +function decodeTupleInputData(input: ParamType, args: Result): InputData[] { + const inputs = input.components!.flatMap((component: ParamType, index: number) => + decodeInputData(component, args[index]) + ); + + return [ + { + name: input.name, + type: `tuple(${inputs.map((input) => input.type).join(",")})`, + value: `(${inputs.map((input) => input.value).join(",")})`, + inputs, + encodedValue: `(${inputs.map((input) => input.encodedValue).join(",")})`, + }, + ]; +} + export function sortTokenTransfers(transfers: TokenTransfer[]): TokenTransfer[] { return [...transfers].sort((_, t) => { if (t.to === BOOTLOADER_FORMAL_ADDRESS || t.from === BOOTLOADER_FORMAL_ADDRESS) { diff --git a/packages/app/tests/composables/useTransactionData.spec.ts b/packages/app/tests/composables/useTransactionData.spec.ts index 0d6b921bb1..cfc625a85a 100644 --- a/packages/app/tests/composables/useTransactionData.spec.ts +++ b/packages/app/tests/composables/useTransactionData.spec.ts @@ -53,12 +53,14 @@ const transactionDataDecodedMethod = { inputs: [ { name: "recipient", + inputs: [], type: "address", value: "0xa1cf087DB965Ab02Fb3CFaCe1f5c63935815f044", encodedValue: "000000000000000000000000a1cf087db965ab02fb3cface1f5c63935815f044", }, { name: "amount", + inputs: [], type: "uint256", value: "1", encodedValue: "0000000000000000000000000000000000000000000000000000000000000001", @@ -175,12 +177,14 @@ describe("useTransactionData:", () => { inputs: [ { encodedValue: "000000000000000000000000a1cf087db965ab02fb3cface1f5c63935815f044", + inputs: [], name: "recipient", type: "address", value: "0xa1cf087DB965Ab02Fb3CFaCe1f5c63935815f044", }, { encodedValue: "0000000000000000000000000000000000000000000000000000000000000001", + inputs: [], name: "amount", type: "uint256", value: "1", diff --git a/packages/app/tests/utils/helpers.spec.ts b/packages/app/tests/utils/helpers.spec.ts index 6ad49b6614..63d5919ef3 100644 --- a/packages/app/tests/utils/helpers.spec.ts +++ b/packages/app/tests/utils/helpers.spec.ts @@ -1,17 +1,20 @@ import { describe, expect, it } from "vitest"; import { format } from "date-fns"; +import { ParamType } from "ethers"; import { utils } from "zksync-ethers"; import ExecuteTx from "@/../mock/transactions/Execute.json"; import type { InputType } from "@/composables/useEventLog"; import type { TokenTransfer } from "@/composables/useTransaction"; +import type { Result } from "ethers"; import { arrayHalfDivider, camelCaseFromSnakeCase, contractInputTypeToHumanType, + decodeInputData, getRawFunctionType, getRequiredArrayLength, getTypeFromEvent, @@ -178,4 +181,164 @@ describe("helpers:", () => { expect(truncateNumber("0.02", 5)).toEqual("0.02"); }); }); + describe("decodeInputData:", () => { + it("decodes a simple input type", () => { + const input = ParamType.from({ + name: "value", + type: "uint256", + baseType: "scalar", + isArray: () => true, + isTuple: () => false, + }); + const args = 42; + + const result = decodeInputData(input, args as unknown as Result); + + expect(result).toEqual([ + { + name: "value", + type: "uint256", + value: "42", + encodedValue: expect.any(String), + inputs: [], + }, + ]); + }); + + it("decodes an array input type", () => { + const input = ParamType.from({ + name: "values", + baseType: "array", + type: "uint256[]", + arrayChildren: { name: "value", type: "uint256" }, + }); + const args = [42, 43]; + + const result = decodeInputData(input, args as unknown as Result); + + expect(result).toEqual([ + { + name: "values", + type: "uint256[]", + value: "[42,43]", + inputs: [ + { + name: "", + type: "uint256", + value: "42", + encodedValue: "000000000000000000000000000000000000000000000000000000000000002a", + inputs: [], + }, + { + name: "", + type: "uint256", + value: "43", + encodedValue: "000000000000000000000000000000000000000000000000000000000000002b", + inputs: [], + }, + ], + encodedValue: + "[000000000000000000000000000000000000000000000000000000000000002a,000000000000000000000000000000000000000000000000000000000000002b]", + }, + ]); + }); + + it("decodes a tuple input type", () => { + const input = ParamType.from({ + name: "tupleValue", + type: "tuple", + baseType: "tuple", + components: [ + { name: "value1", type: "uint256", baseType: "scalar" }, + { name: "value2", type: "string", baseType: "scalar" }, + ], + }); + const args = ["42", "test"]; + + const result = decodeInputData(input, args as unknown as Result); + expect(result).toEqual([ + { + name: "tupleValue", + type: "tuple(uint256,string)", + value: "(42,test)", + inputs: [ + { + name: "value1", + type: "uint256", + value: "42", + encodedValue: "000000000000000000000000000000000000000000000000000000000000002a", + inputs: [], + }, + { + name: "value2", + type: "string", + value: "test", + encodedValue: + "000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000047465737400000000000000000000000000000000000000000000000000000000", + inputs: [], + }, + ], + encodedValue: + "(000000000000000000000000000000000000000000000000000000000000002a,000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000047465737400000000000000000000000000000000000000000000000000000000)", + }, + ]); + }); + + it("decodes a tuple with an array", () => { + const input = ParamType.from({ + name: "tupleValue", + type: "tuple", + baseType: "tuple", + components: [ + { name: "value1", type: "uint256", baseType: "scalar" }, + { name: "value2", type: "uint256[]", baseType: "array", arrayChildren: { name: "value", type: "uint256" } }, + ], + }); + const args = [42, [43, 44]]; + + const result = decodeInputData(input, args as unknown as Result); + + expect(result).toEqual([ + { + name: "tupleValue", + type: "tuple(uint256,uint256[])", + value: "(42,[43,44])", + inputs: [ + { + name: "value1", + type: "uint256", + value: "42", + encodedValue: "000000000000000000000000000000000000000000000000000000000000002a", + inputs: [], + }, + { + name: "value2", + type: "uint256[]", + value: "[43,44]", + inputs: [ + { + name: "", + type: "uint256", + value: "43", + encodedValue: "000000000000000000000000000000000000000000000000000000000000002b", + inputs: [], + }, + { + name: "", + type: "uint256", + value: "44", + encodedValue: "000000000000000000000000000000000000000000000000000000000000002c", + inputs: [], + }, + ], + encodedValue: + "[000000000000000000000000000000000000000000000000000000000000002b,000000000000000000000000000000000000000000000000000000000000002c]", + }, + ], + encodedValue: + "(000000000000000000000000000000000000000000000000000000000000002a,[000000000000000000000000000000000000000000000000000000000000002b,000000000000000000000000000000000000000000000000000000000000002c])", + }, + ]); + }); + }); }); diff --git a/packages/data-fetcher/.env.example b/packages/data-fetcher/.env.example index cddefc6c7b..7371b2d632 100644 --- a/packages/data-fetcher/.env.example +++ b/packages/data-fetcher/.env.example @@ -16,4 +16,6 @@ RPC_BATCH_MAX_COUNT=10 RPC_BATCH_MAX_SIZE_BYTES=1048576 RPC_BATCH_STALL_TIME_MS=0 -MAX_BLOCKS_BATCH_SIZE=20 \ No newline at end of file +MAX_BLOCKS_BATCH_SIZE=20 + +RPC_HEALTH_CHECK_TIMEOUT_MS=20_000 \ No newline at end of file diff --git a/packages/data-fetcher/package.json b/packages/data-fetcher/package.json index 6c0ce6cb5a..8a0afc8981 100644 --- a/packages/data-fetcher/package.json +++ b/packages/data-fetcher/package.json @@ -24,12 +24,14 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@nestjs/axios": "^3.1.3", "@nestjs/common": "^9.0.0", "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.0.0", "@nestjs/platform-express": "^9.0.0", "@nestjs/terminus": "^9.1.2", "@willsoto/nestjs-prometheus": "^4.7.0", + "axios": "^1.7.9", "ethers": "6.13.4", "nest-winston": "^1.7.0", "prom-client": "^14.1.0", diff --git a/packages/data-fetcher/src/config.spec.ts b/packages/data-fetcher/src/config.spec.ts index aae8bc6c74..23b33571ca 100644 --- a/packages/data-fetcher/src/config.spec.ts +++ b/packages/data-fetcher/src/config.spec.ts @@ -22,6 +22,9 @@ describe("config", () => { }, maxBlocksBatchSize: 20, gracefulShutdownTimeoutMs: 0, + healthChecks: { + rpcHealthCheckTimeoutMs: 20_000, + }, }; }); diff --git a/packages/data-fetcher/src/config.ts b/packages/data-fetcher/src/config.ts index c5d1ef86ce..62dd4962f5 100644 --- a/packages/data-fetcher/src/config.ts +++ b/packages/data-fetcher/src/config.ts @@ -15,6 +15,7 @@ export default () => { RPC_BATCH_STALL_TIME_MS, MAX_BLOCKS_BATCH_SIZE, GRACEFUL_SHUTDOWN_TIMEOUT_MS, + RPC_HEALTH_CHECK_TIMEOUT_MS, } = process.env; return { @@ -42,5 +43,8 @@ export default () => { }, maxBlocksBatchSize: parseInt(MAX_BLOCKS_BATCH_SIZE, 10) || 20, gracefulShutdownTimeoutMs: parseInt(GRACEFUL_SHUTDOWN_TIMEOUT_MS, 10) || 0, + healthChecks: { + rpcHealthCheckTimeoutMs: parseInt(RPC_HEALTH_CHECK_TIMEOUT_MS, 10) || 20_000, + }, }; }; diff --git a/packages/data-fetcher/src/health/health.module.ts b/packages/data-fetcher/src/health/health.module.ts index dae128825d..1c4b98ad8c 100644 --- a/packages/data-fetcher/src/health/health.module.ts +++ b/packages/data-fetcher/src/health/health.module.ts @@ -1,11 +1,12 @@ import { Module } from "@nestjs/common"; import { TerminusModule } from "@nestjs/terminus"; +import { HttpModule } from "@nestjs/axios"; import { HealthController } from "./health.controller"; import { JsonRpcHealthIndicator } from "./jsonRpcProvider.health"; @Module({ controllers: [HealthController], - imports: [TerminusModule], + imports: [TerminusModule, HttpModule], providers: [JsonRpcHealthIndicator], }) export class HealthModule {} diff --git a/packages/data-fetcher/src/health/jsonRpcProvider.health.spec.ts b/packages/data-fetcher/src/health/jsonRpcProvider.health.spec.ts index 2cfaa28919..e495025bbb 100644 --- a/packages/data-fetcher/src/health/jsonRpcProvider.health.spec.ts +++ b/packages/data-fetcher/src/health/jsonRpcProvider.health.spec.ts @@ -1,17 +1,20 @@ import { Test, TestingModule } from "@nestjs/testing"; +import { Logger } from "@nestjs/common"; import { mock } from "jest-mock-extended"; -import { HealthCheckError } from "@nestjs/terminus"; import { JsonRpcProviderBase } from "../rpcProvider"; import { JsonRpcHealthIndicator } from "./jsonRpcProvider.health"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; +import { of, throwError } from "rxjs"; +import { AxiosError } from "axios"; describe("JsonRpcHealthIndicator", () => { - const healthIndicatorKey = "rpcProvider"; let jsonRpcProviderMock: JsonRpcProviderBase; let jsonRpcHealthIndicator: JsonRpcHealthIndicator; + let httpService: HttpService; + let configService: ConfigService; - beforeEach(async () => { - jsonRpcProviderMock = mock(); - + const getHealthIndicator = async () => { const app: TestingModule = await Test.createTestingModule({ providers: [ JsonRpcHealthIndicator, @@ -19,38 +22,90 @@ describe("JsonRpcHealthIndicator", () => { provide: JsonRpcProviderBase, useValue: jsonRpcProviderMock, }, + { + provide: HttpService, + useValue: httpService, + }, + { + provide: ConfigService, + useValue: configService, + }, ], }).compile(); - jsonRpcHealthIndicator = app.get(JsonRpcHealthIndicator); + app.useLogger(mock()); + return app.get(JsonRpcHealthIndicator); + }; + + beforeEach(async () => { + jsonRpcProviderMock = mock(); + + httpService = mock({ + post: jest.fn(), + }); + + configService = mock({ + get: jest.fn().mockImplementation((key: string) => { + if (key === "blockchain.rpcUrl") return "http://localhost:3050"; + if (key === "healthChecks.rpcHealthCheckTimeoutMs") return 5000; + return null; + }), + }); + + jsonRpcHealthIndicator = await getHealthIndicator(); }); describe("isHealthy", () => { - describe("when rpcProvider is open", () => { - beforeEach(() => { - jest.spyOn(jsonRpcProviderMock, "getState").mockReturnValueOnce("open"); - }); + const rpcRequest = { + id: 1, + jsonrpc: "2.0", + method: "eth_chainId", + params: [], + }; - it("returns OK health indicator result", async () => { - const result = await jsonRpcHealthIndicator.isHealthy(healthIndicatorKey); - expect(result).toEqual({ [healthIndicatorKey]: { rpcProviderState: "open", status: "up" } }); + it("returns healthy status when RPC responds successfully", async () => { + (httpService.post as jest.Mock).mockReturnValueOnce(of({ data: { result: "0x1" } })); + const result = await jsonRpcHealthIndicator.isHealthy("jsonRpcProvider"); + expect(result).toEqual({ + jsonRpcProvider: { + status: "up", + }, }); + expect(httpService.post).toHaveBeenCalledWith("http://localhost:3050", rpcRequest, { timeout: 5000 }); }); - describe("when rpcProvider is closed", () => { - beforeEach(() => { - jest.spyOn(jsonRpcProviderMock, "getState").mockReturnValueOnce("closed"); - }); + it("throws HealthCheckError when RPC request fails", async () => { + const error = new AxiosError(); + error.response = { + status: 503, + data: "Service Unavailable", + } as any; - it("throws HealthCheckError error", async () => { - expect.assertions(2); - try { - await jsonRpcHealthIndicator.isHealthy(healthIndicatorKey); - } catch (error) { - expect(error).toBeInstanceOf(HealthCheckError); - expect(error.message).toBe("JSON RPC provider is not in open state"); - } + (httpService.post as jest.Mock).mockReturnValueOnce(throwError(() => error)); + await expect(jsonRpcHealthIndicator.isHealthy("jsonRpcProvider")).rejects.toThrow(); + expect(httpService.post).toHaveBeenCalledWith("http://localhost:3050", rpcRequest, { timeout: 5000 }); + }); + + it("throws HealthCheckError when RPC request times out", async () => { + const error = new AxiosError(); + error.code = "ECONNABORTED"; + + (httpService.post as jest.Mock).mockReturnValueOnce(throwError(() => error)); + await expect(jsonRpcHealthIndicator.isHealthy("jsonRpcProvider")).rejects.toThrow(); + expect(httpService.post).toHaveBeenCalledWith("http://localhost:3050", rpcRequest, { timeout: 5000 }); + }); + + it("uses configured timeout from config service", async () => { + (configService.get as jest.Mock).mockImplementation((key: string) => { + if (key === "blockchain.rpcUrl") return "http://localhost:3050"; + if (key === "healthChecks.rpcHealthCheckTimeoutMs") return 10000; + return null; }); + jsonRpcHealthIndicator = await getHealthIndicator(); + + (httpService.post as jest.Mock).mockReturnValueOnce(of({ data: { result: "0x1" } })); + await jsonRpcHealthIndicator.isHealthy("jsonRpcProvider"); + expect(httpService.post).toHaveBeenCalledWith("http://localhost:3050", rpcRequest, { timeout: 10000 }); }); }); }); diff --git a/packages/data-fetcher/src/health/jsonRpcProvider.health.ts b/packages/data-fetcher/src/health/jsonRpcProvider.health.ts index 60406bfb4c..e5434382b2 100644 --- a/packages/data-fetcher/src/health/jsonRpcProvider.health.ts +++ b/packages/data-fetcher/src/health/jsonRpcProvider.health.ts @@ -1,22 +1,64 @@ import { Injectable } from "@nestjs/common"; import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from "@nestjs/terminus"; -import { JsonRpcProviderBase } from "../rpcProvider"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { catchError, firstValueFrom } from "rxjs"; +import { AxiosError } from "axios"; @Injectable() export class JsonRpcHealthIndicator extends HealthIndicator { - constructor(private readonly provider: JsonRpcProviderBase) { + private readonly rpcUrl: string; + private readonly healthCheckTimeoutMs: number; + private readonly logger: Logger; + + constructor(configService: ConfigService, private readonly httpService: HttpService) { super(); + this.logger = new Logger(JsonRpcHealthIndicator.name); + this.rpcUrl = configService.get("blockchain.rpcUrl"); + this.healthCheckTimeoutMs = configService.get("healthChecks.rpcHealthCheckTimeoutMs"); } async isHealthy(key: string): Promise { - const rpcProviderState = this.provider.getState(); - const isHealthy = rpcProviderState === "open"; - const result = this.getStatus(key, isHealthy, { rpcProviderState }); + let isHealthy = true; + try { + // Check RPC health with a pure HTTP request to remove SDK out of the picture + // and avoid any SDK-specific issues. + // Use eth_chainId call as it is the lightest one and return a static value from the memory. + await firstValueFrom( + this.httpService + .post( + this.rpcUrl, + { + id: 1, + jsonrpc: "2.0", + method: "eth_chainId", + params: [], + }, + { timeout: this.healthCheckTimeoutMs } + ) + .pipe( + catchError((error: AxiosError) => { + this.logger.error({ + message: `Failed to ping RPC`, + stack: error.stack, + status: error.response?.status, + response: error.response?.data, + }); + throw error; + }) + ) + ); + } catch { + isHealthy = false; + } + + const result = this.getStatus(key, isHealthy, { status: isHealthy ? "up" : "down" }); if (isHealthy) { return result; } - throw new HealthCheckError("JSON RPC provider is not in open state", result); + throw new HealthCheckError("JSON RPC provider is down or not reachable", result); } } diff --git a/packages/worker/.env.example b/packages/worker/.env.example index d9773b9d8b..9a551c5b21 100644 --- a/packages/worker/.env.example +++ b/packages/worker/.env.example @@ -30,6 +30,9 @@ RPC_BATCH_STALL_TIME_MS=0 COLLECT_DB_CONNECTION_POOL_METRICS_INTERVAL=10000 COLLECT_BLOCKS_TO_PROCESS_METRIC_INTERVAL=10000 +RPC_HEALTH_CHECK_TIMEOUT_MS=20000 +DB_HEALTH_CHECK_TIMEOUT_MS=20000 + DISABLE_MISSING_BLOCKS_METRIC=false CHECK_MISSING_BLOCKS_METRIC_INTERVAL=86400000 diff --git a/packages/worker/src/app.module.ts b/packages/worker/src/app.module.ts index bec09a377a..da46beea4e 100644 --- a/packages/worker/src/app.module.ts +++ b/packages/worker/src/app.module.ts @@ -52,6 +52,7 @@ import { MetricsModule } from "./metrics"; import { DbMetricsService } from "./dbMetrics.service"; import { UnitOfWorkModule } from "./unitOfWork"; import { DataFetcherService } from "./dataFetcher/dataFetcher.service"; +import { SystemContractService } from "./contract/systemContract.service"; @Module({ imports: [ @@ -130,6 +131,7 @@ import { DataFetcherService } from "./dataFetcher/dataFetcher.service"; Logger, RetryDelayProvider, DbMetricsService, + SystemContractService, ], }) export class AppModule {} diff --git a/packages/worker/src/app.service.spec.ts b/packages/worker/src/app.service.spec.ts index 3d4afb8363..60d5a8b295 100644 --- a/packages/worker/src/app.service.spec.ts +++ b/packages/worker/src/app.service.spec.ts @@ -13,6 +13,7 @@ import { BlocksRevertService } from "./blocksRevert"; import { TokenOffChainDataSaverService } from "./token/tokenOffChainData/tokenOffChainDataSaver.service"; import runMigrations from "./utils/runMigrations"; import { BLOCKS_REVERT_DETECTED_EVENT } from "./constants"; +import { SystemContractService } from "./contract/systemContract.service"; jest.mock("./utils/runMigrations"); @@ -39,6 +40,7 @@ describe("AppService", () => { let tokenOffChainDataSaverService: TokenOffChainDataSaverService; let dataSourceMock: DataSource; let configServiceMock: ConfigService; + let systemContractService: SystemContractService; beforeEach(async () => { balancesCleanerService = mock({ @@ -68,6 +70,9 @@ describe("AppService", () => { configServiceMock = mock({ get: jest.fn().mockReturnValue(false), }); + systemContractService = mock({ + addSystemContracts: jest.fn().mockResolvedValue(null), + }); const module = await Test.createTestingModule({ imports: [EventEmitterModule.forRoot()], @@ -106,6 +111,10 @@ describe("AppService", () => { provide: ConfigService, useValue: configServiceMock, }, + { + provide: SystemContractService, + useValue: systemContractService, + }, ], }).compile(); @@ -205,6 +214,13 @@ describe("AppService", () => { appService.onModuleDestroy(); expect(tokenOffChainDataSaverService.stop).toBeCalledTimes(1); }); + + it("adds system contracts", async () => { + appService.onModuleInit(); + await migrationsRunFinished; + expect(systemContractService.addSystemContracts).toBeCalledTimes(1); + appService.onModuleDestroy(); + }); }); describe("onModuleDestroy", () => { diff --git a/packages/worker/src/app.service.ts b/packages/worker/src/app.service.ts index d6ec852c73..f0fbaa8301 100644 --- a/packages/worker/src/app.service.ts +++ b/packages/worker/src/app.service.ts @@ -10,10 +10,12 @@ import { CounterService } from "./counter"; import { BalancesCleanerService } from "./balance"; import { TokenOffChainDataSaverService } from "./token/tokenOffChainData/tokenOffChainDataSaver.service"; import runMigrations from "./utils/runMigrations"; +import { SystemContractService } from "./contract/systemContract.service"; @Injectable() export class AppService implements OnModuleInit, OnModuleDestroy { private readonly logger: Logger; + private isHandlingBlocksRevert = false; public constructor( private readonly counterService: CounterService, @@ -23,13 +25,15 @@ export class AppService implements OnModuleInit, OnModuleDestroy { private readonly balancesCleanerService: BalancesCleanerService, private readonly tokenOffChainDataSaverService: TokenOffChainDataSaverService, private readonly dataSource: DataSource, - private readonly configService: ConfigService + private readonly configService: ConfigService, + private readonly systemContractService: SystemContractService ) { this.logger = new Logger(AppService.name); } public onModuleInit() { runMigrations(this.dataSource, this.logger).then(() => { + this.systemContractService.addSystemContracts(); this.startWorkers(); }); } @@ -40,6 +44,11 @@ export class AppService implements OnModuleInit, OnModuleDestroy { @OnEvent(BLOCKS_REVERT_DETECTED_EVENT) protected async handleBlocksRevert({ detectedIncorrectBlockNumber }: { detectedIncorrectBlockNumber: number }) { + if (this.isHandlingBlocksRevert) { + return; + } + this.isHandlingBlocksRevert = true; + this.logger.log("Stopping workers before blocks revert"); await this.stopWorkers(); @@ -47,7 +56,9 @@ export class AppService implements OnModuleInit, OnModuleDestroy { await this.blocksRevertService.handleRevert(detectedIncorrectBlockNumber); this.logger.log("Starting workers after blocks revert"); - await this.startWorkers(); + this.startWorkers(); + + this.isHandlingBlocksRevert = false; } private startWorkers() { diff --git a/packages/worker/src/block/block.processor.ts b/packages/worker/src/block/block.processor.ts index 3fa438135b..7418ebe8f4 100644 --- a/packages/worker/src/block/block.processor.ts +++ b/packages/worker/src/block/block.processor.ts @@ -78,12 +78,12 @@ export class BlockProcessor { return false; } - if (lastDbBlock && lastDbBlock.hash !== blocksToProcess[0].block?.parentHash) { + if (lastDbBlock && lastDbBlock.hash !== blocksToProcess[0]?.block?.parentHash) { this.triggerBlocksRevertEvent(lastDbBlockNumber); return false; } - const allBlocksExist = !blocksToProcess.find((blockInfo) => !blockInfo.block || !blockInfo.blockDetails); + const allBlocksExist = !blocksToProcess.find((blockInfo) => !blockInfo?.block || !blockInfo?.blockDetails); if (!allBlocksExist) { // We don't need to handle this potential revert as these blocks are not in DB yet, // try again later once these blocks are present in blockchain again. diff --git a/packages/worker/src/block/block.watcher.ts b/packages/worker/src/block/block.watcher.ts index f491c6554a..0ae3f5978a 100644 --- a/packages/worker/src/block/block.watcher.ts +++ b/packages/worker/src/block/block.watcher.ts @@ -119,7 +119,7 @@ export class BlockWatcher implements OnModuleInit, OnModuleDestroy { this.logger.debug(`Last block number is set to: ${this.lastBlockchainBlockNumber}`); this.blockchainService.on("block", (blockNumber) => { - this.lastBlockchainBlockNumber = Math.max(this.lastBlockchainBlockNumber, blockNumber); + this.lastBlockchainBlockNumber = blockNumber || this.lastBlockchainBlockNumber; this.blockchainBlocksMetric.set(this.lastBlockchainBlockNumber); this.logger.debug(`Last block number is updated to: ${this.lastBlockchainBlockNumber}`); }); diff --git a/packages/worker/src/blocksRevert/blocksRevert.service.ts b/packages/worker/src/blocksRevert/blocksRevert.service.ts index 497cd4fb5d..8dd75a1323 100644 --- a/packages/worker/src/blocksRevert/blocksRevert.service.ts +++ b/packages/worker/src/blocksRevert/blocksRevert.service.ts @@ -81,7 +81,7 @@ export class BlocksRevertService { lastExecutedBlockNumber: number, detectedIncorrectBlockNumber: number ) => { - // binary search the last block with matching hash between latest executed block from DB and incorrect clock detected + // binary search the last block with matching hash between latest executed block from DB and incorrect block detected let start = lastExecutedBlockNumber; let end = detectedIncorrectBlockNumber; diff --git a/packages/worker/src/config.spec.ts b/packages/worker/src/config.spec.ts index e6d561388e..7c678217c7 100644 --- a/packages/worker/src/config.spec.ts +++ b/packages/worker/src/config.spec.ts @@ -61,6 +61,10 @@ describe("config", () => { interval: 86_400_000, }, }, + healthChecks: { + rpcHealthCheckTimeoutMs: 20_000, + dbHealthCheckTimeoutMs: 20_000, + }, }; }); @@ -123,6 +127,10 @@ describe("config", () => { interval: 86_400_000, }, }, + healthChecks: { + rpcHealthCheckTimeoutMs: 20_000, + dbHealthCheckTimeoutMs: 20_000, + }, }); }); diff --git a/packages/worker/src/config.ts b/packages/worker/src/config.ts index ba43f72596..b6e83aefd9 100644 --- a/packages/worker/src/config.ts +++ b/packages/worker/src/config.ts @@ -33,6 +33,8 @@ export default () => { COINGECKO_API_KEY, DISABLE_MISSING_BLOCKS_METRIC, CHECK_MISSING_BLOCKS_METRIC_INTERVAL, + RPC_HEALTH_CHECK_TIMEOUT_MS, + DB_HEALTH_CHECK_TIMEOUT_MS, } = process.env; return { @@ -97,5 +99,9 @@ export default () => { interval: parseInt(CHECK_MISSING_BLOCKS_METRIC_INTERVAL, 10) || 86_400_000, // 1 day }, }, + healthChecks: { + rpcHealthCheckTimeoutMs: parseInt(RPC_HEALTH_CHECK_TIMEOUT_MS, 10) || 20_000, + dbHealthCheckTimeoutMs: parseInt(DB_HEALTH_CHECK_TIMEOUT_MS, 10) || 20_000, + }, }; }; diff --git a/packages/worker/src/contract/systemContract.service.spec.ts b/packages/worker/src/contract/systemContract.service.spec.ts new file mode 100644 index 0000000000..6359fddd72 --- /dev/null +++ b/packages/worker/src/contract/systemContract.service.spec.ts @@ -0,0 +1,107 @@ +import { mock } from "jest-mock-extended"; +import { Test, TestingModule } from "@nestjs/testing"; +import { Logger } from "@nestjs/common"; +import { BlockchainService } from "../blockchain/blockchain.service"; +import { AddressRepository } from "../repositories/address.repository"; +import { SystemContractService } from "./systemContract.service"; +import { Address } from "../entities"; + +describe("SystemContractService", () => { + let systemContractService: SystemContractService; + let blockchainServiceMock: BlockchainService; + let addressRepositoryMock: AddressRepository; + const systemContracts = SystemContractService.getSystemContracts(); + + beforeEach(async () => { + blockchainServiceMock = mock({ + getCode: jest.fn().mockImplementation((address: string) => Promise.resolve(`${address}-code`)), + }); + + addressRepositoryMock = mock({ + find: jest.fn(), + }); + + const app: TestingModule = await Test.createTestingModule({ + providers: [ + SystemContractService, + { + provide: BlockchainService, + useValue: blockchainServiceMock, + }, + { + provide: AddressRepository, + useValue: addressRepositoryMock, + }, + ], + }).compile(); + + app.useLogger(mock()); + systemContractService = app.get(SystemContractService); + }); + + describe("addSystemContracts", () => { + it("doesn't add any system contracts if they already exist in DB", async () => { + (addressRepositoryMock.find as jest.Mock).mockResolvedValue( + SystemContractService.getSystemContracts().map((contract) => mock
({ address: contract.address })) + ); + await systemContractService.addSystemContracts(); + expect(addressRepositoryMock.upsert).toBeCalledTimes(0); + }); + + it("adds all system contracts if none of them exist in the DB", async () => { + (addressRepositoryMock.find as jest.Mock).mockResolvedValue([]); + await systemContractService.addSystemContracts(); + expect(addressRepositoryMock.upsert).toBeCalledTimes(systemContracts.length); + for (const systemContract of systemContracts) { + expect(addressRepositoryMock.upsert).toBeCalledWith({ + address: systemContract.address, + bytecode: `${systemContract.address}-code`, + }); + } + }); + + it("adds only missing system contracts", async () => { + const existingContractAddresses = [ + "0x000000000000000000000000000000000000800d", + "0x0000000000000000000000000000000000008006", + ]; + (addressRepositoryMock.find as jest.Mock).mockResolvedValue( + existingContractAddresses.map((existingContractAddress) => mock
({ address: existingContractAddress })) + ); + await systemContractService.addSystemContracts(); + expect(addressRepositoryMock.upsert).toBeCalledTimes(systemContracts.length - existingContractAddresses.length); + for (const systemContract of systemContracts) { + if (!existingContractAddresses.includes(systemContract.address)) { + expect(addressRepositoryMock.upsert).toBeCalledWith({ + address: systemContract.address, + bytecode: `${systemContract.address}-code`, + }); + } + } + }); + + it("adds contracts only if they are deployed to the network", async () => { + const notDeployedSystemContracts = [ + "0x000000000000000000000000000000000000800d", + "0x0000000000000000000000000000000000008006", + ]; + (addressRepositoryMock.find as jest.Mock).mockResolvedValue([]); + (blockchainServiceMock.getCode as jest.Mock).mockImplementation(async (address: string) => { + if (notDeployedSystemContracts.includes(address)) { + return "0x"; + } + return `${address}-code`; + }); + await systemContractService.addSystemContracts(); + expect(addressRepositoryMock.upsert).toBeCalledTimes(systemContracts.length - notDeployedSystemContracts.length); + for (const systemContract of systemContracts) { + if (!notDeployedSystemContracts.includes(systemContract.address)) { + expect(addressRepositoryMock.upsert).toBeCalledWith({ + address: systemContract.address, + bytecode: `${systemContract.address}-code`, + }); + } + } + }); + }); +}); diff --git a/packages/worker/src/contract/systemContract.service.ts b/packages/worker/src/contract/systemContract.service.ts new file mode 100644 index 0000000000..d6b10b1c85 --- /dev/null +++ b/packages/worker/src/contract/systemContract.service.ts @@ -0,0 +1,143 @@ +import { Injectable } from "@nestjs/common"; +import { BlockchainService } from "../blockchain/blockchain.service"; +import { AddressRepository } from "../repositories"; +import { In } from "typeorm"; + +@Injectable() +export class SystemContractService { + constructor( + private readonly addressRepository: AddressRepository, + private readonly blockchainService: BlockchainService + ) {} + + public async addSystemContracts(): Promise { + const systemContracts = SystemContractService.getSystemContracts(); + const existingContracts = await this.addressRepository.find({ + where: { + address: In(systemContracts.map((contract) => contract.address)), + }, + select: { + address: true, + }, + }); + + for (const contract of systemContracts) { + if (!existingContracts.find((existingContract) => existingContract.address === contract.address)) { + const bytecode = await this.blockchainService.getCode(contract.address); + // some contract might not exist on the environment yet + if (bytecode !== "0x") { + await this.addressRepository.upsert({ + address: contract.address, + bytecode, + }); + } + } + } + } + + public static getSystemContracts() { + // name field is never used, it's just for better readability & understanding + return [ + { + address: "0x0000000000000000000000000000000000000000", + name: "EmptyContract", + }, + { + address: "0x0000000000000000000000000000000000000001", + name: "Ecrecover", + }, + { + address: "0x0000000000000000000000000000000000000002", + name: "SHA256", + }, + { + address: "0x0000000000000000000000000000000000000006", + name: "EcAdd", + }, + { + address: "0x0000000000000000000000000000000000000007", + name: "EcMul", + }, + { + address: "0x0000000000000000000000000000000000000008", + name: "EcPairing", + }, + { + address: "0x0000000000000000000000000000000000008001", + name: "EmptyContract", + }, + { + address: "0x0000000000000000000000000000000000008002", + name: "AccountCodeStorage", + }, + { + address: "0x0000000000000000000000000000000000008003", + name: "NonceHolder", + }, + { + address: "0x0000000000000000000000000000000000008004", + name: "KnownCodesStorage", + }, + { + address: "0x0000000000000000000000000000000000008005", + name: "ImmutableSimulator", + }, + { + address: "0x0000000000000000000000000000000000008006", + name: "ContractDeployer", + }, + { + address: "0x0000000000000000000000000000000000008008", + name: "L1Messenger", + }, + { + address: "0x0000000000000000000000000000000000008009", + name: "MsgValueSimulator", + }, + { + address: "0x000000000000000000000000000000000000800a", + name: "L2BaseToken", + }, + { + address: "0x000000000000000000000000000000000000800b", + name: "SystemContext", + }, + { + address: "0x000000000000000000000000000000000000800c", + name: "BootloaderUtilities", + }, + { + address: "0x000000000000000000000000000000000000800d", + name: "EventWriter", + }, + { + address: "0x000000000000000000000000000000000000800e", + name: "Compressor", + }, + { + address: "0x000000000000000000000000000000000000800f", + name: "ComplexUpgrader", + }, + { + address: "0x0000000000000000000000000000000000008010", + name: "Keccak256", + }, + { + address: "0x0000000000000000000000000000000000008012", + name: "CodeOracle", + }, + { + address: "0x0000000000000000000000000000000000000100", + name: "P256Verify", + }, + { + address: "0x0000000000000000000000000000000000008011", + name: "PubdataChunkPublisher", + }, + { + address: "0x0000000000000000000000000000000000010000", + name: "Create2Factory", + }, + ]; + } +} diff --git a/packages/worker/src/health/health.controller.spec.ts b/packages/worker/src/health/health.controller.spec.ts index 6dc59e2200..a7b47551ea 100644 --- a/packages/worker/src/health/health.controller.spec.ts +++ b/packages/worker/src/health/health.controller.spec.ts @@ -4,12 +4,14 @@ import { HealthCheckService, TypeOrmHealthIndicator, HealthCheckResult } from "@ import { mock } from "jest-mock-extended"; import { JsonRpcHealthIndicator } from "./jsonRpcProvider.health"; import { HealthController } from "./health.controller"; +import { ConfigService } from "@nestjs/config"; describe("HealthController", () => { let healthCheckServiceMock: HealthCheckService; let dbHealthCheckerMock: TypeOrmHealthIndicator; let jsonRpcHealthIndicatorMock: JsonRpcHealthIndicator; let healthController: HealthController; + let configServiceMock: ConfigService; beforeEach(async () => { healthCheckServiceMock = mock({ @@ -20,6 +22,12 @@ describe("HealthController", () => { }), }); + configServiceMock = mock({ + get: jest.fn().mockImplementation((key: string) => { + if (key === "healthChecks.dbHealthCheckTimeoutMs") return 5000; + return null; + }), + }); dbHealthCheckerMock = mock(); jsonRpcHealthIndicatorMock = mock(); @@ -38,6 +46,10 @@ describe("HealthController", () => { provide: JsonRpcHealthIndicator, useValue: jsonRpcHealthIndicatorMock, }, + { + provide: ConfigService, + useValue: configServiceMock, + }, ], }).compile(); @@ -50,7 +62,7 @@ describe("HealthController", () => { it("checks health of the DB", async () => { await healthController.check(); expect(dbHealthCheckerMock.pingCheck).toHaveBeenCalledTimes(1); - expect(dbHealthCheckerMock.pingCheck).toHaveBeenCalledWith("database"); + expect(dbHealthCheckerMock.pingCheck).toHaveBeenCalledWith("database", { timeout: 5000 }); }); it("checks health of the JSON RPC provider", async () => { diff --git a/packages/worker/src/health/health.controller.ts b/packages/worker/src/health/health.controller.ts index ae5678e812..15c34bb98d 100644 --- a/packages/worker/src/health/health.controller.ts +++ b/packages/worker/src/health/health.controller.ts @@ -1,17 +1,21 @@ import { Logger, Controller, Get } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { HealthCheckService, TypeOrmHealthIndicator, HealthCheck, HealthCheckResult } from "@nestjs/terminus"; import { JsonRpcHealthIndicator } from "./jsonRpcProvider.health"; @Controller(["health", "ready"]) export class HealthController { private readonly logger: Logger; + private readonly dbHealthCheckTimeoutMs: number; constructor( private readonly healthCheckService: HealthCheckService, private readonly dbHealthChecker: TypeOrmHealthIndicator, - private readonly jsonRpcHealthIndicator: JsonRpcHealthIndicator + private readonly jsonRpcHealthIndicator: JsonRpcHealthIndicator, + configService: ConfigService ) { this.logger = new Logger(HealthController.name); + this.dbHealthCheckTimeoutMs = configService.get("healthChecks.dbHealthCheckTimeoutMs"); } @Get() @@ -19,7 +23,7 @@ export class HealthController { public async check(): Promise { try { return await this.healthCheckService.check([ - () => this.dbHealthChecker.pingCheck("database"), + () => this.dbHealthChecker.pingCheck("database", { timeout: this.dbHealthCheckTimeoutMs }), () => this.jsonRpcHealthIndicator.isHealthy("jsonRpcProvider"), ]); } catch (error) { diff --git a/packages/worker/src/health/health.module.ts b/packages/worker/src/health/health.module.ts index dae128825d..37f46aac46 100644 --- a/packages/worker/src/health/health.module.ts +++ b/packages/worker/src/health/health.module.ts @@ -2,10 +2,11 @@ import { Module } from "@nestjs/common"; import { TerminusModule } from "@nestjs/terminus"; import { HealthController } from "./health.controller"; import { JsonRpcHealthIndicator } from "./jsonRpcProvider.health"; +import { HttpModule } from "@nestjs/axios"; @Module({ controllers: [HealthController], - imports: [TerminusModule], + imports: [TerminusModule, HttpModule], providers: [JsonRpcHealthIndicator], }) export class HealthModule {} diff --git a/packages/worker/src/health/jsonRpcProvider.health.spec.ts b/packages/worker/src/health/jsonRpcProvider.health.spec.ts index 2cfaa28919..e495025bbb 100644 --- a/packages/worker/src/health/jsonRpcProvider.health.spec.ts +++ b/packages/worker/src/health/jsonRpcProvider.health.spec.ts @@ -1,17 +1,20 @@ import { Test, TestingModule } from "@nestjs/testing"; +import { Logger } from "@nestjs/common"; import { mock } from "jest-mock-extended"; -import { HealthCheckError } from "@nestjs/terminus"; import { JsonRpcProviderBase } from "../rpcProvider"; import { JsonRpcHealthIndicator } from "./jsonRpcProvider.health"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; +import { of, throwError } from "rxjs"; +import { AxiosError } from "axios"; describe("JsonRpcHealthIndicator", () => { - const healthIndicatorKey = "rpcProvider"; let jsonRpcProviderMock: JsonRpcProviderBase; let jsonRpcHealthIndicator: JsonRpcHealthIndicator; + let httpService: HttpService; + let configService: ConfigService; - beforeEach(async () => { - jsonRpcProviderMock = mock(); - + const getHealthIndicator = async () => { const app: TestingModule = await Test.createTestingModule({ providers: [ JsonRpcHealthIndicator, @@ -19,38 +22,90 @@ describe("JsonRpcHealthIndicator", () => { provide: JsonRpcProviderBase, useValue: jsonRpcProviderMock, }, + { + provide: HttpService, + useValue: httpService, + }, + { + provide: ConfigService, + useValue: configService, + }, ], }).compile(); - jsonRpcHealthIndicator = app.get(JsonRpcHealthIndicator); + app.useLogger(mock()); + return app.get(JsonRpcHealthIndicator); + }; + + beforeEach(async () => { + jsonRpcProviderMock = mock(); + + httpService = mock({ + post: jest.fn(), + }); + + configService = mock({ + get: jest.fn().mockImplementation((key: string) => { + if (key === "blockchain.rpcUrl") return "http://localhost:3050"; + if (key === "healthChecks.rpcHealthCheckTimeoutMs") return 5000; + return null; + }), + }); + + jsonRpcHealthIndicator = await getHealthIndicator(); }); describe("isHealthy", () => { - describe("when rpcProvider is open", () => { - beforeEach(() => { - jest.spyOn(jsonRpcProviderMock, "getState").mockReturnValueOnce("open"); - }); + const rpcRequest = { + id: 1, + jsonrpc: "2.0", + method: "eth_chainId", + params: [], + }; - it("returns OK health indicator result", async () => { - const result = await jsonRpcHealthIndicator.isHealthy(healthIndicatorKey); - expect(result).toEqual({ [healthIndicatorKey]: { rpcProviderState: "open", status: "up" } }); + it("returns healthy status when RPC responds successfully", async () => { + (httpService.post as jest.Mock).mockReturnValueOnce(of({ data: { result: "0x1" } })); + const result = await jsonRpcHealthIndicator.isHealthy("jsonRpcProvider"); + expect(result).toEqual({ + jsonRpcProvider: { + status: "up", + }, }); + expect(httpService.post).toHaveBeenCalledWith("http://localhost:3050", rpcRequest, { timeout: 5000 }); }); - describe("when rpcProvider is closed", () => { - beforeEach(() => { - jest.spyOn(jsonRpcProviderMock, "getState").mockReturnValueOnce("closed"); - }); + it("throws HealthCheckError when RPC request fails", async () => { + const error = new AxiosError(); + error.response = { + status: 503, + data: "Service Unavailable", + } as any; - it("throws HealthCheckError error", async () => { - expect.assertions(2); - try { - await jsonRpcHealthIndicator.isHealthy(healthIndicatorKey); - } catch (error) { - expect(error).toBeInstanceOf(HealthCheckError); - expect(error.message).toBe("JSON RPC provider is not in open state"); - } + (httpService.post as jest.Mock).mockReturnValueOnce(throwError(() => error)); + await expect(jsonRpcHealthIndicator.isHealthy("jsonRpcProvider")).rejects.toThrow(); + expect(httpService.post).toHaveBeenCalledWith("http://localhost:3050", rpcRequest, { timeout: 5000 }); + }); + + it("throws HealthCheckError when RPC request times out", async () => { + const error = new AxiosError(); + error.code = "ECONNABORTED"; + + (httpService.post as jest.Mock).mockReturnValueOnce(throwError(() => error)); + await expect(jsonRpcHealthIndicator.isHealthy("jsonRpcProvider")).rejects.toThrow(); + expect(httpService.post).toHaveBeenCalledWith("http://localhost:3050", rpcRequest, { timeout: 5000 }); + }); + + it("uses configured timeout from config service", async () => { + (configService.get as jest.Mock).mockImplementation((key: string) => { + if (key === "blockchain.rpcUrl") return "http://localhost:3050"; + if (key === "healthChecks.rpcHealthCheckTimeoutMs") return 10000; + return null; }); + jsonRpcHealthIndicator = await getHealthIndicator(); + + (httpService.post as jest.Mock).mockReturnValueOnce(of({ data: { result: "0x1" } })); + await jsonRpcHealthIndicator.isHealthy("jsonRpcProvider"); + expect(httpService.post).toHaveBeenCalledWith("http://localhost:3050", rpcRequest, { timeout: 10000 }); }); }); }); diff --git a/packages/worker/src/health/jsonRpcProvider.health.ts b/packages/worker/src/health/jsonRpcProvider.health.ts index 60406bfb4c..e5434382b2 100644 --- a/packages/worker/src/health/jsonRpcProvider.health.ts +++ b/packages/worker/src/health/jsonRpcProvider.health.ts @@ -1,22 +1,64 @@ import { Injectable } from "@nestjs/common"; import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from "@nestjs/terminus"; -import { JsonRpcProviderBase } from "../rpcProvider"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { catchError, firstValueFrom } from "rxjs"; +import { AxiosError } from "axios"; @Injectable() export class JsonRpcHealthIndicator extends HealthIndicator { - constructor(private readonly provider: JsonRpcProviderBase) { + private readonly rpcUrl: string; + private readonly healthCheckTimeoutMs: number; + private readonly logger: Logger; + + constructor(configService: ConfigService, private readonly httpService: HttpService) { super(); + this.logger = new Logger(JsonRpcHealthIndicator.name); + this.rpcUrl = configService.get("blockchain.rpcUrl"); + this.healthCheckTimeoutMs = configService.get("healthChecks.rpcHealthCheckTimeoutMs"); } async isHealthy(key: string): Promise { - const rpcProviderState = this.provider.getState(); - const isHealthy = rpcProviderState === "open"; - const result = this.getStatus(key, isHealthy, { rpcProviderState }); + let isHealthy = true; + try { + // Check RPC health with a pure HTTP request to remove SDK out of the picture + // and avoid any SDK-specific issues. + // Use eth_chainId call as it is the lightest one and return a static value from the memory. + await firstValueFrom( + this.httpService + .post( + this.rpcUrl, + { + id: 1, + jsonrpc: "2.0", + method: "eth_chainId", + params: [], + }, + { timeout: this.healthCheckTimeoutMs } + ) + .pipe( + catchError((error: AxiosError) => { + this.logger.error({ + message: `Failed to ping RPC`, + stack: error.stack, + status: error.response?.status, + response: error.response?.data, + }); + throw error; + }) + ) + ); + } catch { + isHealthy = false; + } + + const result = this.getStatus(key, isHealthy, { status: isHealthy ? "up" : "down" }); if (isHealthy) { return result; } - throw new HealthCheckError("JSON RPC provider is not in open state", result); + throw new HealthCheckError("JSON RPC provider is down or not reachable", result); } }