From 3f5dc4f6bfa815a30e143804bba570174153b5e5 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 08:46:27 +0200 Subject: [PATCH 01/19] Create `ensRainbowClient` singleton for ENSIndexer app --- .../singleton.ts} | 5 +++++ apps/ensindexer/src/lib/graphnode-helpers.ts | 10 ++++------ .../src/lib/public-config-builder/singleton.ts | 4 +--- 3 files changed, 10 insertions(+), 9 deletions(-) rename apps/ensindexer/src/lib/{ensraibow-api-client.ts => ensrainbow/singleton.ts} (83%) diff --git a/apps/ensindexer/src/lib/ensraibow-api-client.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts similarity index 83% rename from apps/ensindexer/src/lib/ensraibow-api-client.ts rename to apps/ensindexer/src/lib/ensrainbow/singleton.ts index 877943b43..aaf6c9757 100644 --- a/apps/ensindexer/src/lib/ensraibow-api-client.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -19,3 +19,8 @@ export function getENSRainbowApiClient(): EnsRainbow.ApiClient { return ensRainbowApiClient; } + +/** + * Singleton ENSRainbow API Client instance for ENSIndexer app. + */ +export const ensRainbowClient = getENSRainbowApiClient(); diff --git a/apps/ensindexer/src/lib/graphnode-helpers.ts b/apps/ensindexer/src/lib/graphnode-helpers.ts index df8d4a82e..cb04339ac 100644 --- a/apps/ensindexer/src/lib/graphnode-helpers.ts +++ b/apps/ensindexer/src/lib/graphnode-helpers.ts @@ -3,9 +3,7 @@ import pRetry from "p-retry"; import type { LabelHash, LiteralLabel } from "@ensnode/ensnode-sdk"; import { type EnsRainbow, ErrorCode, isHealError } from "@ensnode/ensrainbow-sdk"; -import { getENSRainbowApiClient } from "@/lib/ensraibow-api-client"; - -const ensRainbowApiClient = getENSRainbowApiClient(); +import { ensRainbowClient } from "@/lib/ensrainbow/singleton"; /** * Attempt to heal a labelHash to its original label. @@ -44,13 +42,13 @@ export async function labelByLabelHash(labelHash: LabelHash): Promise>; + let response: EnsRainbow.HealResponse; try { response = await pRetry( async () => { lastServerError = undefined; - const result = await ensRainbowApiClient.heal(labelHash); + const result = await ensRainbowClient.heal(labelHash); if (isHealError(result) && result.errorCode === ErrorCode.ServerError) { lastServerError = result; @@ -81,7 +79,7 @@ export async function labelByLabelHash(labelHash: LabelHash): Promise Date: Mon, 30 Mar 2026 08:49:52 +0200 Subject: [PATCH 02/19] Simplify `ensrainbow/signleton.ts` file --- .../src/lib/ensrainbow/singleton.ts | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index aaf6c9757..9e2d97342 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -1,26 +1,19 @@ import config from "@/config"; -import { type EnsRainbow, EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; +import { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; -export function getENSRainbowApiClient(): EnsRainbow.ApiClient { - const ensRainbowApiClient = new EnsRainbowApiClient({ - endpointUrl: config.ensRainbowUrl, - labelSet: config.labelSet, - }); +const { ensRainbowUrl, labelSet } = config; - if ( - ensRainbowApiClient.getOptions().endpointUrl === - EnsRainbowApiClient.defaultOptions().endpointUrl - ) { - console.warn( - `Using default public ENSRainbow server which may cause increased network latency. For production, use your own ENSRainbow server that runs on the same network as the ENSIndexer server.`, - ); - } - - return ensRainbowApiClient; +if (ensRainbowUrl === EnsRainbowApiClient.defaultOptions().endpointUrl) { + console.warn( + `Using default public ENSRainbow server which may cause increased network latency. For production, use your own ENSRainbow server that runs on the same network as the ENSIndexer server.`, + ); } /** * Singleton ENSRainbow API Client instance for ENSIndexer app. */ -export const ensRainbowClient = getENSRainbowApiClient(); +export const ensRainbowClient = new EnsRainbowApiClient({ + endpointUrl: ensRainbowUrl, + labelSet, +}); From 40bab875d1e05ca869bb187ac8c5328e5069991f Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 09:46:21 +0200 Subject: [PATCH 03/19] Create `waitForEnsRainbowToBeReady` function This function will enbale ENSIndexer modules to wait for when the ENSRainbow instance is ready to serve traffic. --- .../src/lib/ensrainbow/singleton.ts | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index 9e2d97342..8f4f06acd 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -1,5 +1,7 @@ import config from "@/config"; +import pRetry from "p-retry"; + import { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; const { ensRainbowUrl, labelSet } = config; @@ -11,9 +13,60 @@ if (ensRainbowUrl === EnsRainbowApiClient.defaultOptions().endpointUrl) { } /** - * Singleton ENSRainbow API Client instance for ENSIndexer app. + * Singleton ENSRainbow Client instance for ENSIndexer app. */ export const ensRainbowClient = new EnsRainbowApiClient({ endpointUrl: ensRainbowUrl, labelSet, }); + +/** + * Cached promise for waiting for ENSRainbow to be ready. + * + * This ensures that multiple concurrent calls to + * {@link waitForEnsRainbowToBeReady} will share the same underlying promise + * in order to use the same retry sequence. + */ +let waitForEnsRainbowToBeReadyPromise: Promise | undefined; + +/** + * Wait for ENSRainbow to be ready + * + * Blocks execution until the ENSRainbow instance is ready to serve requests. + * + * Note: It may take 30+ minutes for the ENSRainbow instance to become ready in + * a cold start scenario. We use retries for the ENSRainbow health check with + * an exponential backoff strategy to handle this. + * + * @throws When ENSRainbow fails to become ready after all configured retry attempts. + * This error will trigger termination of the ENSIndexer process. + */ +export function waitForEnsRainbowToBeReady(): Promise { + if (waitForEnsRainbowToBeReadyPromise) { + return waitForEnsRainbowToBeReadyPromise; + } + + console.log(`Waiting for ENSRainbow instance to be ready at '${ensRainbowUrl}'...`); + + waitForEnsRainbowToBeReadyPromise = pRetry(async () => ensRainbowClient.health(), { + retries: 12, // This allows for a total of over 1 hour of retries with the exponential backoff strategy + onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { + console.log( + `ENSRainbow health check attempt ${attemptNumber} failed (${error.message}). ${retriesLeft} retries left.`, + ); + }, + }) + .then(() => console.log(`ENSRainbow instance is ready at '${ensRainbowUrl}'.`)) + .catch((error) => { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + + console.error(`ENSRainbow health check failed after multiple attempts: ${errorMessage}`); + + // Throw the error to terminate the ENSIndexer process due to failed health check of critical dependency + throw new Error(errorMessage, { + cause: error instanceof Error ? error : undefined, + }); + }); + + return waitForEnsRainbowToBeReadyPromise; +} From e84c0c79bd1c09d45f31d4ed1d8fb2f98e349fa9 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 09:47:38 +0200 Subject: [PATCH 04/19] Implement `eventHandlerPreconditios` Allows to wait with indexing onchain events until ENSRainbow instance is ready. --- .../src/lib/indexing-engines/ponder.test.ts | 181 ++++++++++++++++-- .../src/lib/indexing-engines/ponder.ts | 77 +++++++- 2 files changed, 236 insertions(+), 22 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 4bb7f5a79..3919b703a 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts @@ -9,6 +9,8 @@ import { const { mockPonderOn } = vi.hoisted(() => ({ mockPonderOn: vi.fn() })); +const mockWaitForEnsRainbow = vi.hoisted(() => vi.fn()); + vi.mock("ponder:registry", () => ({ ponder: { on: (...args: unknown[]) => mockPonderOn(...args), @@ -19,9 +21,14 @@ vi.mock("ponder:schema", () => ({ ensIndexerSchema: {}, })); +vi.mock("@/lib/ensrainbow/singleton", () => ({ + waitForEnsRainbowToBeReady: mockWaitForEnsRainbow, +})); + describe("addOnchainEventListener", () => { beforeEach(() => { vi.clearAllMocks(); + mockWaitForEnsRainbow.mockResolvedValue(undefined); }); describe("registration", () => { @@ -45,7 +52,7 @@ describe("addOnchainEventListener", () => { }); describe("context transformation", () => { - it("adds ensDb property referencing the same object as db", () => { + it("adds ensDb property referencing the same object as db", async () => { const testHandler = vi.fn(); const mockDb = vi.fn(); const mockContext = { db: mockDb } as unknown as Context; @@ -54,14 +61,14 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); const [, callback] = mockPonderOn.mock.calls[0]!; - callback({ context: mockContext, event: mockEvent }); + await callback({ context: mockContext, event: mockEvent }); const callArg = testHandler.mock.calls[0]?.[0]; expect(callArg?.context.ensDb).toBe(callArg?.context.db); }); - it("preserves all other context properties", () => { - const testHandler = vi.fn(); + it("preserves all other context properties", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); const mockDb = vi.fn(); const mockContext = { db: mockDb, @@ -73,7 +80,7 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); const [, callback] = mockPonderOn.mock.calls[0]!; - callback({ context: mockContext, event: mockEvent }); + await callback({ context: mockContext, event: mockEvent }); expect(testHandler).toHaveBeenCalledWith({ context: expect.objectContaining({ @@ -88,9 +95,9 @@ describe("addOnchainEventListener", () => { }); describe("event handling", () => { - it("supports multiple event names independently", () => { - const handler1 = vi.fn(); - const handler2 = vi.fn(); + it("supports multiple event names independently", async () => { + const handler1 = vi.fn().mockResolvedValue(undefined); + const handler2 = vi.fn().mockResolvedValue(undefined); addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler1); addOnchainEventListener("Resolver:NameChanged" as EventNames, handler2); @@ -100,7 +107,10 @@ describe("addOnchainEventListener", () => { const [, callback1] = mockPonderOn.mock.calls[0]!; const mockDb1 = vi.fn(); const event1 = {} as IndexingEngineEvent; - callback1({ context: { db: mockDb1 } as unknown as Context, event: event1 }); + await callback1({ + context: { db: mockDb1 } as unknown as Context, + event: event1, + }); expect(handler1).toHaveBeenCalledTimes(1); expect(handler2).toHaveBeenCalledTimes(0); @@ -108,13 +118,16 @@ describe("addOnchainEventListener", () => { const [, callback2] = mockPonderOn.mock.calls[1]!; const mockDb2 = vi.fn(); const event2 = {} as IndexingEngineEvent; - callback2({ context: { db: mockDb2 } as unknown as Context, event: event2 }); + await callback2({ + context: { db: mockDb2 } as unknown as Context, + event: event2, + }); expect(handler2).toHaveBeenCalledTimes(1); }); - it("passes the event argument through to the handler", () => { - const testHandler = vi.fn(); + it("passes the event argument through to the handler", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); const mockDb = vi.fn(); const mockEvent = { args: { node: "0x123", label: "0x456" }, @@ -123,7 +136,10 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); const [, callback] = mockPonderOn.mock.calls[0]!; - callback({ context: { db: mockDb } as unknown as Context, event: mockEvent }); + await callback({ + context: { db: mockDb } as unknown as Context, + event: mockEvent, + }); expect(testHandler).toHaveBeenCalledWith(expect.objectContaining({ event: mockEvent })); }); @@ -144,7 +160,7 @@ describe("addOnchainEventListener", () => { expect(asyncHandler).toHaveBeenCalled(); }); - it("handles sync handlers", () => { + it("handles sync handlers", async () => { const syncHandler = vi.fn(); const mockDb = vi.fn(); const mockContext = { db: mockDb } as unknown as Context; @@ -153,14 +169,14 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("Resolver:AddrChanged" as EventNames, syncHandler); const [, callback] = mockPonderOn.mock.calls[0]!; - callback({ context: mockContext, event: mockEvent }); + await callback({ context: mockContext, event: mockEvent }); expect(syncHandler).toHaveBeenCalled(); }); }); describe("error handling", () => { - it("propagates errors from sync handlers", () => { + it("propagates errors from sync handlers", async () => { const error = new Error("Handler failed"); const failingHandler = vi.fn(() => { throw error; @@ -172,7 +188,9 @@ describe("addOnchainEventListener", () => { addOnchainEventListener("Resolver:AddrChanged" as EventNames, failingHandler); const [, callback] = mockPonderOn.mock.calls[0]!; - expect(() => callback({ context: mockContext, event: mockEvent })).toThrow("Handler failed"); + await expect(callback({ context: mockContext, event: mockEvent })).rejects.toThrow( + "Handler failed", + ); }); it("propagates errors from async handlers", async () => { @@ -190,6 +208,135 @@ describe("addOnchainEventListener", () => { ); }); }); + + describe("preconditions", () => { + it("waits for ENSRainbow to be ready before executing onchain event handlers", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + await callback({ context: mockContext, event: mockEvent }); + + expect(mockWaitForEnsRainbow).toHaveBeenCalled(); + expect(testHandler).toHaveBeenCalled(); + }); + + it("does not execute handler if ENSRainbow precondition fails", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + const preconditionError = new Error("ENSRainbow not ready"); + + mockWaitForEnsRainbow.mockRejectedValue(preconditionError); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + await expect(callback({ context: mockContext, event: mockEvent })).rejects.toThrow( + "ENSRainbow not ready", + ); + expect(testHandler).not.toHaveBeenCalled(); + }); + + it("does not throw error for setup events - they have no preconditions", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + + addOnchainEventListener("Registry:setup" as EventNames, testHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + // Should not throw and should call handler without waiting for ENSRainbow + await expect(callback({ context: mockContext, event: mockEvent })).resolves.toBeUndefined(); + expect(testHandler).toHaveBeenCalled(); + expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + }); + + it("resolves ENSRainbow precondition before calling handler", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + + let preconditionResolved = false; + mockWaitForEnsRainbow.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + preconditionResolved = true; + }); + + addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + await callback({ context: mockContext, event: mockEvent }); + + expect(preconditionResolved).toBe(true); + expect(testHandler).toHaveBeenCalled(); + }); + }); + + describe("event type detection", () => { + it("correctly identifies onchain events by name", async () => { + const onchainHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + + addOnchainEventListener("ETHRegistry:NewOwner" as EventNames, onchainHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + await callback({ context: mockContext, event: mockEvent }); + + expect(mockWaitForEnsRainbow).toHaveBeenCalled(); + expect(onchainHandler).toHaveBeenCalled(); + }); + + it("correctly identifies setup events by :setup suffix and skips preconditions", async () => { + const setupHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + + addOnchainEventListener("PublicResolver:setup" as EventNames, setupHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + await callback({ context: mockContext, event: mockEvent }); + + // Setup events should not call ENSRainbow preconditions + expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(setupHandler).toHaveBeenCalled(); + }); + + it("handles various onchain event name formats", async () => { + const testHandler = vi.fn().mockResolvedValue(undefined); + const mockDb = vi.fn(); + const mockContext = { db: mockDb } as unknown as Context; + const mockEvent = {} as IndexingEngineEvent; + + const onchainEvents = [ + "Resolver:AddrChanged", + "Registry:Transfer", + "ETHRegistry:NewResolver", + "BaseRegistrar:NameRegistered", + ]; + + for (const eventName of onchainEvents) { + vi.clearAllMocks(); + addOnchainEventListener(eventName as EventNames, testHandler); + + const [, callback] = mockPonderOn.mock.calls[0]!; + await callback({ context: mockContext, event: mockEvent }); + + expect(mockWaitForEnsRainbow).toHaveBeenCalled(); + expect(testHandler).toHaveBeenCalled(); + } + }); + }); }); describe("IndexingEngineContext type", () => { diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index fca836a2b..516b71c67 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -15,6 +15,8 @@ import { ponder, } from "ponder:registry"; +import { waitForEnsRainbowToBeReady } from "@/lib/ensrainbow/singleton"; + /** * Context passed to event handlers registered with * {@link addOnchainEventListener}. @@ -91,6 +93,69 @@ function buildIndexingEngineContext( }; } +/** + * Event type IDs for indexing handlers. + */ +const EventTypeIds = { + /** + * Setup event + * + * Driven by code, not by an onchain event. + * + * Event handlers for the setup events are fully executed before + * any onchain event handlers are executed, so they can be used to set up + * necessary state for onchain event handlers. + */ + Setup: "Setup", + + /** + * Onchain event + * + * Driven by an onchain event emitted by an indexed contract. + */ + Onchain: "Onchain", +} as const; + +/** + * The derived string union of possible {@link EventTypeIds}. + */ +type EventTypeId = (typeof EventTypeIds)[keyof typeof EventTypeIds]; + +function buildEventTypeId(eventName: EventNames): EventTypeId { + if (eventName.endsWith(":setup")) { + return EventTypeIds.Setup; + } else { + return EventTypeIds.Onchain; + } +} + +/** + * Execute any necessary preconditions before running an event handler + * for a given event type. + * + * Some event handlers may have preconditions that need to be met before + * they can run. For example, onchain event handlers depend on ENSRainbow + * instance being ready to serve "heal" requests. + */ +async function eventHandlerPreconditions(eventName: EventName) { + const eventType = buildEventTypeId(eventName); + + switch (eventType) { + case EventTypeIds.Setup: + /** + * Setup event handlers should not have any precondition. This is because + * only after all setup handlers have run, the indexing metrics for + * Ponder app are populated for all indexed chains. + * ENSIndexer relies on these indexing metrics immediately on startup to + * store the current Indexing Status in ENSDb. + */ + return; + case EventTypeIds.Onchain: { + return await waitForEnsRainbowToBeReady(); + } + } +} + /** * A thin wrapper around `ponder.on` that allows us to: * - Provide custom context to event handlers. @@ -106,10 +171,12 @@ export function addOnchainEventListener( eventName: EventName, eventHandler: (args: IndexingEngineEventHandlerArgs) => Promise | void, ) { - return ponder.on(eventName, ({ context, event }) => - eventHandler({ - context: buildIndexingEngineContext(context), - event, - }), + return ponder.on(eventName, async ({ context, event }) => + eventHandlerPreconditions(eventName).then(() => + eventHandler({ + context: buildIndexingEngineContext(context), + event, + }), + ), ); } From febed1cdc8f6644bcc0120853320f596b6d0675f Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 10:07:53 +0200 Subject: [PATCH 05/19] docs(changeset): Introduced event handler preconditions to improve resiliency. --- .changeset/sharp-moons-shave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sharp-moons-shave.md diff --git a/.changeset/sharp-moons-shave.md b/.changeset/sharp-moons-shave.md new file mode 100644 index 000000000..05c70f89b --- /dev/null +++ b/.changeset/sharp-moons-shave.md @@ -0,0 +1,5 @@ +--- +"ensindexer": minor +--- + +Introduced event handler preconditions to improve resiliency. From 22c3c22497e3b9d3fd7cce5f0b30e4bcffddf36d Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 11:57:27 +0200 Subject: [PATCH 06/19] Fix URL comparison for ENSRainbow singleton instnace --- apps/ensindexer/src/lib/ensrainbow/singleton.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index 8f4f06acd..348eed869 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -6,7 +6,7 @@ import { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk"; const { ensRainbowUrl, labelSet } = config; -if (ensRainbowUrl === EnsRainbowApiClient.defaultOptions().endpointUrl) { +if (ensRainbowUrl.href === EnsRainbowApiClient.defaultOptions().endpointUrl.href) { console.warn( `Using default public ENSRainbow server which may cause increased network latency. For production, use your own ENSRainbow server that runs on the same network as the ENSIndexer server.`, ); From d0dd98ce508352e6c789a745e85aad2e28531979 Mon Sep 17 00:00:00 2001 From: Tomek Kopacki Date: Mon, 30 Mar 2026 12:11:30 +0200 Subject: [PATCH 07/19] Apply suggestions from code review Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- .changeset/sharp-moons-shave.md | 2 +- apps/ensindexer/src/lib/ensrainbow/singleton.ts | 2 +- apps/ensindexer/src/lib/indexing-engines/ponder.ts | 14 ++++++-------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.changeset/sharp-moons-shave.md b/.changeset/sharp-moons-shave.md index 05c70f89b..d5e8ba732 100644 --- a/.changeset/sharp-moons-shave.md +++ b/.changeset/sharp-moons-shave.md @@ -2,4 +2,4 @@ "ensindexer": minor --- -Introduced event handler preconditions to improve resiliency. +Introduced indexing event handler preconditions to optimize the cross-service availability in an ENSNode instance when ENSRainbow is performing a cold-start. diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index 348eed869..9f1b41a3b 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -62,7 +62,7 @@ export function waitForEnsRainbowToBeReady(): Promise { console.error(`ENSRainbow health check failed after multiple attempts: ${errorMessage}`); - // Throw the error to terminate the ENSIndexer process due to failed health check of critical dependency + // 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, }); diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 516b71c67..f25a5db4b 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -100,7 +100,7 @@ const EventTypeIds = { /** * Setup event * - * Driven by code, not by an onchain event. + * Driven by indexing initialization code, not by indexing an onchain event. * * Event handlers for the setup events are fully executed before * any onchain event handlers are executed, so they can be used to set up @@ -134,8 +134,7 @@ function buildEventTypeId(eventName: EventNames): EventTypeId { * for a given event type. * * Some event handlers may have preconditions that need to be met before - * they can run. For example, onchain event handlers depend on ENSRainbow - * instance being ready to serve "heal" requests. + * they can run. */ async function eventHandlerPreconditions(eventName: EventName) { const eventType = buildEventTypeId(eventName); @@ -143,11 +142,10 @@ async function eventHandlerPreconditions(eventName switch (eventType) { case EventTypeIds.Setup: /** - * Setup event handlers should not have any precondition. This is because - * only after all setup handlers have run, the indexing metrics for - * Ponder app are populated for all indexed chains. - * ENSIndexer relies on these indexing metrics immediately on startup to - * store the current Indexing Status in ENSDb. + * Setup event handlers should not have any *blocking* preconditions. This is because + * Ponder populates the indexing metrics for all indexed chains only after all setup handlers have run. + * ENSIndexer relies on these indexing metrics being immediately available on startup to build and + * store the current Indexing Status in ENSDb. */ return; case EventTypeIds.Onchain: { From b9a0829326c598bc3d399ee6e5d75412247b27ea Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 20:44:58 +0200 Subject: [PATCH 08/19] Apply PR feedback --- .../src/lib/ensrainbow/singleton.ts | 9 ++- .../src/lib/indexing-engines/ponder.ts | 76 ++++++++++++++++--- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index 9f1b41a3b..c1909e07a 100644 --- a/apps/ensindexer/src/lib/ensrainbow/singleton.ts +++ b/apps/ensindexer/src/lib/ensrainbow/singleton.ts @@ -49,10 +49,11 @@ export function waitForEnsRainbowToBeReady(): Promise { console.log(`Waiting for ENSRainbow instance to be ready at '${ensRainbowUrl}'...`); waitForEnsRainbowToBeReadyPromise = pRetry(async () => ensRainbowClient.health(), { - retries: 12, // This allows for a total of over 1 hour of retries with the exponential backoff strategy + retries: 12, // This allows for a total of over 1 hour of retries with the exponential backoff strategy. + // 1 + 2 + 4 + ... + 2048 = 2^12 - 1 = 4,095s ≈ 1h 8m onFailedAttempt: ({ error, attemptNumber, retriesLeft }) => { - console.log( - `ENSRainbow health check attempt ${attemptNumber} failed (${error.message}). ${retriesLeft} retries left.`, + console.warn( + `Attempt ${attemptNumber} failed for the ENSRainbow health check at '${ensRainbowUrl}' (${error.message}). ${retriesLeft} retries left.`, ); }, }) @@ -62,7 +63,7 @@ export function waitForEnsRainbowToBeReady(): Promise { console.error(`ENSRainbow health check failed after multiple attempts: ${errorMessage}`); - // Throw the error to terminate the ENSIndexer process due to the failed health check of a critical dependency + // 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, }); diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index f25a5db4b..48611523f 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -100,7 +100,7 @@ const EventTypeIds = { /** * Setup event * - * Driven by indexing initialization code, not by indexing an onchain event. + * Driven by indexing initialization code, not by indexing an onchain event. * * Event handlers for the setup events are fully executed before * any onchain event handlers are executed, so they can be used to set up @@ -129,27 +129,81 @@ function buildEventTypeId(eventName: EventNames): EventTypeId { } } +let preparedIndexingSetup = false; + +/** + * Prepare for executing the "setup" event handlers. + * + * This function is idempotent and will only execute its logic once, even if + * called multiple times. This is to ensure that we affect the "hot path" of + * indexing as little as possible, since this function is called for + * every "setup" event. + */ +function prepareIndexingSetup(): void { + if (preparedIndexingSetup) { + return; + } + + preparedIndexingSetup = true; + + /** + * Setup event handlers should not have any *blocking* preconditions. This is because + * Ponder populates the indexing metrics for all indexed chains only after all setup handlers have run. + * ENSIndexer relies on these indexing metrics being immediately available on startup to build and + * store the current Indexing Status in ENSDb. + */ +} + +let preparedIndexingActivation = false; + +/** + * Prepare for executing the "onchain" event handlers. + * + * This function is idempotent and will only execute its logic once, even if + * called multiple times. This is to ensure that we affect the "hot path" of + * indexing as little as possible, since this function is called for every + * "onchain" event. + * + * @example A single blocking precondition + * ```ts + * await waitForEnsRainbowToBeReady(); + * ``` + * + * @example Multiple blocking preconditions + * ```ts + * await Promise.all([ + * waitForEnsRainbowToBeReady(), + * waitForAnotherPrecondition(), + * ]); + * ``` + */ +async function prepareIndexingActivation() { + if (preparedIndexingActivation) { + return; + } + + preparedIndexingActivation = true; + + await waitForEnsRainbowToBeReady(); +} + /** * Execute any necessary preconditions before running an event handler * for a given event type. * * Some event handlers may have preconditions that need to be met before - * they can run. + * they can run. */ -async function eventHandlerPreconditions(eventName: EventName) { +async function eventHandlerPreconditions( + eventName: EventName, +): Promise { const eventType = buildEventTypeId(eventName); switch (eventType) { case EventTypeIds.Setup: - /** - * Setup event handlers should not have any *blocking* preconditions. This is because - * Ponder populates the indexing metrics for all indexed chains only after all setup handlers have run. - * ENSIndexer relies on these indexing metrics being immediately available on startup to build and - * store the current Indexing Status in ENSDb. - */ - return; + return prepareIndexingSetup(); case EventTypeIds.Onchain: { - return await waitForEnsRainbowToBeReady(); + return prepareIndexingActivation(); } } } From ef53bf854d721dcc2bc6f9a92f7021d83a6fb4bd Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 30 Mar 2026 20:45:16 +0200 Subject: [PATCH 09/19] Update testing suite --- .../src/lib/indexing-engines/ponder.test.ts | 411 ++++++++++-------- 1 file changed, 234 insertions(+), 177 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 3919b703a..8de3cee43 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts @@ -1,11 +1,7 @@ import type { Context, EventNames } from "ponder:registry"; import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; -import { - addOnchainEventListener, - type IndexingEngineContext, - type IndexingEngineEvent, -} from "./ponder"; +import type { IndexingEngineContext, IndexingEngineEvent } from "./ponder"; const { mockPonderOn } = vi.hoisted(() => ({ mockPonderOn: vi.fn() })); @@ -26,76 +22,118 @@ vi.mock("@/lib/ensrainbow/singleton", () => ({ })); describe("addOnchainEventListener", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); mockWaitForEnsRainbow.mockResolvedValue(undefined); + // Reset module state to test idempotent behavior correctly + vi.resetModules(); }); - describe("registration", () => { - it("registers the handler with the correct event name", () => { - const testHandler = vi.fn(); + // Helper to get fresh module reference after resetModules() + async function getPonderModule() { + return await import("./ponder"); + } - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + // Helper to extract the callback registered with ponder.on + function getRegisteredCallback( + callIndex = 0, + ): (args: { + context: Context; + event: IndexingEngineEvent; + }) => Promise { + return mockPonderOn.mock.calls[callIndex]![1] as ReturnType; + } + + 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); expect(mockPonderOn).toHaveBeenCalledWith("Resolver:AddrChanged", expect.any(Function)); }); - it("returns the result from ponder.on", () => { - const mockReturnValue = { unsubscribe: vi.fn() }; - mockPonderOn.mockReturnValue(mockReturnValue); - const testHandler = vi.fn(); + it("returns the subscription object from ponder.on", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const mockSubscription = { unsubscribe: vi.fn() }; + mockPonderOn.mockReturnValue(mockSubscription); + const handler = vi.fn(); - const result = addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + const result = addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); - expect(result).toBe(mockReturnValue); + expect(result).toBe(mockSubscription); }); }); describe("context transformation", () => { - it("adds ensDb property referencing the same object as db", async () => { - const testHandler = vi.fn(); + 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; - const mockEvent = {} as IndexingEngineEvent; - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + await getRegisteredCallback()({ + context: mockContext, + event: {} as IndexingEngineEvent, + }); - const callArg = testHandler.mock.calls[0]?.[0]; - expect(callArg?.context.ensDb).toBe(callArg?.context.db); + const receivedContext = handler.mock.calls[0]![0].context; + expect(receivedContext.ensDb).toBe(mockDb); + expect(receivedContext.ensDb).toBe(receivedContext.db); }); - it("preserves all other context properties", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); + 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: { a: "0x123" } } as unknown as IndexingEngineEvent; - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + const mockEvent = { args: { node: "0x123" } } as unknown as IndexingEngineEvent; - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + await getRegisteredCallback()({ context: mockContext, event: mockEvent }); - expect(testHandler).toHaveBeenCalledWith({ + expect(handler).toHaveBeenCalledWith({ context: expect.objectContaining({ db: mockDb, ensDb: mockDb, chain: { id: 1 }, block: { number: 100n }, + client: expect.any(Object), }), event: mockEvent, }); }); }); - describe("event handling", () => { - it("supports multiple event names independently", async () => { + 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, + }); + + 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); @@ -104,220 +142,234 @@ describe("addOnchainEventListener", () => { expect(mockPonderOn).toHaveBeenCalledTimes(2); - const [, callback1] = mockPonderOn.mock.calls[0]!; - const mockDb1 = vi.fn(); - const event1 = {} as IndexingEngineEvent; - await callback1({ - context: { db: mockDb1 } as unknown as Context, - event: event1, + // Trigger first handler + await getRegisteredCallback(0)({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, }); - expect(handler1).toHaveBeenCalledTimes(1); - expect(handler2).toHaveBeenCalledTimes(0); - - const [, callback2] = mockPonderOn.mock.calls[1]!; - const mockDb2 = vi.fn(); - const event2 = {} as IndexingEngineEvent; - await callback2({ - context: { db: mockDb2 } as unknown as Context, - event: event2, - }); - - expect(handler2).toHaveBeenCalledTimes(1); - }); - - it("passes the event argument through to the handler", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockEvent = { - args: { node: "0x123", label: "0x456" }, - } as unknown as IndexingEngineEvent; - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + expect(handler2).not.toHaveBeenCalled(); - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ - context: { db: mockDb } as unknown as Context, - event: mockEvent, + // Trigger second handler + await getRegisteredCallback(1)({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, }); - - expect(testHandler).toHaveBeenCalledWith(expect.objectContaining({ event: mockEvent })); + expect(handler2).toHaveBeenCalledTimes(1); }); }); describe("handler types", () => { - it("handles async handlers", async () => { + it("supports async handlers", async () => { + const { addOnchainEventListener } = await getPonderModule(); const asyncHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; addOnchainEventListener("Resolver:AddrChanged" as EventNames, asyncHandler); - - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); expect(asyncHandler).toHaveBeenCalled(); }); - it("handles sync handlers", async () => { + it("supports sync handlers", async () => { + const { addOnchainEventListener } = await getPonderModule(); const syncHandler = vi.fn(); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; addOnchainEventListener("Resolver:AddrChanged" as EventNames, syncHandler); - - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); expect(syncHandler).toHaveBeenCalled(); }); }); - describe("error handling", () => { - it("propagates errors from sync handlers", async () => { - const error = new Error("Handler failed"); + describe("error propagation", () => { + it("re-throws errors from sync handlers", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const error = new Error("Sync handler failed"); const failingHandler = vi.fn(() => { throw error; }); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; addOnchainEventListener("Resolver:AddrChanged" as EventNames, failingHandler); - const [, callback] = mockPonderOn.mock.calls[0]!; - await expect(callback({ context: mockContext, event: mockEvent })).rejects.toThrow( - "Handler failed", - ); + await expect( + getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + ).rejects.toThrow("Sync handler failed"); }); - it("propagates errors from async handlers", async () => { + it("re-throws errors from async handlers", async () => { + const { addOnchainEventListener } = await getPonderModule(); const error = new Error("Async handler failed"); const failingHandler = vi.fn().mockRejectedValue(error); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; addOnchainEventListener("Resolver:AddrChanged" as EventNames, failingHandler); - const [, callback] = mockPonderOn.mock.calls[0]!; - await expect(callback({ context: mockContext, event: mockEvent })).rejects.toThrow( - "Async handler failed", - ); + await expect( + getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + ).rejects.toThrow("Async handler failed"); }); }); - describe("preconditions", () => { - it("waits for ENSRainbow to be ready before executing onchain event handlers", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + describe("ENSRainbow preconditions (onchain events)", () => { + it("waits for ENSRainbow before executing the handler", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn().mockResolvedValue(undefined); - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); - expect(mockWaitForEnsRainbow).toHaveBeenCalled(); - expect(testHandler).toHaveBeenCalled(); + expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalled(); }); - it("does not execute handler if ENSRainbow precondition fails", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; - const preconditionError = new Error("ENSRainbow not ready"); + it("prevents handler execution if ENSRainbow is not ready", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn().mockResolvedValue(undefined); + mockWaitForEnsRainbow.mockRejectedValue(new Error("ENSRainbow not ready")); - mockWaitForEnsRainbow.mockRejectedValue(preconditionError); + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); + await expect( + getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }), + ).rejects.toThrow("ENSRainbow not ready"); - const [, callback] = mockPonderOn.mock.calls[0]!; - await expect(callback({ context: mockContext, event: mockEvent })).rejects.toThrow( - "ENSRainbow not ready", - ); - expect(testHandler).not.toHaveBeenCalled(); + expect(handler).not.toHaveBeenCalled(); }); - it("does not throw error for setup events - they have no preconditions", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; + 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); - addOnchainEventListener("Registry:setup" as EventNames, testHandler); + // Register two different onchain event listeners + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler1); + addOnchainEventListener("Registry:Transfer" as EventNames, handler2); - const [, callback] = mockPonderOn.mock.calls[0]!; - // Should not throw and should call handler without waiting for ENSRainbow - await expect(callback({ context: mockContext, event: mockEvent })).resolves.toBeUndefined(); - expect(testHandler).toHaveBeenCalled(); - expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); - }); + // Trigger the first event handler + await getRegisteredCallback(0)({ + context: { db: vi.fn() } as unknown as Context, + event: { args: { a: "1" } } as unknown as IndexingEngineEvent, + }); + expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); - it("resolves ENSRainbow precondition before calling handler", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; + // Trigger the second event handler + await getRegisteredCallback(1)({ + context: { db: vi.fn() } 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("resolves ENSRainbow before calling the handler", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn().mockResolvedValue(undefined); let preconditionResolved = false; + mockWaitForEnsRainbow.mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); preconditionResolved = true; }); - addOnchainEventListener("Resolver:AddrChanged" as EventNames, testHandler); - - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); expect(preconditionResolved).toBe(true); - expect(testHandler).toHaveBeenCalled(); + expect(handler).toHaveBeenCalled(); }); }); - describe("event type detection", () => { - it("correctly identifies onchain events by name", async () => { - const onchainHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; + describe("setup events (no preconditions)", () => { + it("skips ENSRainbow wait for :setup events", async () => { + const { addOnchainEventListener } = await getPonderModule(); + const handler = vi.fn().mockResolvedValue(undefined); - addOnchainEventListener("ETHRegistry:NewOwner" as EventNames, onchainHandler); + addOnchainEventListener("Registry:setup" as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalled(); + }); - expect(mockWaitForEnsRainbow).toHaveBeenCalled(); - expect(onchainHandler).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", + "ETHRegistrarController:setup", + ]; + + for (const eventName of setupEvents) { + vi.clearAllMocks(); + handler.mockClear(); + + addOnchainEventListener(eventName as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + + expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalled(); + } }); + }); - it("correctly identifies setup events by :setup suffix and skips preconditions", async () => { + describe("event type detection", () => { + it("treats :setup suffix as setup event type", async () => { + const { addOnchainEventListener } = await getPonderModule(); const setupHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; + const onchainHandler = vi.fn().mockResolvedValue(undefined); addOnchainEventListener("PublicResolver:setup" as EventNames, setupHandler); + addOnchainEventListener("PublicResolver:AddrChanged" as EventNames, onchainHandler); - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); - - // Setup events should not call ENSRainbow preconditions + // Setup event - no ENSRainbow wait + await getRegisteredCallback(0)({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); expect(mockWaitForEnsRainbow).not.toHaveBeenCalled(); expect(setupHandler).toHaveBeenCalled(); - }); - it("handles various onchain event name formats", async () => { - const testHandler = vi.fn().mockResolvedValue(undefined); - const mockDb = vi.fn(); - const mockContext = { db: mockDb } as unknown as Context; - const mockEvent = {} as IndexingEngineEvent; + // Onchain event - ENSRainbow wait required + await getRegisteredCallback(1)({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); + expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); + expect(onchainHandler).toHaveBeenCalled(); + }); + it("treats all non-:setup events as onchain events", async () => { + const handler = vi.fn().mockResolvedValue(undefined); const onchainEvents = [ "Resolver:AddrChanged", "Registry:Transfer", @@ -327,13 +379,18 @@ describe("addOnchainEventListener", () => { for (const eventName of onchainEvents) { vi.clearAllMocks(); - addOnchainEventListener(eventName as EventNames, testHandler); + vi.resetModules(); + const { addOnchainEventListener: freshAddOnchainEventListener } = await getPonderModule(); + handler.mockClear(); - const [, callback] = mockPonderOn.mock.calls[0]!; - await callback({ context: mockContext, event: mockEvent }); + freshAddOnchainEventListener(eventName as EventNames, handler); + await getRegisteredCallback()({ + context: { db: vi.fn() } as unknown as Context, + event: {} as IndexingEngineEvent, + }); expect(mockWaitForEnsRainbow).toHaveBeenCalled(); - expect(testHandler).toHaveBeenCalled(); + expect(handler).toHaveBeenCalled(); } }); }); From d51b2ea74e441fb34d19dd4336d3f4e9985ab9f9 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 31 Mar 2026 12:12:31 +0200 Subject: [PATCH 10/19] Update testing suite --- .../src/lib/indexing-engines/ponder.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 8de3cee43..28d13674f 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts @@ -281,6 +281,51 @@ describe("addOnchainEventListener", () => { 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); From e60b06f69fe9e1513427fd64d65e5ff229b34998 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 31 Mar 2026 12:18:38 +0200 Subject: [PATCH 11/19] Update ENSDb SDK to allow storing and reading `EnsRainbowPublicConfig` object This enable storing the public config of the connected ENSRainbow instance in ENSDb. The ENSDb record will be used to read the public config for ENSRainbow instance from other ENSNode apps. --- .../ensdb-sdk/src/client/ensdb-reader.test.ts | 13 +++++++++++++ packages/ensdb-sdk/src/client/ensdb-reader.ts | 19 +++++++++++++++++++ .../ensdb-sdk/src/client/ensdb-writer.test.ts | 14 ++++++++++++++ packages/ensdb-sdk/src/client/ensdb-writer.ts | 15 +++++++++++++++ .../ensdb-sdk/src/client/ensnode-metadata.ts | 8 ++++++++ .../src/client/serialize/ensnode-metadata.ts | 6 ++++++ 6 files changed, 75 insertions(+) diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.test.ts b/packages/ensdb-sdk/src/client/ensdb-reader.test.ts index 3a488c4a8..850693cea 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 ac6b0b72e..2ff6ae6a3 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"; /** @@ -156,6 +158,23 @@ export class EnsDbReader< return deserializeEnsIndexerPublicConfig(record); } + /** + * Get ENSRainbow Public Config + * + * @returns the existing record, or `undefined`. + */ + async getEnsRainbowPublicConfig(): Promise { + const record = await this.getEnsNodeMetadata({ + key: EnsNodeMetadataKeys.EnsRainbowPublicConfig, + }); + + if (!record) { + return undefined; + } + + return record; + } + /** * Get Indexing Status Snapshot * diff --git a/packages/ensdb-sdk/src/client/ensdb-writer.test.ts b/packages/ensdb-sdk/src/client/ensdb-writer.test.ts index 51e7ccf75..5111aac0a 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 2ebbd9e08..e8c0b1ff5 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 bdb35c406..615435236 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 cae7fcdd3..a9c4c6e3d 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,10 @@ export interface SerializedEnsNodeMetadataEnsIndexerPublicConfig { value: SerializedEnsIndexerPublicConfig; } +/** + * Serialized representation of {@link EnsNodeMetadataEnsRainbowPublicConfig}. + */ +export type SerializedEnsNodeMetadataEnsRainbowPublicConfig = EnsNodeMetadataEnsRainbowPublicConfig; /** * Serialized representation of {@link EnsNodeMetadataEnsIndexerIndexingStatus}. */ @@ -38,4 +43,5 @@ export interface SerializedEnsNodeMetadataEnsIndexerIndexingStatus { export type SerializedEnsNodeMetadata = | SerializedEnsNodeMetadataEnsDbVersion | SerializedEnsNodeMetadataEnsIndexerPublicConfig + | SerializedEnsNodeMetadataEnsRainbowPublicConfig | SerializedEnsNodeMetadataEnsIndexerIndexingStatus; From f9296187825e65ba03c92a94b5beb5dd709d2c7e Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 31 Mar 2026 12:20:40 +0200 Subject: [PATCH 12/19] docs(changeset): Extended the ENSNode Metadata with ENSRainbow Public Config. --- .changeset/whole-lines-smoke.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/whole-lines-smoke.md diff --git a/.changeset/whole-lines-smoke.md b/.changeset/whole-lines-smoke.md new file mode 100644 index 000000000..0209735b3 --- /dev/null +++ b/.changeset/whole-lines-smoke.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensdb-sdk": minor +--- + +Extended the ENSNode Metadata with ENSRainbow Public Config. From 63fbec59a8a9fa3d6eb62542f8e163de0929c064 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 31 Mar 2026 14:28:07 +0200 Subject: [PATCH 13/19] Enable ENSDb Writer Worker to store "unstarted" Indexing Status object --- .../ensdb-writer-worker.test.ts | 24 ++++++++++++++---- .../ensdb-writer-worker.ts | 25 ++----------------- 2 files changed, 21 insertions(+), 28 deletions(-) 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 f55d75247..629f41b26 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 @@ -261,7 +261,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, @@ -269,13 +269,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 = { @@ -297,8 +304,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 1645d196a..a66063572 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"; @@ -231,7 +229,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, @@ -248,24 +247,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; - } } From fb1d93ebf0d405abdd7bad481572443708fb154a Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 31 Mar 2026 16:12:16 +0200 Subject: [PATCH 14/19] Require valid ENSRainbow connection before starting indexing --- .../src/lib/indexing-engines/ponder.ts | 28 ++- .../valid-ensrainbow-connection.ts | 181 ++++++++++++++++++ 2 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 apps/ensindexer/src/lib/indexing-engines/preconditions/valid-ensrainbow-connection.ts diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 48611523f..17ed41b38 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 @@ -164,18 +164,27 @@ let preparedIndexingActivation = false; * indexing as little as possible, since this function is called for every * "onchain" event. * + * @throws If valid ENSRainbow connection could not be established after + * multiple attempts. + * * @example A single blocking precondition * ```ts * await waitForEnsRainbowToBeReady(); * ``` * - * @example Multiple blocking preconditions + * @example Multiple concurrent blocking preconditions * ```ts * await Promise.all([ * waitForEnsRainbowToBeReady(), * waitForAnotherPrecondition(), * ]); * ``` + * + * @example Multiple sequential blocking preconditions + * ```ts + * await waitForEnsRainbowToBeReady(); + * await waitForAnotherPrecondition(); + * ``` */ async function prepareIndexingActivation() { if (preparedIndexingActivation) { @@ -184,7 +193,20 @@ async function prepareIndexingActivation() { preparedIndexingActivation = true; - await waitForEnsRainbowToBeReady(); + try { + await ensureValidEnsRainbowConnection(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + + console.error( + `[Ponder Indexing Engine]: Failed to establish a valid connection to ENSRainbow: ${errorMessage}`, + ); + + // Throw the error to terminate the ENSIndexer process due to failed connection to critical dependency + throw new Error(errorMessage, { + cause: error, + }); + } } /** 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 000000000..dcdc8832a --- /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 + * lower 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 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."); +} From c3967b96bbe7caa64b5f071b83a1a50c7b3d466a Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 31 Mar 2026 16:22:22 +0200 Subject: [PATCH 15/19] Update testing suite --- .../src/lib/indexing-engines/ponder.test.ts | 607 +++++++++++++----- 1 file changed, 462 insertions(+), 145 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 28d13674f..0f27d0419 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,325 @@ 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 () => { + it("calls waitForEnsRainbowToBeReady only once when two onchain callbacks fire concurrently", async () => { const { addOnchainEventListener } = await getPonderModule(); - const handler1 = vi.fn().mockResolvedValue(undefined); - const handler2 = vi.fn().mockResolvedValue(undefined); + const handler1 = createHandler(); + const handler2 = createHandler(); let resolveReadiness: (() => void) | undefined; + let getConfigCallCount = 0; - // Create a promise that won't resolve until we manually trigger it + mockGetEnsRainbowPublicConfig.mockImplementation(() => { + getConfigCallCount++; + return new Promise((resolve) => setTimeout(() => resolve(undefined), 50)); + }); + mockGetIndexingStatusSnapshot.mockResolvedValue(createUnstartedIndexingStatus()); 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, + context: { db: createMockDb() } as unknown as Context, event: { args: { a: "1" } } as unknown as IndexingEngineEvent, }); const promise2 = 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 only have been called once despite concurrent execution + expect(getConfigCallCount).toBe(1); + await new Promise((resolve) => setTimeout(resolve, 100)); 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 +579,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 +636,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 +652,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 +661,101 @@ 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).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Original connection error"); + expect((error as Error).cause).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", () => { From f346b2a71e38d11a29679a30b9e0f565ea9022c8 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 1 Apr 2026 05:22:59 +0200 Subject: [PATCH 16/19] Apply AI PR feedback --- .../src/lib/indexing-engines/ponder.test.ts | 42 ------------------- .../src/lib/indexing-engines/ponder.ts | 6 +-- .../valid-ensrainbow-connection.ts | 6 +-- .../src/client/serialize/ensnode-metadata.ts | 1 + 4 files changed, 7 insertions(+), 48 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts index 0f27d0419..db0efddaa 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.test.ts @@ -463,48 +463,6 @@ describe("addOnchainEventListener", () => { expect(handler2).toHaveBeenCalledTimes(1); }); - it("calls waitForEnsRainbowToBeReady only once when two onchain callbacks fire concurrently", async () => { - const { addOnchainEventListener } = await getPonderModule(); - const handler1 = createHandler(); - const handler2 = createHandler(); - let resolveReadiness: (() => void) | undefined; - let getConfigCallCount = 0; - - mockGetEnsRainbowPublicConfig.mockImplementation(() => { - getConfigCallCount++; - return new Promise((resolve) => setTimeout(() => resolve(undefined), 50)); - }); - mockGetIndexingStatusSnapshot.mockResolvedValue(createUnstartedIndexingStatus()); - mockWaitForEnsRainbow.mockImplementation(() => { - return new Promise((resolve) => { - resolveReadiness = resolve; - }); - }); - - addOnchainEventListener("Resolver:AddrChanged" as EventNames, handler1); - addOnchainEventListener("Registry:Transfer" as EventNames, handler2); - - const promise1 = getRegisteredCallback(0)({ - context: { db: createMockDb() } as unknown as Context, - event: { args: { a: "1" } } as unknown as IndexingEngineEvent, - }); - const promise2 = getRegisteredCallback(1)({ - context: { db: createMockDb() } as unknown as Context, - event: { args: { a: "2" } } as unknown as IndexingEngineEvent, - }); - - expect(getConfigCallCount).toBe(1); - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(mockWaitForEnsRainbow).toHaveBeenCalledTimes(1); - expect(handler1).not.toHaveBeenCalled(); - - resolveReadiness!(); - await Promise.all([promise1, promise2]); - - expect(handler1).toHaveBeenCalledTimes(1); - expect(handler2).toHaveBeenCalledTimes(1); - }); - it("resolves ENSRainbow before calling the handler", async () => { let preconditionResolved = false; diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 17ed41b38..61709b493 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -169,20 +169,20 @@ let preparedIndexingActivation = false; * * @example A single blocking precondition * ```ts - * await waitForEnsRainbowToBeReady(); + * await ensureValidEnsRainbowConnection(); * ``` * * @example Multiple concurrent blocking preconditions * ```ts * await Promise.all([ - * waitForEnsRainbowToBeReady(), + * ensureValidEnsRainbowConnection(), * waitForAnotherPrecondition(), * ]); * ``` * * @example Multiple sequential blocking preconditions * ```ts - * await waitForEnsRainbowToBeReady(); + * await ensureValidEnsRainbowConnection(); * await waitForAnotherPrecondition(); * ``` */ 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 index dcdc8832a..c9f9505e7 100644 --- a/apps/ensindexer/src/lib/indexing-engines/preconditions/valid-ensrainbow-connection.ts +++ b/apps/ensindexer/src/lib/indexing-engines/preconditions/valid-ensrainbow-connection.ts @@ -49,8 +49,8 @@ function invariant_labelSetIdCompatibility( /** * The highest label set version in the fetched ENSRainbow public config must be - * lower than or equal to the highest label set version in the stored ENSRainbow - * public config. + * 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 @@ -172,7 +172,7 @@ export async function ensureValidEnsRainbowConnection(): Promise { await waitForEnsRainbowToBeReady(); - // Once the connected ENSRainbow instance is ready, we we can try + // 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); diff --git a/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts b/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts index a9c4c6e3d..989756ed3 100644 --- a/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts +++ b/packages/ensdb-sdk/src/client/serialize/ensnode-metadata.ts @@ -29,6 +29,7 @@ export interface SerializedEnsNodeMetadataEnsIndexerPublicConfig { * Serialized representation of {@link EnsNodeMetadataEnsRainbowPublicConfig}. */ export type SerializedEnsNodeMetadataEnsRainbowPublicConfig = EnsNodeMetadataEnsRainbowPublicConfig; + /** * Serialized representation of {@link EnsNodeMetadataEnsIndexerIndexingStatus}. */ From 4e2454b3486e41dab536dfef16b2ea6b2199eae0 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 1 Apr 2026 05:42:27 +0200 Subject: [PATCH 17/19] Improve docs for Ponder Indexing Engine Also, allow for async preconditions for the indexing setup events. This will be handy for ensuring certain pg extensions were enabled before indexing starts. --- .../src/lib/indexing-engines/ponder.ts | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/apps/ensindexer/src/lib/indexing-engines/ponder.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 61709b493..60ac09cc5 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -134,24 +134,27 @@ let preparedIndexingSetup = false; /** * Prepare for executing the "setup" event handlers. * - * This function is idempotent and will only execute its logic once, even if - * called multiple times. This is to ensure that we affect the "hot path" of - * indexing as little as possible, since this function is called for - * every "setup" event. + * This function is guaranteed to be executed just once before + * the first "setup" event handler is executed. + * This is to ensure that we affect the "hot path" of + * indexing as little as possible, since this function is + * called as part of preconditions for every "setup" event. + * + * Note that this functions should not have any long-running operations. + * That would delay the population of Ponder Indexing Metrics for + * all indexed chains. Ponder Indexing Metrics are populated only after + * all setup handlers have completed. ENSIndexer relies on + * Ponder Indexing Metrics being immediately available on startup to + * build and store the current Indexing Status snapshot in ENSDb. */ -function prepareIndexingSetup(): void { +async function prepareIndexingSetup(): Promise { if (preparedIndexingSetup) { return; } preparedIndexingSetup = true; - /** - * Setup event handlers should not have any *blocking* preconditions. This is because - * Ponder populates the indexing metrics for all indexed chains only after all setup handlers have run. - * ENSIndexer relies on these indexing metrics being immediately available on startup to build and - * store the current Indexing Status in ENSDb. - */ + // Currently, we don't have any indexing setup preconditions. } let preparedIndexingActivation = false; @@ -159,10 +162,11 @@ let preparedIndexingActivation = false; /** * Prepare for executing the "onchain" event handlers. * - * This function is idempotent and will only execute its logic once, even if - * called multiple times. This is to ensure that we affect the "hot path" of - * indexing as little as possible, since this function is called for every - * "onchain" event. + * This function is guaranteed to be executed just once before + * the first "onchain" event handler is executed. + * This is to ensure that we affect the "hot path" of + * indexing as little as possible, since this function is + * called as part of preconditions for every "onchain" event. * * @throws If valid ENSRainbow connection could not be established after * multiple attempts. @@ -245,12 +249,12 @@ export function addOnchainEventListener( eventName: EventName, eventHandler: (args: IndexingEngineEventHandlerArgs) => Promise | void, ) { - return ponder.on(eventName, async ({ context, event }) => - eventHandlerPreconditions(eventName).then(() => - eventHandler({ - context: buildIndexingEngineContext(context), - event, - }), - ), - ); + return ponder.on(eventName, async ({ context, event }) => { + await eventHandlerPreconditions(eventName); + + await eventHandler({ + context: buildIndexingEngineContext(context), + event, + }); + }); } From 3d7bfb374fc7ea87bc15376cf279c943e1979149 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Thu, 2 Apr 2026 21:23:58 +0200 Subject: [PATCH 18/19] Apply PR feedback --- .../src/lib/ensrainbow/singleton.ts | 27 ++++++++++--------- .../src/lib/indexing-engines/ponder.ts | 5 ++-- .../valid-ensrainbow-connection.ts | 2 +- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/ensindexer/src/lib/ensrainbow/singleton.ts b/apps/ensindexer/src/lib/ensrainbow/singleton.ts index 5a61909b7..f2ae52bd8 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"; const { ensRainbowUrl, labelSet } = config; @@ -50,18 +51,22 @@ export function waitForEnsRainbowToBeReady(): Promise { console.log(`Waiting for ENSRainbow instance to be ready at '${ensRainbowUrl}'...`); + const retryInterval: Duration = 5; + const retryIntervalMs = secondsToMilliseconds(retryInterval); + const retriesPerMinute = 60 / retryInterval; + waitForEnsRainbowToBeReadyPromise = pRetry(async () => ensRainbowClient.health(), { - retries: 12 * 60, // This allows for a total of over 1 hour of retries with 1 minute between attempts. - minTimeout: secondsToMilliseconds(5), - maxTimeout: secondsToMilliseconds(5), + 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 }) => { - // Log every 12 attempts (i.e. every minute) to avoid excessive logging + // Log once every minute to avoid excessive logging during ENSRainbow cold start, // while still providing visibility into the retry process. - if (attemptNumber % 12 !== 0) return; - - console.warn( - `Attempt ${attemptNumber} failed for the ENSRainbow health check at '${ensRainbowUrl}' (${error.message}). ${retriesLeft} retries left. This might be due to ENSRainbow having a cold start, which can take 30+ minutes.`, - ); + if (attemptNumber % 12 === 0) { + console.warn( + `Attempt ${attemptNumber} failed for the ENSRainbow health check at '${ensRainbowUrl}' (${error.message}). ${retriesLeft} retries left. This might be due to ENSRainbow having a cold start, which can take 30+ minutes.`, + ); + } }, }) .then(() => console.log(`ENSRainbow instance is ready at '${ensRainbowUrl}'.`)) @@ -71,9 +76,7 @@ export function waitForEnsRainbowToBeReady(): Promise { console.error(`ENSRainbow health check failed after multiple attempts: ${errorMessage}`); // 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.ts b/apps/ensindexer/src/lib/indexing-engines/ponder.ts index 10b7e5770..e63095399 100644 --- a/apps/ensindexer/src/lib/indexing-engines/ponder.ts +++ b/apps/ensindexer/src/lib/indexing-engines/ponder.ts @@ -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; 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 index c9f9505e7..92d2fd0e9 100644 --- a/apps/ensindexer/src/lib/indexing-engines/preconditions/valid-ensrainbow-connection.ts +++ b/apps/ensindexer/src/lib/indexing-engines/preconditions/valid-ensrainbow-connection.ts @@ -133,7 +133,7 @@ export async function ensureValidEnsRainbowConnection(): Promise { */ if (!storedConfig) { console.log( - 'No stored ENSRainbow Public Config found in ENSDb. Validating the omnichain indexing status is "Unstarted"...', + "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 From 4a80427da9b77c5bfea3faca87f7aac0952466e8 Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 3 Apr 2026 12:10:37 -0500 Subject: [PATCH 19/19] fix: tidy up ensdb reader fns --- packages/ensdb-sdk/src/client/ensdb-reader.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/ensdb-sdk/src/client/ensdb-reader.ts b/packages/ensdb-sdk/src/client/ensdb-reader.ts index 2ff6ae6a3..ea4a878eb 100644 --- a/packages/ensdb-sdk/src/client/ensdb-reader.ts +++ b/packages/ensdb-sdk/src/client/ensdb-reader.ts @@ -151,9 +151,7 @@ export class EnsDbReader< key: EnsNodeMetadataKeys.EnsIndexerPublicConfig, }); - if (!record) { - return undefined; - } + if (!record) return undefined; return deserializeEnsIndexerPublicConfig(record); } @@ -168,10 +166,6 @@ export class EnsDbReader< key: EnsNodeMetadataKeys.EnsRainbowPublicConfig, }); - if (!record) { - return undefined; - } - return record; } @@ -187,9 +181,7 @@ export class EnsDbReader< }, ); - if (!record) { - return undefined; - } + if (!record) return undefined; return deserializeCrossChainIndexingStatusSnapshot(record); } @@ -215,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"];