diff --git a/.changeset/whole-lines-smoke.md b/.changeset/whole-lines-smoke.md new file mode 100644 index 0000000000..0209735b3e --- /dev/null +++ b/.changeset/whole-lines-smoke.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensdb-sdk": minor +--- + +Extended the ENSNode Metadata with ENSRainbow Public Config. diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts index ba0f0bee5b..7cf080e5f4 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.test.ts @@ -263,7 +263,7 @@ describe("EnsDbWriterWorker", () => { }); describe("interval behavior - snapshot upserts", () => { - it("continues upserting after snapshot validation errors", async () => { + it("upserts snapshots across different omnichain statuses", async () => { // arrange const unstartedSnapshot = createMockOmnichainSnapshot({ omnichainStatus: OmnichainIndexingStatusIds.Unstarted, @@ -271,13 +271,20 @@ describe("EnsDbWriterWorker", () => { const validSnapshot = createMockOmnichainSnapshot({ omnichainIndexingCursor: 200, }); - const crossChainSnapshot = createMockCrossChainSnapshot({ + const unstartedCrossChainSnapshot = createMockCrossChainSnapshot({ + slowestChainIndexingCursor: 100, + snapshotTime: 200, + omnichainSnapshot: unstartedSnapshot, + }); + const validCrossChainSnapshot = createMockCrossChainSnapshot({ slowestChainIndexingCursor: 200, snapshotTime: 300, omnichainSnapshot: validSnapshot, }); - vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain).mockReturnValue(crossChainSnapshot); + vi.mocked(buildCrossChainIndexingStatusSnapshotOmnichain) + .mockReturnValueOnce(unstartedCrossChainSnapshot) + .mockReturnValueOnce(validCrossChainSnapshot); const ensDbClient = createMockEnsDbWriter(); const indexingStatusBuilder = { @@ -299,8 +306,15 @@ describe("EnsDbWriterWorker", () => { // assert expect(indexingStatusBuilder.getOmnichainIndexingStatusSnapshot).toHaveBeenCalledTimes(2); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(1); - expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledWith(crossChainSnapshot); + expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenCalledTimes(2); + expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenNthCalledWith( + 1, + unstartedCrossChainSnapshot, + ); + expect(ensDbClient.upsertIndexingStatusSnapshot).toHaveBeenNthCalledWith( + 2, + validCrossChainSnapshot, + ); // cleanup worker.stop(); diff --git a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts index 7204dd535d..9ce45ce75a 100644 --- a/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts +++ b/apps/ensindexer/src/lib/ensdb-writer-worker/ensdb-writer-worker.ts @@ -7,8 +7,6 @@ import { type CrossChainIndexingStatusSnapshot, type Duration, type EnsIndexerPublicConfig, - OmnichainIndexingStatusIds, - type OmnichainIndexingStatusSnapshot, validateEnsIndexerPublicConfigCompatibility, } from "@ensnode/ensnode-sdk"; import type { LocalPonderClient } from "@ensnode/ponder-sdk"; @@ -260,7 +258,8 @@ export class EnsDbWriterWorker { // get system timestamp for the current iteration const snapshotTime = getUnixTime(new Date()); - const omnichainSnapshot = await this.getValidatedIndexingStatusSnapshot(); + const omnichainSnapshot = + await this.indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(); const crossChainSnapshot = buildCrossChainIndexingStatusSnapshotOmnichain( omnichainSnapshot, @@ -278,24 +277,4 @@ export class EnsDbWriterWorker { // should not cause the ENSDb Writer Worker to stop functioning. } } - - /** - * Get validated Omnichain Indexing Status Snapshot - * - * @returns Validated Omnichain Indexing Status Snapshot. - * @throws Error if the Omnichain Indexing Status is not in expected status yet. - */ - private async getValidatedIndexingStatusSnapshot(): Promise { - const omnichainSnapshot = await this.indexingStatusBuilder.getOmnichainIndexingStatusSnapshot(); - - // It only makes sense to write Indexing Status Snapshots into ENSDb once - // the indexing process has started, as before that there is no meaningful - // status to record. - // Invariant: the Omnichain Status must indicate that indexing has started already. - if (omnichainSnapshot.omnichainStatus === OmnichainIndexingStatusIds.Unstarted) { - throw new Error("Omnichain Status must not be 'Unstarted'."); - } - - return omnichainSnapshot; - } } diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index c6785560c0..bd9b262f4f 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -3,6 +3,7 @@ import config from "@/config"; import { secondsToMilliseconds } from "date-fns"; import pRetry from "p-retry"; +import type { Duration } from "@ensnode/ensnode-sdk"; import { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; import { logger } from "@/lib/logger"; @@ -47,28 +48,34 @@ let waitForEnsRainbowToBeReadyPromise: Promise | undefined; * This error will trigger termination of the ENSIndexer process. */ export function waitForEnsRainbowToBeReady(): Promise { - if (waitForEnsRainbowToBeReadyPromise) { - return waitForEnsRainbowToBeReadyPromise; - } + if (waitForEnsRainbowToBeReadyPromise) return waitForEnsRainbowToBeReadyPromise; logger.info({ msg: `Waiting for ENSRainbow instance to be ready`, ensRainbowInstance: ensRainbowUrl.href, }); + const retryInterval: Duration = 5; + const retryIntervalMs = secondsToMilliseconds(retryInterval); + const retriesPerMinute = 60 / retryInterval; + waitForEnsRainbowToBeReadyPromise = pRetry(async () => ensRainbowClient.health(), { - retries: 60, // This allows for a total of over 1 hour of retries with 1 minute between attempts. - minTimeout: secondsToMilliseconds(60), - maxTimeout: secondsToMilliseconds(60), + retries: retriesPerMinute * 60, // This allows for a total of over 1 hour of retries with `retryInterval` between attempts. + minTimeout: retryIntervalMs, + maxTimeout: retryIntervalMs, onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { - logger.warn({ - msg: `ENSRainbow health check failed`, - attempt: attemptNumber, - retriesLeft, - error: retriesLeft === 0 ? error : undefined, - ensRainbowInstance: ensRainbowUrl.href, - advice: `This might be due to ENSRainbow having a cold start, which can take 30+ minutes.`, - }); + // Log once every minute to avoid excessive logging during ENSRainbow cold start, + // while still providing visibility into the retry process. + if (attemptNumber % 12 === 0) { + logger.warn({ + msg: `ENSRainbow health check failed`, + attempt: attemptNumber, + retriesLeft, + error: retriesLeft === 0 ? error : undefined, + ensRainbowInstance: ensRainbowUrl.href, + advice: `This might be due to ENSRainbow having a cold start, which can take 30+ minutes.`, + }); + } }, }) .then(() => { @@ -78,18 +85,13 @@ export function waitForEnsRainbowToBeReady(): Promise { }); }) .catch((error) => { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - logger.error({ msg: `ENSRainbow health check failed after multiple attempts`, error, ensRainbowInstance: ensRainbowUrl.href, }); - // Throw the error to terminate the ENSIndexer process due to the failed health check of a critical dependency - throw new Error(errorMessage, { - cause: error instanceof Error ? error : undefined, - }); + throw error; }); return waitForEnsRainbowToBeReadyPromise; diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 28d13674fd..99f695b343 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts @@ -1,12 +1,20 @@ import type { Context, EventNames } from "ponder:registry"; import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; +import { OmnichainIndexingStatusIds } from "@ensnode/ensnode-sdk"; + import type { IndexingEngineContext, IndexingEngineEvent } from "./ponder"; const { mockPonderOn } = vi.hoisted(() => ({ mockPonderOn: vi.fn() })); const mockWaitForEnsRainbow = vi.hoisted(() => vi.fn()); +const mockGetEnsRainbowPublicConfig = vi.hoisted(() => vi.fn()); +const mockGetIndexingStatusSnapshot = vi.hoisted(() => vi.fn()); +const mockUpsertEnsRainbowPublicConfig = vi.hoisted(() => vi.fn()); + +const mockEnsRainbowClientConfig = vi.hoisted(() => vi.fn()); + vi.mock("ponder:registry", () => ({ ponder: { on: (...args: unknown[]) => mockPonderOn(...args), @@ -19,22 +27,61 @@ vi.mock("ponder:schema", () => ({ vi.mock("@/lib/ensrainbow/singleton", () => ({ waitForEnsRainbowToBeReady: mockWaitForEnsRainbow, + ensRainbowClient: { + config: mockEnsRainbowClientConfig, + }, +})); + +vi.mock("@/lib/ensdb/singleton", () => ({ + ensDbClient: { + getEnsRainbowPublicConfig: mockGetEnsRainbowPublicConfig, + getIndexingStatusSnapshot: mockGetIndexingStatusSnapshot, + upsertEnsRainbowPublicConfig: mockUpsertEnsRainbowPublicConfig, + }, +})); + +vi.mock("p-retry", () => ({ + default: (fn: () => Promise) => fn(), })); describe("addOnchainEventListener", () => { + // Test fixtures + const createMockDb = () => vi.fn(); + const createMockContext = (db = createMockDb()) => + ({ + db, + chain: { id: 1 }, + block: { number: 100n }, + client: { request: vi.fn() }, + }) as unknown as Context; + const createMockEvent = () => + ({ args: { node: "0x123" } }) as unknown as IndexingEngineEvent; + const createHandler = () => vi.fn().mockResolvedValue(undefined); + + // ENSRainbow config factories + const createEnsRainbowConfig = (labelSetId = "test-label-set", version = 1) => ({ + labelSet: { labelSetId, highestLabelSetVersion: version }, + }); + + const createUnstartedIndexingStatus = () => ({ + omnichainSnapshot: { omnichainStatus: OmnichainIndexingStatusIds.Unstarted }, + }); + beforeEach(async () => { vi.clearAllMocks(); + // Default mocks for successful ENSRainbow connection + mockGetEnsRainbowPublicConfig.mockResolvedValue(createEnsRainbowConfig()); + mockGetIndexingStatusSnapshot.mockResolvedValue(createUnstartedIndexingStatus()); + mockUpsertEnsRainbowPublicConfig.mockResolvedValue(undefined); + mockEnsRainbowClientConfig.mockResolvedValue(createEnsRainbowConfig()); mockWaitForEnsRainbow.mockResolvedValue(undefined); - // Reset module state to test idempotent behavior correctly vi.resetModules(); }); - // Helper to get fresh module reference after resetModules() async function getPonderModule() { return await import("./ponder"); } - // Helper to extract the callback registered with ponder.on function getRegisteredCallback( callIndex = 0, ): (args: { @@ -44,12 +91,30 @@ describe("addOnchainEventListener", () => { return mockPonderOn.mock.calls[callIndex]![1] as ReturnType; } + async function registerAndExecuteHandler( + eventName: EventNames, + handler: ReturnType, + context?: Context, + event?: IndexingEngineEvent, + ) { + const { addOnchainEventListener } = await getPonderModule(); + addOnchainEventListener(eventName, handler); + await getRegisteredCallback()({ + context: context ?? ({ db: createMockDb() } as unknown as Context), + event: event ?? ({} as IndexingEngineEvent), + }); + return handler; + } + + async function expectHandlerThrows(setupFn: () => Promise, expectedError: string | RegExp) { + await expect(setupFn()).rejects.toThrow(expectedError); + } + describe("handler registration", () => { it("registers the event name and handler with ponder.on", async () => { const { addOnchainEventListener } = await getPonderModule(); - const handler = vi.fn(); - addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + addOnchainEventListener("Resolver:AddrChanged" as EventNames, createHandler()); expect(mockPonderOn).toHaveBeenCalledWith("Resolver:AddrChanged", expect.any(Function)); }); @@ -58,9 +123,8 @@ describe("addOnchainEventListener", () => { const { addOnchainEventListener } = await getPonderModule(); const mockSubscription = { unsubscribe: vi.fn() }; mockPonderOn.mockReturnValue(mockSubscription); - const handler = vi.fn(); - const result = addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + const result = addOnchainEventListener("Resolver:AddrChanged" as EventNames, createHandler()); expect(result).toBe(mockSubscription); }); @@ -68,16 +132,12 @@ describe("addOnchainEventListener", () => { describe("context transformation", () => { it("adds ensDb as an alias to the Ponder db", async () => { - const { addOnchainEventListener } = await getPonderModule(); - const handler = vi.fn(); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); - await getRegisteredCallback()({ - context: mockContext, - event: {} as IndexingEngineEvent, - }); + const mockDb = createMockDb(); + const handler = await registerAndExecuteHandler( + "Resolver:AddrChanged" as EventNames, + createHandler(), + { db: mockDb } as unknown as Context, + ); const receivedContext = handler.mock.calls[0]![0].context; expect(receivedContext.ensDb).toBe(mockDb); @@ -85,24 +145,19 @@ describe("addOnchainEventListener", () => { }); it("preserves all other Ponder context properties", async () => { - const { addOnchainEventListener } = await getPonderModule(); - const handler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { - db: mockDb, - chain: { id: 1 }, - block: { number: 100n }, - client: { request: vi.fn() }, - } as unknown as Context; - const mockEvent = { args: { node: "0x123" } } as unknown as IndexingEngineEvent; - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); - await getRegisteredCallback()({ context: mockContext, event: mockEvent }); + const mockContext = createMockContext(); + const mockEvent = createMockEvent(); + const handler = await registerAndExecuteHandler( + "Resolver:AddrChanged" as EventNames, + createHandler(), + mockContext, + mockEvent, + ); expect(handler).toHaveBeenCalledWith({ context: expect.objectContaining({ - db: mockDb, - ensDb: mockDb, + db: mockContext.db, + ensDb: mockContext.db, chain: { id: 1 }, block: { number: 100n }, client: expect.any(Object), @@ -114,45 +169,40 @@ describe("addOnchainEventListener", () => { describe("event forwarding", () => { it("passes the original event to the handler unchanged", async () => { - const { addOnchainEventListener } = await getPonderModule(); - const handler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); const mockEvent = { args: { node: "0x123", label: "0x456", owner: "0x789" }, block: { number: 100n }, transaction: { hash: "0xabc" }, } as unknown as IndexingEngineEvent; - - addOnchainEventListener("Registry:Transfer" as EventNames, handler); - await getRegisteredCallback()({ - context: { db: mockDb } as unknown as Context, - event: mockEvent, - }); + const handler = await registerAndExecuteHandler( + "Registry:Transfer" as EventNames, + createHandler(), + { db: createMockDb() } as unknown as Context, + mockEvent, + ); expect(handler).toHaveBeenCalledWith(expect.objectContaining({ event: mockEvent })); }); it("supports multiple independent event registrations", async () => { const { addOnchainEventListener } = await getPonderModule(); - const handler1 = vi.fn().mockResolvedValue(undefined); - const handler2 = vi.fn().mockResolvedValue(undefined); + const handler1 = createHandler(); + const handler2 = createHandler(); addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler1); addOnchainEventListener("Resolver:NameChanged" as EventNames, handler2); expect(mockPonderOn).toHaveBeenCalledTimes(2); - // Trigger first handler await getRegisteredCallback(0)({ - context: { db: vi.fn() } as unknown as Context, + context: { db: createMockDb() } as unknown as Context, event: {} as IndexingEngineEvent, }); expect(handler1).toHaveBeenCalledTimes(1); expect(handler2).not.toHaveBeenCalled(); - // Trigger second handler await getRegisteredCallback(1)({ - context: { db: vi.fn() } as unknown as Context, + context: { db: createMockDb() } as unknown as Context, event: {} as IndexingEngineEvent, }); expect(handler2).toHaveBeenCalledTimes(1); @@ -161,28 +211,16 @@ describe("addOnchainEventListener", () => { describe("handler types", () => { it("supports async handlers", async () => { - const { addOnchainEventListener } = await getPonderModule(); - const asyncHandler = vi.fn().mockResolvedValue(undefined); - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, asyncHandler); - await getRegisteredCallback()({ - context: { db: vi.fn() } as unknown as Context, - event: {} as IndexingEngineEvent, - }); - - expect(asyncHandler).toHaveBeenCalled(); + const handler = await registerAndExecuteHandler( + "Resolver:AddrChanged" as EventNames, + createHandler(), + ); + expect(handler).toHaveBeenCalled(); }); it("supports sync handlers", async () => { - const { addOnchainEventListener } = await getPonderModule(); const syncHandler = vi.fn(); - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, syncHandler); - await getRegisteredCallback()({ - context: { db: vi.fn() } as unknown as Context, - event: {} as IndexingEngineEvent, - }); - + await registerAndExecuteHandler("Resolver:AddrChanged" as EventNames, syncHandler); expect(syncHandler).toHaveBeenCalled(); }); }); @@ -197,12 +235,14 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("Resolver:AddrChanged" as EventNames, failingHandler); - await expect( - getRegisteredCallback()({ - context: { db: vi.fn() } as unknown as Context, - event: {} as IndexingEngineEvent, - }), - ).rejects.toThrow("Sync handler failed"); + await expectHandlerThrows( + async () => + getRegisteredCallback()({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + "Sync handler failed", + ); }); it("re-throws errors from async handlers", async () => { @@ -212,159 +252,283 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("Resolver:AddrChanged" as EventNames, failingHandler); - await expect( - getRegisteredCallback()({ - context: { db: vi.fn() } as unknown as Context, - event: {} as IndexingEngineEvent, - }), - ).rejects.toThrow("Async handler failed"); + await expectHandlerThrows( + async () => + getRegisteredCallback()({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + "Async handler failed", + ); }); }); describe("ENSRainbow preconditions (onchain events)", () => { it("waits for ENSRainbow before executing the handler", async () => { + const handler = await registerAndExecuteHandler( + "Resolver:AddrChanged" as EventNames, + createHandler(), + ); + expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalled(); + }); + + it("fetches ENSRainbow public config from ENSDb", async () => { + await registerAndExecuteHandler("Resolver:AddrChanged" as EventNames, createHandler()); + expect(mockGetEnsRainbowPublicConfig).toHaveBeenCalledTimes(1); + }); + + it("fetches ENSRainbow public config from ENSRainbow API when not stored", async () => { + mockGetEnsRainbowPublicConfig.mockResolvedValue(undefined); + + await registerAndExecuteHandler("Resolver:AddrChanged" as EventNames, createHandler()); + + expect(mockEnsRainbowClientConfig).toHaveBeenCalledTimes(1); + }); + + it("validates indexing status is Unstarted when no config stored", async () => { + mockGetEnsRainbowPublicConfig.mockResolvedValue(undefined); + mockGetIndexingStatusSnapshot.mockResolvedValue(createUnstartedIndexingStatus()); + + const handler = await registerAndExecuteHandler( + "Resolver:AddrChanged" as EventNames, + createHandler(), + ); + + expect(mockGetIndexingStatusSnapshot).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalled(); + }); + + it("throws when indexing status is not Unstarted and no config stored", async () => { const { addOnchainEventListener } = await getPonderModule(); - const handler = vi.fn().mockResolvedValue(undefined); + mockGetEnsRainbowPublicConfig.mockResolvedValue(undefined); + mockGetIndexingStatusSnapshot.mockResolvedValue({ + omnichainSnapshot: { omnichainStatus: OmnichainIndexingStatusIds.Backfill }, + }); - addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); - await getRegisteredCallback()({ - context: { db: vi.fn() } as unknown as Context, - event: {} as IndexingEngineEvent, + addOnchainEventListener("Resolver:AddrChanged" as EventNames, createHandler()); + + await expectHandlerThrows( + async () => + getRegisteredCallback()({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + `omnichain indexing status must be '${OmnichainIndexingStatusIds.Unstarted}'`, + ); + }); + + it("throws when indexing status snapshot is missing", async () => { + const { addOnchainEventListener } = await getPonderModule(); + mockGetEnsRainbowPublicConfig.mockResolvedValue(undefined); + mockGetIndexingStatusSnapshot.mockResolvedValue(undefined); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, createHandler()); + + await expectHandlerThrows( + async () => + getRegisteredCallback()({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + /.*/, // Any error is acceptable + ); + }); + + describe("config validation", () => { + it("validates fetched config against stored config when config exists", async () => { + const storedConfig = createEnsRainbowConfig("test-label-set", 1); + const fetchedConfig = createEnsRainbowConfig("test-label-set", 1); + mockGetEnsRainbowPublicConfig.mockResolvedValue(storedConfig); + mockEnsRainbowClientConfig.mockResolvedValue(fetchedConfig); + + const handler = await registerAndExecuteHandler( + "Resolver:AddrChanged" as EventNames, + createHandler(), + ); + + expect(handler).toHaveBeenCalled(); + expect(mockUpsertEnsRainbowPublicConfig).toHaveBeenCalledWith(fetchedConfig); }); - expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalled(); + it("throws when config validation fails due to labelSetId mismatch", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const storedConfig = createEnsRainbowConfig("stored-label-set", 1); + const fetchedConfig = createEnsRainbowConfig("fetched-label-set", 1); + mockGetEnsRainbowPublicConfig.mockResolvedValue(storedConfig); + mockEnsRainbowClientConfig.mockResolvedValue(fetchedConfig); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, createHandler()); + + await expectHandlerThrows( + async () => + getRegisteredCallback()({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + /label set ID/i, + ); + }); + + it("throws when config validation fails due to version downgrade", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const storedConfig = createEnsRainbowConfig("test-label-set", 2); + const fetchedConfig = createEnsRainbowConfig("test-label-set", 1); + mockGetEnsRainbowPublicConfig.mockResolvedValue(storedConfig); + mockEnsRainbowClientConfig.mockResolvedValue(fetchedConfig); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, createHandler()); + + await expectHandlerThrows( + async () => + getRegisteredCallback()({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + /highest label set version/i, + ); + }); + }); + + it("throws when ENSRainbow config is missing from API response", async () => { + const { addOnchainEventListener } = await getPonderModule(); + mockGetEnsRainbowPublicConfig.mockResolvedValue(undefined); + mockGetIndexingStatusSnapshot.mockResolvedValue(createUnstartedIndexingStatus()); + mockEnsRainbowClientConfig.mockResolvedValue(undefined); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, createHandler()); + + await expectHandlerThrows( + async () => + getRegisteredCallback()({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + "ENSRainbow Public Config is missing from the response", + ); + }); + + it("upserts validated ENSRainbow config to ENSDb", async () => { + const fetchedConfig = createEnsRainbowConfig(); + mockGetEnsRainbowPublicConfig.mockResolvedValue(undefined); + mockGetIndexingStatusSnapshot.mockResolvedValue(createUnstartedIndexingStatus()); + mockEnsRainbowClientConfig.mockResolvedValue(fetchedConfig); + + await registerAndExecuteHandler("Resolver:AddrChanged" as EventNames, createHandler()); + + expect(mockUpsertEnsRainbowPublicConfig).toHaveBeenCalledWith(fetchedConfig); }); it("prevents handler execution if ENSRainbow is not ready", async () => { const { addOnchainEventListener } = await getPonderModule(); - const handler = vi.fn().mockResolvedValue(undefined); + const handler = createHandler(); + mockGetEnsRainbowPublicConfig.mockResolvedValue(undefined); + mockGetIndexingStatusSnapshot.mockResolvedValue(createUnstartedIndexingStatus()); mockWaitForEnsRainbow.mockRejectedValue(new Error("ENSRainbow not ready")); addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); - await expect( - getRegisteredCallback()({ - context: { db: vi.fn() } as unknown as Context, - event: {} as IndexingEngineEvent, - }), - ).rejects.toThrow("ENSRainbow not ready"); - + await expectHandlerThrows( + async () => + getRegisteredCallback()({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + "ENSRainbow not ready", + ); expect(handler).not.toHaveBeenCalled(); }); it("calls waitForEnsRainbowToBeReady only once across multiple onchain events (idempotent)", async () => { const { addOnchainEventListener } = await getPonderModule(); - const handler1 = vi.fn().mockResolvedValue(undefined); - const handler2 = vi.fn().mockResolvedValue(undefined); + const handler1 = createHandler(); + const handler2 = createHandler(); - // Register two different onchain event listeners addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler1); addOnchainEventListener("Registry:Transfer" as EventNames, handler2); - // Trigger the first event handler await getRegisteredCallback(0)({ - context: { db: vi.fn() } as unknown as Context, + context: { db: createMockDb() } as unknown as Context, event: { args: { a: "1" } } as unknown as IndexingEngineEvent, }); expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); - // Trigger the second event handler await getRegisteredCallback(1)({ - context: { db: vi.fn() } as unknown as Context, + context: { db: createMockDb() } as unknown as Context, event: { args: { a: "2" } } as unknown as IndexingEngineEvent, }); - // Should still only have been called once (idempotent behavior) expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); expect(handler1).toHaveBeenCalledTimes(1); expect(handler2).toHaveBeenCalledTimes(1); }); - it("calls waitForEnsRainbowToBeReady only once when two onchain callbacks fire concurrently before the readiness promise resolves", async () => { - const { addOnchainEventListener } = await getPonderModule(); - const handler1 = vi.fn().mockResolvedValue(undefined); - const handler2 = vi.fn().mockResolvedValue(undefined); - let resolveReadiness: (() => void) | undefined; - - // Create a promise that won't resolve until we manually trigger it - mockWaitForEnsRainbow.mockImplementation(() => { - return new Promise((resolve) => { - resolveReadiness = resolve; - }); - }); - - // Register two different onchain event listeners - addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler1); - addOnchainEventListener("Registry:Transfer" as EventNames, handler2); - - // Fire both handlers concurrently - neither should complete yet - const promise1 = getRegisteredCallback(0)({ - context: { db: vi.fn() } as unknown as Context, - event: { args: { a: "1" } } as unknown as IndexingEngineEvent, - }); - const promise2 = getRegisteredCallback(1)({ - context: { db: vi.fn() } as unknown as Context, - event: { args: { a: "2" } } as unknown as IndexingEngineEvent, - }); - - // Should only have been called once despite concurrent execution - expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); - - // Neither handler should have executed yet - expect(handler1).not.toHaveBeenCalled(); - expect(handler2).not.toHaveBeenCalled(); - - // Now resolve the readiness promise - resolveReadiness!(); - - // Wait for both handlers to complete - await Promise.all([promise1, promise2]); - - // Both handlers should have executed after resolution - expect(handler1).toHaveBeenCalledTimes(1); - expect(handler2).toHaveBeenCalledTimes(1); - }); - it("resolves ENSRainbow before calling the handler", async () => { - const { addOnchainEventListener } = await getPonderModule(); - const handler = vi.fn().mockResolvedValue(undefined); let preconditionResolved = false; + mockGetEnsRainbowPublicConfig.mockResolvedValue(undefined); + mockGetIndexingStatusSnapshot.mockResolvedValue(createUnstartedIndexingStatus()); mockWaitForEnsRainbow.mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); preconditionResolved = true; }); - addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); - await getRegisteredCallback()({ - context: { db: vi.fn() } as unknown as Context, - event: {} as IndexingEngineEvent, - }); + const handler = await registerAndExecuteHandler( + "Resolver:AddrChanged" as EventNames, + createHandler(), + ); expect(preconditionResolved).toBe(true); expect(handler).toHaveBeenCalled(); }); + + it("propagates ENSRainbow connection errors with context", async () => { + const { addOnchainEventListener } = await getPonderModule(); + mockGetEnsRainbowPublicConfig.mockRejectedValue(new Error("Database connection failed")); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, createHandler()); + + await expectHandlerThrows( + async () => + getRegisteredCallback()({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + /.*/, + ); + }); }); describe("setup events (no preconditions)", () => { - it("skips ENSRainbow wait for :setup events", async () => { + async function registerAndExecuteSetupEvent( + eventName: string, + handler: ReturnType, + ) { const { addOnchainEventListener } = await getPonderModule(); - const handler = vi.fn().mockResolvedValue(undefined); - - addOnchainEventListener("Registry:setup" as EventNames, handler); + addOnchainEventListener(eventName as EventNames, handler); await getRegisteredCallback()({ - context: { db: vi.fn() } as unknown as Context, + context: { db: createMockDb() } as unknown as Context, event: {} as IndexingEngineEvent, }); + return handler; + } + + it("skips ENSRainbow wait for :setup events", async () => { + await registerAndExecuteSetupEvent("Registry:setup", createHandler()); expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); - expect(handler).toHaveBeenCalled(); + expect(mockGetEnsRainbowPublicConfig).not.toHaveBeenCalled(); + expect(mockGetIndexingStatusSnapshot).not.toHaveBeenCalled(); + }); + + it("skips all ENSRainbow connection setup for :setup events", async () => { + await registerAndExecuteSetupEvent("PublicResolver:setup", createHandler()); + + expect(mockEnsRainbowClientConfig).not.toHaveBeenCalled(); + expect(mockUpsertEnsRainbowPublicConfig).not.toHaveBeenCalled(); }); it("handles various setup event name formats", async () => { - const { addOnchainEventListener } = await getPonderModule(); - const handler = vi.fn().mockResolvedValue(undefined); const setupEvents = [ "Registry:setup", "PublicResolver:setup", @@ -373,40 +537,56 @@ describe("addOnchainEventListener", () => { for (const eventName of setupEvents) { vi.clearAllMocks(); - handler.mockClear(); + const handler = createHandler(); - addOnchainEventListener(eventName as EventNames, handler); - await getRegisteredCallback()({ - context: { db: vi.fn() } as unknown as Context, - event: {} as IndexingEngineEvent, - }); + await registerAndExecuteSetupEvent(eventName, handler); expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(mockGetEnsRainbowPublicConfig).not.toHaveBeenCalled(); expect(handler).toHaveBeenCalled(); } }); + + it("calls prepareIndexingSetup only once across multiple setup events", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler1 = createHandler(); + const handler2 = createHandler(); + + addOnchainEventListener("Registry:setup" as EventNames, handler1); + addOnchainEventListener("PublicResolver:setup" as EventNames, handler2); + + await getRegisteredCallback(0)({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + await getRegisteredCallback(1)({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + }); }); describe("event type detection", () => { it("treats :setup suffix as setup event type", async () => { const { addOnchainEventListener } = await getPonderModule(); - const setupHandler = vi.fn().mockResolvedValue(undefined); - const onchainHandler = vi.fn().mockResolvedValue(undefined); + const setupHandler = createHandler(); + const onchainHandler = createHandler(); addOnchainEventListener("PublicResolver:setup" as EventNames, setupHandler); addOnchainEventListener("PublicResolver:AddrChanged" as EventNames, onchainHandler); - // Setup event - no ENSRainbow wait await getRegisteredCallback(0)({ - context: { db: vi.fn() } as unknown as Context, + context: { db: createMockDb() } as unknown as Context, event: {} as IndexingEngineEvent, }); expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); expect(setupHandler).toHaveBeenCalled(); - // Onchain event - ENSRainbow wait required await getRegisteredCallback(1)({ - context: { db: vi.fn() } as unknown as Context, + context: { db: createMockDb() } as unknown as Context, event: {} as IndexingEngineEvent, }); expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); @@ -414,7 +594,7 @@ describe("addOnchainEventListener", () => { }); it("treats all non-:setup events as onchain events", async () => { - const handler = vi.fn().mockResolvedValue(undefined); + const handler = createHandler(); const onchainEvents = [ "Resolver:AddrChanged", "Registry:Transfer", @@ -430,7 +610,7 @@ describe("addOnchainEventListener", () => { freshAddOnchainEventListener(eventName as EventNames, handler); await getRegisteredCallback()({ - context: { db: vi.fn() } as unknown as Context, + context: { db: createMockDb() } as unknown as Context, event: {} as IndexingEngineEvent, }); @@ -439,6 +619,99 @@ describe("addOnchainEventListener", () => { } }); }); + + describe("prepareIndexingActivation error handling", () => { + it("throws when ensureValidEnsRainbowConnection fails", async () => { + const { addOnchainEventListener } = await getPonderModule(); + mockGetEnsRainbowPublicConfig.mockRejectedValue(new Error("Connection failed")); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, createHandler()); + + await expectHandlerThrows( + async () => + getRegisteredCallback()({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + "Connection failed", + ); + }); + + it("throws error with cause when connection fails", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const originalError = new Error("Original connection error"); + mockGetEnsRainbowPublicConfig.mockRejectedValue(originalError); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, createHandler()); + + try { + await getRegisteredCallback()({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + expect.fail("Should have thrown"); + } catch (error) { + expect(error).toBe(originalError); + } + }); + }); + + describe("integration: setup followed by onchain events", () => { + it("handles setup event then onchain event correctly", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const setupHandler = createHandler(); + const onchainHandler = createHandler(); + + addOnchainEventListener("Registry:setup" as EventNames, setupHandler); + addOnchainEventListener("Registry:Transfer" as EventNames, onchainHandler); + + await getRegisteredCallback(0)({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + expect(setupHandler).toHaveBeenCalledTimes(1); + expect(mockGetEnsRainbowPublicConfig).not.toHaveBeenCalled(); + + await getRegisteredCallback(1)({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + expect(onchainHandler).toHaveBeenCalledTimes(1); + expect(mockGetEnsRainbowPublicConfig).toHaveBeenCalledTimes(1); + }); + + it("handles multiple setup events before first onchain event", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const setupHandler1 = createHandler(); + const setupHandler2 = createHandler(); + const onchainHandler = createHandler(); + + addOnchainEventListener("Registry:setup" as EventNames, setupHandler1); + addOnchainEventListener("Resolver:setup" as EventNames, setupHandler2); + addOnchainEventListener("Registry:Transfer" as EventNames, onchainHandler); + + await getRegisteredCallback(0)({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + await getRegisteredCallback(1)({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + + expect(setupHandler1).toHaveBeenCalledTimes(1); + expect(setupHandler2).toHaveBeenCalledTimes(1); + expect(mockGetEnsRainbowPublicConfig).not.toHaveBeenCalled(); + + await getRegisteredCallback(2)({ + context: { db: createMockDb() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + + expect(onchainHandler).toHaveBeenCalledTimes(1); + expect(mockGetEnsRainbowPublicConfig).toHaveBeenCalledTimes(1); + }); + }); }); describe("IndexingEngineContext type", () => { diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 84996fdc67..385d232cc3 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -15,7 +15,7 @@ import { ponder, } from "ponder:registry"; -import { waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton"; +import { ensureValidEnsRainbowConnection } from "./preconditions/valid-ensrainbow-connection"; /** * Context passed to event handlers registered with @@ -109,9 +109,10 @@ const EventTypeIds = { Setup: "Setup", /** - * Onchain event + * Onchain log event * - * Driven by an onchain event emitted by an indexed contract. + * This is Ponder's `LogEvent`, driven by an onchain log event emitted by + * an indexed contract. */ Onchain: "Onchain", } as const; @@ -173,7 +174,7 @@ async function initializeIndexingSetup(): Promise { * ``` */ async function initializeIndexingActivation(): Promise { - await waitForEnsRainbowToBeReady(); + await ensureValidEnsRainbowConnection(); } let indexingSetupPromise: Promise | null = null; @@ -194,23 +195,21 @@ let indexingActivationPromise: Promise | null = null; async function eventHandlerPreconditions(eventType: EventTypeId): Promise { switch (eventType) { case EventTypeIds.Setup: { + // Initialize the indexing setup just once if (indexingSetupPromise === null) { - // Initialize the indexing setup just once. indexingSetupPromise = initializeIndexingSetup(); } - return await indexingSetupPromise; + return indexingSetupPromise; } case EventTypeIds.Onchain: { + // Initialize the indexing activation just once if (indexingActivationPromise === null) { - // Initialize the indexing activation just once in order to - // optimize the "hot path" of indexing onchain events, since these are - // much more frequent than setup events. indexingActivationPromise = initializeIndexingActivation(); } - return await indexingActivationPromise; + return indexingActivationPromise; } } } @@ -234,9 +233,6 @@ export function addOnchainEventListener( return ponder.on(eventName, async ({ context, event }) => { await eventHandlerPreconditions(eventType); - await eventHandler({ - context: buildIndexingEngineContext(context), - event, - }); + await eventHandler({ context: buildIndexingEngineContext(context), event }); }); } diff --git a/apps/ensindexer/src/lib/indexing-engines/preconditions/valid-ensrainbow-connection.ts b/apps/ensindexer/src/lib/indexing-engines/preconditions/valid-ensrainbow-connection.ts new file mode 100644 index 0000000000..92d2fd0e9a --- /dev/null +++ b/apps/ensindexer/src/lib/indexing-engines/preconditions/valid-ensrainbow-connection.ts @@ -0,0 +1,181 @@ +import pRetry from "p-retry"; + +import { + type CrossChainIndexingStatusSnapshot, + type EnsRainbowPublicConfig, + OmnichainIndexingStatusIds, +} from "@ensnode/ensnode-sdk"; + +import { ensDbClient } from "@/lib/ensdb/singleton"; +import { ensRainbowClient, waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton"; + +/** + * Invariant: The omnichain indexing status must be `Unstarted` before we can + * upsert the ENSRainbow public config into ENSDb. + * + * @throws Error if the invariant is violated. + */ +function invariant_indexingStatusUnstarted( + indexingStatusSnapshot: CrossChainIndexingStatusSnapshot, +): void { + const { omnichainStatus } = indexingStatusSnapshot.omnichainSnapshot; + + if (omnichainStatus !== OmnichainIndexingStatusIds.Unstarted) { + throw new Error( + `The omnichain indexing status must be '${OmnichainIndexingStatusIds.Unstarted}' to upsert the ENSRainbow public config into ENSDb. Provided status: '${omnichainStatus}'.`, + ); + } +} + +/** + * Invariant: The label set IDs in the stored and fetched + * ENSRainbow public config objects must be the same. + * + * @throws Error if the invariant is violated. + */ +function invariant_labelSetIdCompatibility( + storedConfig: EnsRainbowPublicConfig, + fetchedConfig: EnsRainbowPublicConfig, +): void { + const storedLabelSetId = storedConfig.labelSet.labelSetId; + const fetchedLabelSetId = fetchedConfig.labelSet.labelSetId; + + if (storedLabelSetId !== fetchedLabelSetId) { + throw new Error( + `Label set IDs must be the same. Provided label set ID in stored config: '${storedLabelSetId}' and label set ID in fetched config: '${fetchedLabelSetId}'.`, + ); + } +} + +/** + * The highest label set version in the fetched ENSRainbow public config must be + * greater than or equal to the highest label set version in + * the stored ENSRainbow public config. + * + * This invariant is necessary to ensure that we don't run indexing logic with + * two incompatible versions of the label set, which could lead to + * incorrect indexing results. + * + * @throws Error if the invariant is violated. + */ +function invariant_highestLabelSetVersionCompatibility( + storedConfig: EnsRainbowPublicConfig, + fetchedConfig: EnsRainbowPublicConfig, +): void { + const storedHighestLabelSetVersion = storedConfig.labelSet.highestLabelSetVersion; + const fetchedHighestLabelSetVersion = fetchedConfig.labelSet.highestLabelSetVersion; + + if (storedHighestLabelSetVersion > fetchedHighestLabelSetVersion) { + throw new Error( + `The highest label set version in the fetched config ('${fetchedHighestLabelSetVersion}') must be greater than or equal to the highest label set version in the stored config ('${storedHighestLabelSetVersion}').`, + ); + } +} + +/** + * Get validated ENSRainbow Public Config to be stored in ENSDb. + * + * This function needs to be run after ensuring that ENSRainbow is ready to + * serve HTTP requests. + * + * @param storedConfig The ENSRainbow Public Config stored in ENSDb, + * may be undefined if no config is stored yet. + * @returns The validated ENSRainbow Public Config object to be stored in ENSDb: + * 1) If there is no stored config, returns the fetched config from ENSRainbow. + * 2) If there is a stored config, returns the fetched config from ENSRainbow + * if it's compatible with the stored config. + * @throws Error if fetching the ENSRainbow Public Config fails, or + * if the fetched config is not compatible with the stored config + * (if it exists), or if the invariant on the omnichain indexing status + * is violated when there is no stored config. + */ +async function getValidatedEnsRainbowPublicConfig( + storedConfig: EnsRainbowPublicConfig | undefined, +): Promise { + const fetchedConfig = await ensRainbowClient.config(); + + if (!fetchedConfig) { + throw new Error("ENSRainbow Public Config is missing from the response."); + } + + // Validate the fetched config compatibility with the stored one, + // if the stored config exists. + if (storedConfig) { + invariant_labelSetIdCompatibility(storedConfig, fetchedConfig); + invariant_highestLabelSetVersionCompatibility(storedConfig, fetchedConfig); + } + + return fetchedConfig; +} + +/** + * Ensure that we have a valid connection to ENSRainbow and + * a compatible ENSRainbow Public Config in ENSDb. + * + * All steps in this function are necessary preconditions before + * we can start executing "onchain" event handlers. + * + * Successfully completing this function means that: + * 1) ENSRainbow instance is ready to serve HTTP requests, and + * 2) ENSRainbow Public Config stored in ENSDb is up-to-date and valid. + * + * @throws Error if ENSRainbow is not ready after multiple retries, or + * the Indexing Status snapshot is missing after multiple retries, + * the ENSRainbow Public Config validation failed. + */ +export async function ensureValidEnsRainbowConnection(): Promise { + const storedConfig = await ensDbClient.getEnsRainbowPublicConfig(); + + /** + * If there's no stored config in ENSDb, it means no indexing should have + * happened yet. Therefore, we require the omnichain indexing status to + * be `Unstarted` here. + */ + if (!storedConfig) { + console.log( + "No stored ENSRainbow Public Config found in ENSDb. Validating the omnichain indexing status is 'Unstarted'...", + ); + /** + * Fetch the indexing status snapshot with retries, to handle potential + * transient errors, i.e. when ENSDb has not been yet populated with + * ENSNode Metadata for Indexing Status. + * If the fetch fails after all retries, something is likely wrong with + * ENSDb state, so we throw the error to terminate the ENSIndexer process + * and avoid running with an invalid state. + */ + const indexingStatusSnapshot = await pRetry( + async () => { + const snapshot = await ensDbClient.getIndexingStatusSnapshot(); + + if (!snapshot) { + throw new Error("Indexing Status snapshot was not found in ENSDb."); + } + + console.log("Successfully loaded Indexing Status snapshot from ENSDb."); + + return snapshot; + }, + { + retries: 3, + onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { + console.warn( + `Indexing Status snapshot is unavailable. Attempt ${attemptNumber} failed (${error.message}). ${retriesLeft} retries left.`, + ); + }, + }, + ); + + invariant_indexingStatusUnstarted(indexingStatusSnapshot); + + console.log('Omnichain indexing status is "Unstarted".'); + } + + await waitForEnsRainbowToBeReady(); + + // Once the connected ENSRainbow instance is ready, we can try + // upserting the ENSRainbow Public Config into ENSDb. + console.log("Upserting the validated ENSRainbow Public Config into ENSDb..."); + const validatedEnsRainbowConfig = await getValidatedEnsRainbowPublicConfig(storedConfig); + await ensDbClient.upsertEnsRainbowPublicConfig(validatedEnsRainbowConfig); + console.log("Successfully upserted the validated ENSRainbow Public Config into ENSDb."); +} diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.test.ts b/packages/ensdb-sdk/src/client/ensdb-reader.test.ts index 3a488c4a86..850693cea8 100644 --- a/packages/ensdb-sdk/src/client/ensdb-reader.test.ts +++ b/packages/ensdb-sdk/src/client/ensdb-reader.test.ts @@ -85,4 +85,17 @@ describe("EnsDbReader", () => { ); }); }); + + describe("getEnsRainbowPublicConfig", () => { + it("returns undefined when no record exists", async () => { + await expect(createEnsDbReader().getEnsRainbowPublicConfig()).resolves.toBeUndefined(); + }); + + it("returns the stored config", async () => { + const config = ensDbClientMock.publicConfig.ensRainbowPublicConfig; + selectResult.current = [{ value: config }]; + + await expect(createEnsDbReader().getEnsRainbowPublicConfig()).resolves.toStrictEqual(config); + }); + }); }); diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.ts b/packages/ensdb-sdk/src/client/ensdb-reader.ts index ac6b0b72e3..ea4a878ebd 100644 --- a/packages/ensdb-sdk/src/client/ensdb-reader.ts +++ b/packages/ensdb-sdk/src/client/ensdb-reader.ts @@ -5,6 +5,7 @@ import { deserializeCrossChainIndexingStatusSnapshot, deserializeEnsIndexerPublicConfig, type EnsIndexerPublicConfig, + type EnsRainbowPublicConfig, } from "@ensnode/ensnode-sdk"; import { @@ -20,6 +21,7 @@ import type { SerializedEnsNodeMetadataEnsDbVersion, SerializedEnsNodeMetadataEnsIndexerIndexingStatus, SerializedEnsNodeMetadataEnsIndexerPublicConfig, + SerializedEnsNodeMetadataEnsRainbowPublicConfig, } from "./serialize/ensnode-metadata"; /** @@ -149,13 +151,24 @@ export class EnsDbReader< key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, }); - if (!record) { - return undefined; - } + if (!record) return undefined; return deserializeEnsIndexerPublicConfig(record); } + /** + * Get ENSRainbow Public Config + * + * @returns the existing record, or `undefined`. + */ + async getEnsRainbowPublicConfig(): Promise { + const record = await this.getEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsRainbowPublicConfig, + }); + + return record; + } + /** * Get Indexing Status Snapshot * @@ -168,9 +181,7 @@ export class EnsDbReader< }, ); - if (!record) { - return undefined; - } + if (!record) return undefined; return deserializeCrossChainIndexingStatusSnapshot(record); } @@ -196,9 +207,7 @@ export class EnsDbReader< ), ); - if (result.length === 0) { - return undefined; - } + if (result.length === 0) return undefined; if (result.length === 1 && result[0]) { return result[0].value as EnsNodeMetadataType["value"]; diff --git a/packages/ensdb-sdk/src/client/ensdb-writer.test.ts b/packages/ensdb-sdk/src/client/ensdb-writer.test.ts index 51e7ccf754..5111aac0a3 100644 --- a/packages/ensdb-sdk/src/client/ensdb-writer.test.ts +++ b/packages/ensdb-sdk/src/client/ensdb-writer.test.ts @@ -83,6 +83,20 @@ describe("EnsDbWriter", () => { }); }); + describe("upsertEnsRainbowPublicConfig", () => { + it("writes the rainbow public config", async () => { + const config = ensDbClientMock.publicConfig.ensRainbowPublicConfig; + + await createEnsDbWriter().upsertEnsRainbowPublicConfig(config); + + expect(valuesMock).toHaveBeenCalledWith({ + ensIndexerSchemaName: ensDbClientMock.ensIndexerSchemaName, + key: EnsNodeMetadataKeys.EnsRainbowPublicConfig, + value: config, + }); + }); + }); + describe("migrateEnsNodeSchema", () => { it("calls drizzle-orm migrateEnsNodeSchema with the correct parameters", async () => { const migrationsDirPath = "/path/to/migrations"; diff --git a/packages/ensdb-sdk/src/client/ensdb-writer.ts b/packages/ensdb-sdk/src/client/ensdb-writer.ts index 2ebbd9e08a..e8c0b1ff57 100644 --- a/packages/ensdb-sdk/src/client/ensdb-writer.ts +++ b/packages/ensdb-sdk/src/client/ensdb-writer.ts @@ -3,6 +3,7 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; import { type CrossChainIndexingStatusSnapshot, type EnsIndexerPublicConfig, + type EnsRainbowPublicConfig, serializeCrossChainIndexingStatusSnapshot, serializeEnsIndexerPublicConfig, } from "@ensnode/ensnode-sdk"; @@ -59,6 +60,20 @@ export class EnsDbWriter extends EnsDbReader { }); } + /** + * Upsert ENSRainbow Public Config + * + * @throws when upsert operation failed. + */ + async upsertEnsRainbowPublicConfig( + ensRainbowPublicConfig: EnsRainbowPublicConfig, + ): Promise { + await this.upsertEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsRainbowPublicConfig, + value: ensRainbowPublicConfig, + }); + } + /** * Upsert Indexing Status Snapshot * diff --git a/packages/ensdb-sdk/src/client/ensnode-metadata.ts b/packages/ensdb-sdk/src/client/ensnode-metadata.ts index bdb35c4069..6154352369 100644 --- a/packages/ensdb-sdk/src/client/ensnode-metadata.ts +++ b/packages/ensdb-sdk/src/client/ensnode-metadata.ts @@ -1,6 +1,7 @@ import type { CrossChainIndexingStatusSnapshot, EnsIndexerPublicConfig, + EnsRainbowPublicConfig, } from "@ensnode/ensnode-sdk"; /** @@ -9,6 +10,7 @@ import type { export const EnsNodeMetadataKeys = { EnsDbVersion: "ensdb_version", EnsIndexerPublicConfig: "ensindexer_public_config", + EnsRainbowPublicConfig: "ensrainbow_public_config", EnsIndexerIndexingStatus: "ensindexer_indexing_status", } as const; @@ -24,6 +26,11 @@ export interface EnsNodeMetadataEnsIndexerPublicConfig { value: EnsIndexerPublicConfig; } +export interface EnsNodeMetadataEnsRainbowPublicConfig { + key: typeof EnsNodeMetadataKeys.EnsRainbowPublicConfig; + value: EnsRainbowPublicConfig; +} + export interface EnsNodeMetadataEnsIndexerIndexingStatus { key: typeof EnsNodeMetadataKeys.EnsIndexerIndexingStatus; value: CrossChainIndexingStatusSnapshot; @@ -37,4 +44,5 @@ export interface EnsNodeMetadataEnsIndexerIndexingStatus { export type EnsNodeMetadata = | EnsNodeMetadataEnsDbVersion | EnsNodeMetadataEnsIndexerPublicConfig + | EnsNodeMetadataEnsRainbowPublicConfig | EnsNodeMetadataEnsIndexerIndexingStatus; diff --git a/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts b/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts index cae7fcdd34..989756ed3e 100644 --- a/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts +++ b/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts @@ -8,6 +8,7 @@ import type { EnsNodeMetadataEnsDbVersion, EnsNodeMetadataEnsIndexerIndexingStatus, EnsNodeMetadataEnsIndexerPublicConfig, + EnsNodeMetadataEnsRainbowPublicConfig, EnsNodeMetadataKeys, } from "../ensnode-metadata"; @@ -24,6 +25,11 @@ export interface SerializedEnsNodeMetadataEnsIndexerPublicConfig { value: SerializedEnsIndexerPublicConfig; } +/** + * Serialized representation of {@link EnsNodeMetadataEnsRainbowPublicConfig}. + */ +export type SerializedEnsNodeMetadataEnsRainbowPublicConfig = EnsNodeMetadataEnsRainbowPublicConfig; + /** * Serialized representation of {@link EnsNodeMetadataEnsIndexerIndexingStatus}. */ @@ -38,4 +44,5 @@ export interface SerializedEnsNodeMetadataEnsIndexerIndexingStatus { export type SerializedEnsNodeMetadata = | SerializedEnsNodeMetadataEnsDbVersion | SerializedEnsNodeMetadataEnsIndexerPublicConfig + | SerializedEnsNodeMetadataEnsRainbowPublicConfig | SerializedEnsNodeMetadataEnsIndexerIndexingStatus;