Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions apps/ensindexer/ponder/src/api/handlers/ensnode-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import config from "@/config";

import { publicClients } from "ponder:api";
import { getUnixTime } from "date-fns";
import { Hono } from "hono";

Expand All @@ -15,10 +14,13 @@ import {
} from "@ensnode/ensnode-sdk";

import { buildENSIndexerPublicConfig } from "@/config/public";
import { createCrossChainIndexingStatusSnapshotOmnichain } from "@/lib/indexing-status/build-index-status";
import { buildOmnichainIndexingStatusSnapshot } from "@/lib/indexing-status-builder/omnichain-indexing-status-snapshot";
import {
buildOmnichainIndexingStatusSnapshot,
createCrossChainIndexingStatusSnapshotOmnichain,
} from "@/lib/indexing-status/build-index-status";
cachedChainsBlockRefs,
indexedChainIds,
ponderClient,
} from "@/ponder/api/lib/local-ponder-client";

const app = new Hono();

Expand All @@ -38,23 +40,33 @@ app.get("/indexing-status", async (c) => {
let omnichainSnapshot: OmnichainIndexingStatusSnapshot | undefined;

try {
omnichainSnapshot = await buildOmnichainIndexingStatusSnapshot(publicClients);
const [ponderIndexingMetrics, ponderIndexingStatus] = await Promise.all([
ponderClient.metrics(),
ponderClient.status(),
]);

omnichainSnapshot = buildOmnichainIndexingStatusSnapshot(
indexedChainIds,
cachedChainsBlockRefs,
ponderIndexingMetrics,
ponderIndexingStatus,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
console.error(`Omnichain snapshot is currently not available: ${errorMessage}`);
console.error(
`Indexing Status is currently not available. Failed to fetch Omnichain Indexing Status snapshot: ${errorMessage}`,
);
}

// return IndexingStatusResponseError
if (typeof omnichainSnapshot === "undefined") {
if (!omnichainSnapshot) {
return c.json(
serializeIndexingStatusResponse({
responseCode: IndexingStatusResponseCodes.Error,
} satisfies IndexingStatusResponseError),
500,
);
}

// otherwise, proceed with creating IndexingStatusResponseOk
const crossChainSnapshot = createCrossChainIndexingStatusSnapshotOmnichain(
omnichainSnapshot,
snapshotTime,
Expand Down
171 changes: 171 additions & 0 deletions apps/ensindexer/ponder/src/api/lib/chains-config-blockrange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* This file is about parsing the object that is exported by `ponder.config.ts`.
*
* Each Ponder datasource defined in the aforementioned Ponder Config object
* can include information about startBlock and endBlock. This is to let
* Ponder know which blockrange to index for a particular Ponder Datasource.
*
* ENSIndexer, however, needs a blockrange for each indexed chain. This is why
* we examine Ponder Config object, looking for the "lowest" startBlock, and
* the "highest" endBlock defined for each of the indexed chains.
*/

import type { AddressConfig, ChainConfig, CreateConfigReturnType } from "ponder";

import {
type BlockNumber,
type Blockrange,
type ChainId,
type ChainIdString,
deserializeBlockNumber,
deserializeBlockrange,
deserializeChainId,
} from "@ensnode/ensnode-sdk";

/**
Copy link
Contributor

@vercel vercel bot Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code duplication in Ponder config parsing logic across two files creates maintenance burden and risk of divergence

Fix on Vercel

* Ponder config datasource with a flat `chain` value.
*/
export type PonderConfigDatasourceFlat = {
chain: ChainIdString;
} & AddressConfig &
Blockrange;

/**
* Ponder config datasource with a nested `chain` value.
*/
export type PonderConfigDatasourceNested = {
chain: Record<ChainIdString, AddressConfig & Blockrange>;
};

/**
* Ponder config datasource
*/
export type PonderConfigDatasource = PonderConfigDatasourceFlat | PonderConfigDatasourceNested;

/**
* Ponder config datasource
*/
type PonderConfigDatasources = {
[datasourceId: string]: PonderConfigDatasource;
};

/**
* Ponder chains config
*
* Chain config for each indexed chain.
*/
type PonderConfigChains = {
[chainId: ChainIdString]: ChainConfig;
};

/**
* Ponder Config
*
* A utility type describing Ponder Config.
*/
export type PonderConfigType = CreateConfigReturnType<
PonderConfigChains,
PonderConfigDatasources,
PonderConfigDatasources,
PonderConfigDatasources
>;

/**
* Ensure the `ponderDatasource` is {@link PonderConfigDatasourceFlat}.
*/
function isPonderDatasourceFlat(
ponderDatasource: PonderConfigDatasource,
): ponderDatasource is PonderConfigDatasourceFlat {
return typeof ponderDatasource.chain === "string";
}

/**
* Ensure the `ponderDatasource` is {@link PonderConfigDatasourceNested}.
*/
function isPonderDatasourceNested(
ponderDatasource: PonderConfigDatasource,
): ponderDatasource is PonderConfigDatasourceNested {
return typeof ponderDatasource.chain === "object";
}

/**
* Build {@link Blockrange} for each indexed chain.
*
* Invariants:
* - every chain include a startBlock,
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar in the invariants list: “every chain include a startBlock” should be “every chain includes a startBlock”.

Suggested change
* - every chain include a startBlock,
* - every chain includes a startBlock,

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@vercel vercel bot Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar error in comment: "every chain include a startBlock" should use singular verb "includes"

Fix on Vercel

* - some chains may include an endBlock,
* - all present startBlock and endBlock values are valid {@link BlockNumber} values.
*/
export function buildChainsBlockrange(ponderConfig: PonderConfigType): Map<ChainId, Blockrange> {
const chainsBlockrange = new Map<ChainId, Blockrange>();

Comment on lines +99 to +101
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildChainsBlockrange introduces non-trivial parsing/invariant logic (flat vs nested datasources, min start block, conditional end block). There are existing Vitest suites for indexing-status/ponder-metadata; adding focused unit tests for this function (including mixed flat/nested configs and partial endBlock definitions) would help prevent regressions.

Copilot uses AI. Check for mistakes.
// 0. Get all ponder sources (includes chain + startBlock & endBlock)
const ponderSources = [
...Object.values(ponderConfig.accounts ?? {}),
...Object.values(ponderConfig.blocks ?? {}),
...Object.values(ponderConfig.contracts ?? {}),
] as PonderConfigDatasource[];

// 1. For every indexed chain
for (const serializedChainId of Object.keys(ponderConfig.chains)) {
const chainStartBlocks: BlockNumber[] = [];
const chainEndBlocks: BlockNumber[] = [];

// 1.1. For every Ponder source (accounts, blocks, contracts),
// extract startBlock number (required) and endBlock number (optional).
for (const ponderSource of ponderSources) {
let startBlock: Blockrange["startBlock"];
let endBlock: Blockrange["endBlock"];

if (isPonderDatasourceFlat(ponderSource) && ponderSource.chain === serializedChainId) {
startBlock = ponderSource.startBlock;
endBlock = ponderSource.endBlock;
} else if (isPonderDatasourceNested(ponderSource) && ponderSource.chain[serializedChainId]) {
startBlock = ponderSource.chain[serializedChainId].startBlock;
endBlock = ponderSource.chain[serializedChainId].endBlock;
}

if (typeof startBlock === "number") {
chainStartBlocks.push(deserializeBlockNumber(startBlock));
}

if (typeof endBlock === "number") {
chainEndBlocks.push(deserializeBlockNumber(endBlock));
}
}

// 2. Get the lowest startBlock for the chain.
const chainLowestStartBlock =
chainStartBlocks.length > 0 ? Math.min(...chainStartBlocks) : undefined;

// 3.a) The endBlock can only be set for a chain if and only if every
// ponderSource for that chain has its respective `endBlock` defined.
const isEndBlockForChainAllowed = chainEndBlocks.length === chainStartBlocks.length;

Comment on lines +141 to +144
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isEndBlockForChainAllowed is computed as chainEndBlocks.length === chainStartBlocks.length, but chainStartBlocks only counts sources where startBlock is a number. This can incorrectly allow an endBlock when some datasources for the chain use non-numeric startBlock (e.g. "latest") or otherwise weren’t counted. Track the total number of datasources that match the chain separately and only allow endBlock if all matching datasources provide a numeric endBlock (and consider failing fast if any matching datasource has a non-numeric startBlock, since it’s described as unsupported).

Copilot uses AI. Check for mistakes.
// 3.b) Get the highest endBLock for the chain.
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in comment: endBLock should be endBlock.

Suggested change
// 3.b) Get the highest endBLock for the chain.
// 3.b) Get the highest endBlock for the chain.

Copilot uses AI. Check for mistakes.
const chainHighestEndBlock =
isEndBlockForChainAllowed && chainEndBlocks.length > 0
? Math.max(...chainEndBlocks)
: undefined;

// 4. Enforce invariants

// Invariant: the indexed chain must have its startBlock defined as number.
if (typeof chainLowestStartBlock === "undefined") {
throw new Error(
`No minimum start block found for chain '${serializedChainId}'. Either all contracts, accounts, and block intervals use "latest" (unsupported) or the chain is misconfigured.`,
);
}

// 5. Assign a valid blockrange to the chain
chainsBlockrange.set(
deserializeChainId(serializedChainId),
deserializeBlockrange({
startBlock: chainLowestStartBlock,
endBlock: chainHighestEndBlock,
}),
);
}

return chainsBlockrange;
}
70 changes: 70 additions & 0 deletions apps/ensindexer/ponder/src/api/lib/local-ponder-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import config from "@/config";

import { publicClients as ponderPublicClients } from "ponder:api";
import type { PublicClient } from "viem";

import { deserializeChainId } from "@ensnode/ensnode-sdk";
import { type ChainId, PonderClient } from "@ensnode/ponder-sdk";

import type { ChainBlockRefs } from "@/lib/indexing-status/ponder-metadata";
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChainBlockRefs is imported from @/lib/indexing-status/ponder-metadata, but fetchChainsBlockRefs returns ChainBlockRefs from @/lib/indexing-status-builder/chain-block-refs. These are different types (ensnode-sdk vs ponder-sdk BlockRef) and will likely make the returned Map<ChainId, ChainBlockRefs> not typecheck. Import ChainBlockRefs from the same module as fetchChainsBlockRefs (or re-export a single canonical type) so the map value type is consistent.

Suggested change
import type { ChainBlockRefs } from "@/lib/indexing-status/ponder-metadata";
import type { ChainBlockRefs } from "@/lib/indexing-status-builder/chain-block-refs";

Copilot uses AI. Check for mistakes.
import { fetchChainsBlockRefs } from "@/lib/indexing-status-builder/chain-block-refs";
import { buildChainsBlockrange } from "@/ponder/api/lib/chains-config-blockrange";
import ponderConfig from "@/ponder/config";

/**
* Cached PublicClient instances for indexed chains, keyed by chain ID.
*/
const publicClients = new Map<ChainId, PublicClient>(
Object.entries(ponderPublicClients).map(([chainId, publicClient]) => [
deserializeChainId(chainId),
publicClient,
]),
);

/**
* Configured block range for indexed chains, based on Ponder Config.
*/
const chainsConfigBlockrange = buildChainsBlockrange(ponderConfig);

/**
* Indexed chain IDs, based on Ponder Config.
*/
export const indexedChainIds = Array.from(publicClients.keys()) satisfies ChainId[];

/**
* PonderClient instance for fetching indexing status and metrics from
* Ponder API.
*/
export const ponderClient = new PonderClient(config.ensIndexerUrl);

/**
* Cached block references based on Ponder Config and Ponder API metrics,
* keyed by chain ID.
*
* Note: this value is initialized on application startup, and is
* not expected to change during runtime, as it is based on
* the configured block range and block references fetched
* from RPCs at startup.
*/
// TODO: this operation may fail, so it should be wrapped in auto-retry logic.
// pRetry could be used, with a retry strategy that includes
// exponential backoff and jitter, to avoid overwhelming the RPC endpoints
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Top-level await on module-level exports causes entire API to fail if initialization fails, with no error handling or recovery mechanism

Fix on Vercel

// in case of transient errors or rate limits.
export const cachedChainsBlockRefs = await initializeCachedChainsBlockRefs();

async function initializeCachedChainsBlockRefs(): Promise<Readonly<Map<ChainId, ChainBlockRefs>>> {
Comment on lines +49 to +55
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cachedChainsBlockRefs is initialized using a top-level await, which performs network requests (Ponder metrics + RPC block fetches) at module import time. If any of these calls fail, the whole module import fails and the API server may not start. Consider lazy initialization (e.g., an async getter with memoization) and/or moving initialization into the server startup sequence with explicit retry/backoff and a clear failure mode.

Copilot uses AI. Check for mistakes.
const ponderIndexingMetrics = await ponderClient.metrics();

const chainsBlockRefs = await fetchChainsBlockRefs(
indexedChainIds,
chainsConfigBlockrange,
ponderIndexingMetrics.chains,
publicClients,
);

if (chainsBlockRefs.size === 0) {
throw new Error("Failed to fetch chainsBlockRefs: no block refs found");
}

return Object.freeze(chainsBlockRefs);
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Object.freeze(chainsBlockRefs) does not make a Map immutable (callers can still mutate via .set()/.delete()). If immutability is required, avoid exporting a mutable Map instance (e.g., return a ReadonlyMap reference that isn’t shared, or wrap access behind functions and keep the mutable map private).

Suggested change
return Object.freeze(chainsBlockRefs);
return chainsBlockRefs as Readonly<Map<ChainId, ChainBlockRefs>>;

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,41 +56,43 @@ export interface ChainBlockRefs {
}

/**
* Get {@link IndexedChainBlockRefs} for indexed chains.
* Fetch {@link ChainBlockRefs} for indexed chains.
*
* Guaranteed to include {@link ChainBlockRefs} for each indexed chain.
*/
export async function getChainsBlockRefs(
export async function fetchChainsBlockRefs(
chainIds: ChainId[],
chainsConfigBlockrange: Record<ChainId, Blockrange>,
chainsConfigBlockrange: Map<ChainId, Blockrange>,
chainsIndexingMetrics: Map<ChainId, ChainIndexingMetrics>,
publicClients: Record<ChainId, PublicClient>,
publicClients: Map<ChainId, PublicClient>,
): Promise<Map<ChainId, ChainBlockRefs>> {
const chainsBlockRefs = new Map<ChainId, ChainBlockRefs>();

for (const chainId of chainIds) {
const blockrange = chainsConfigBlockrange[chainId];
const blockrange = chainsConfigBlockrange.get(chainId);
const startBlock = blockrange?.startBlock;
const endBlock = blockrange?.endBlock;
const publicClient = publicClients[chainId];
const indexingMetrics = chainsIndexingMetrics.get(chainId);

if (typeof startBlock !== "number") {
throw new Error(`startBlock not found for chain ${chainId}`);
throw new Error(`startBlock must be defined for chain ${chainId}`);
}

const publicClient = publicClients.get(chainId);

if (typeof publicClient === "undefined") {
throw new Error(`publicClient not found for chain ${chainId}`);
throw new Error(`publicClient must be defined for chain ${chainId}`);
}

const indexingMetrics = chainsIndexingMetrics.get(chainId);

if (typeof indexingMetrics === "undefined") {
throw new Error(`indexingMetrics not found for chain ${chainId}`);
throw new Error(`indexingMetrics must be defined for chain ${chainId}`);
}

const historicalTotalBlocks = indexingMetrics.backfillSyncBlocksTotal;

if (typeof historicalTotalBlocks !== "number") {
throw new Error(`No historical total blocks metric found for chain ${chainId}`);
throw new Error(`historicalTotalBlocks must be defined for chain ${chainId}`);
}

const backfillEndBlock = startBlock + historicalTotalBlocks - 1;
Expand Down
3 changes: 2 additions & 1 deletion apps/ensindexer/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"extends": "@ensnode/shared-configs/tsconfig.ponder.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@/ponder/*": ["./ponder/src/*"]
},
"typeRoots": ["./types"]
},
Expand Down
Loading