Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9bc4fc5
feat(ensindexer): introduce `Indexing Status Builder` module
tk-o Feb 6, 2026
cb6057d
Add cross-chain indexing status snapshot to Indexing Status Builder
tk-o Feb 6, 2026
28f9d31
Merge remote-tracking branch 'origin/main' into feat/indexing-status-…
tk-o Feb 9, 2026
9986ac8
Merge remote-tracking branch 'origin/main' into feat/indexing-status-…
tk-o Feb 11, 2026
008c0fa
wip
tk-o Feb 11, 2026
3b15c47
Merge remote-tracking branch 'origin/main' into feat/indexing-status-…
tk-o Feb 18, 2026
63275b6
Improve data model for Indexing Status Builder
tk-o Feb 18, 2026
9bad342
Apply AI PR feedback
tk-o Feb 18, 2026
afe61a4
Apply AI PR feedback
tk-o Feb 18, 2026
34d7920
Add testing suite cases
tk-o Feb 18, 2026
e9db330
Add invariant to buildOmnichainIndexingStatusSnapshot function`
tk-o Feb 18, 2026
01d336a
Integrating "local" Ponder Client with Indexing Status Builder into …
tk-o Feb 10, 2026
8c3addd
Split chains indexing metadata
tk-o Feb 18, 2026
d9d7976
Introduce `LocalPonderClient` class for ENSIndexer app to use
tk-o Feb 18, 2026
91e743a
Introduce `p-retry` to ENSIndexer
tk-o Feb 18, 2026
3d1a32c
Integrate LocalPonderClient with ENSIndexer app
tk-o Feb 18, 2026
f52301e
Fix `getLocalPonderClient` function
tk-o Feb 18, 2026
7234c81
Merge remote-tracking branch 'origin/main' into feat/indexing-status-…
tk-o Feb 18, 2026
ebf55d0
Fix backfillEndBlockNumber formula
tk-o Feb 18, 2026
6b00f45
Apply AI PR feedback
tk-o Feb 18, 2026
42b48d2
Prefer Ponder SDK types over ENSNode SDK types
tk-o Feb 19, 2026
14ace99
Rename ChainIndexingMetadataFixed to ChainIndexingMetadataImmutable
tk-o Feb 19, 2026
b8b2f19
Use `indexedChainIds` from ENSIndexer config
tk-o Feb 19, 2026
c51d99e
Merge remote-tracking branch 'origin/main' into feat/indexing-status-…
tk-o Feb 20, 2026
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
2 changes: 1 addition & 1 deletion apps/ensapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"hono": "catalog:",
"hono-openapi": "^1.1.2",
"p-memoize": "^8.0.0",
"p-retry": "^7.1.0",
"p-retry": "catalog:",
"pg-connection-string": "catalog:",
"pino": "catalog:",
"ponder-enrich-gql-docs-middleware": "^0.1.3",
Expand Down
1 change: 1 addition & 0 deletions apps/ensindexer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"pg-connection-string": "catalog:",
"hono": "catalog:",
"ponder": "catalog:",
"p-retry": "catalog:",
"viem": "catalog:",
"zod": "catalog:"
},
Expand Down
27 changes: 17 additions & 10 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,13 +14,18 @@ import {
} from "@ensnode/ensnode-sdk";

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

const app = new Hono();

// Calling `getLocalPonderClient` at the top level to initialize
// the singleton client instance on app startup.
// This ensures that the client is ready to use when handling requests,
// and allows us to catch initialization errors early.
getLocalPonderClient();

// include ENSIndexer Public Config endpoint
app.get("/config", async (c) => {
// prepare the public config object, including dependency info
Expand All @@ -38,23 +42,26 @@ app.get("/indexing-status", async (c) => {
let omnichainSnapshot: OmnichainIndexingStatusSnapshot | undefined;

try {
omnichainSnapshot = await buildOmnichainIndexingStatusSnapshot(publicClients);
const localPonderClient = await getLocalPonderClient();
const chainsIndexingMetadata = await localPonderClient.chainsIndexingMetadata();

omnichainSnapshot = buildOmnichainIndexingStatusSnapshot(chainsIndexingMetadata);
} 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
185 changes: 185 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,185 @@
/**
* 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 {
deserializeBlockNumber,
deserializeBlockrange,
deserializeChainId,
} from "@ensnode/ensnode-sdk";
import type {
BlockNumber,
Blockrange,
BlockrangeWithStartBlock,
ChainId,
ChainIdString,
} from "@ensnode/ponder-sdk";

/**
* 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,
* - some chains may include an endBlock,
* - all present startBlock and endBlock values are valid {@link BlockNumber} values.
*
* @throws Error if any of the above invariants are violated.
*/
export function buildChainsBlockrange(
ponderConfig: PonderConfigType,
): Map<ChainId, BlockrangeWithStartBlock> {
const chainsBlockrange = new Map<ChainId, BlockrangeWithStartBlock>();

// 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;

// 3.b) Get the highest endBLock for the chain.
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Typo: "endBLock" → "endBlock".

-    // 3.b) Get the highest endBLock for the chain.
+    // 3.b) Get the highest endBlock for the chain.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 3.b) Get the highest endBLock for the chain.
// 3.b) Get the highest endBlock for the chain.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensindexer/ponder/src/api/lib/chains-config-blockrange.ts` at line 152,
Fix the typo in the inline comment that currently reads "Get the highest
endBLock for the chain." — change "endBLock" to "endBlock" so the comment
correctly references the endBlock concept used in the chains-config-blockrange
logic; ensure any other nearby occurrences of the same misspelling in the same
file are corrected to "endBlock".

Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

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

Spelling error: "endBLock" should be "endBlock" (lowercase 'l' in "Block").

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
const chainId = deserializeChainId(serializedChainId);
const blockrange = deserializeBlockrange({
startBlock: chainLowestStartBlock,
endBlock: chainHighestEndBlock,
});

// Invariant: the blockrange must include a valid startBlock number.
if (typeof blockrange.startBlock === "undefined") {
throw new Error(
`Invalid blockrange for chain '${serializedChainId}'. The blockrange must include a valid startBlock number.`,
);
}

chainsBlockrange.set(chainId, blockrange as BlockrangeWithStartBlock);
Comment on lines +175 to +181
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

The second startBlock guard at lines 175–179 is unreachable at runtime.

After the throw at lines 161–165, chainLowestStartBlock is a valid BlockNumber, so deserializeBlockrange will either throw on schema failure or return with startBlock present. The if (typeof blockrange.startBlock === "undefined") branch can never execute.

The guard exists only to satisfy TypeScript's inferred return type of deserializeBlockrange (Blockrange with optional startBlock) before the cast on line 181. Consider using an assertion comment or a type assertion directly to make the intent clearer without the misleading dead throw:

♻️ Proposed simplification
-    // Invariant: the blockrange must include a valid startBlock number.
-    if (typeof blockrange.startBlock === "undefined") {
-      throw new Error(
-        `Invalid blockrange for chain '${serializedChainId}'. The blockrange must include a valid startBlock number.`,
-      );
-    }
-
-    chainsBlockrange.set(chainId, blockrange as BlockrangeWithStartBlock);
+    // startBlock is guaranteed non-undefined: chainLowestStartBlock was validated above.
+    chainsBlockrange.set(chainId, blockrange as BlockrangeWithStartBlock);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (typeof blockrange.startBlock === "undefined") {
throw new Error(
`Invalid blockrange for chain '${serializedChainId}'. The blockrange must include a valid startBlock number.`,
);
}
chainsBlockrange.set(chainId, blockrange as BlockrangeWithStartBlock);
// startBlock is guaranteed non-undefined: chainLowestStartBlock was validated above.
chainsBlockrange.set(chainId, blockrange as BlockrangeWithStartBlock);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensindexer/ponder/src/api/lib/chains-config-blockrange.ts` around lines
175 - 181, The second guard checking typeof blockrange.startBlock ===
"undefined" is unreachable; remove that throw and instead assert the type of the
returned value from deserializeBlockrange so TypeScript knows startBlock is
present. Replace the runtime guard with a type assertion/cast to
BlockrangeWithStartBlock (or add an inline assertion comment) when calling
chainsBlockrange.set(chainId, ...) after
deserializeBlockrange(chainLowestStartBlock, ...), referencing
deserializeBlockrange, blockrange, chainLowestStartBlock,
BlockrangeWithStartBlock, chainsBlockrange.set and serializedChainId to locate
the code.

}

return chainsBlockrange;
}
39 changes: 39 additions & 0 deletions apps/ensindexer/ponder/src/api/lib/fetch-block-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { PublicClient } from "viem";

import { bigIntToNumber, deserializeBlockRef } from "@ensnode/ensnode-sdk";
import type { BlockNumber, BlockRef } from "@ensnode/ponder-sdk";

/**
* Fetch block reference (number and timestamp) for a given block number on
* a given chain, using the provided public client.
*
* @returns Requested block reference
* @throws Error if the block cannot be fetched or if the block data is invalid.
*/
export async function fetchBlockRef(
publicClient: PublicClient,
blockNumber: BlockNumber,
): Promise<BlockRef> {
const chainId = publicClient.chain?.id;

if (!chainId) {
throw new Error(
`Public client is missing chain ID, cannot fetch block ref for block number ${blockNumber}`,
);
}

try {
const block = await publicClient.getBlock({ blockNumber: BigInt(blockNumber) });

return deserializeBlockRef({
timestamp: bigIntToNumber(block.timestamp),
number: bigIntToNumber(block.number),
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";

throw new Error(
`Error fetching block for chain ID ${chainId} at block number ${blockNumber}: ${errorMessage}`,
);
}
}
Loading