Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/ensindexer-per-chain-end-blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensindexer": minor
---

Replace the global `START_BLOCK`/`END_BLOCK` indexing-range configuration with per-chain end blocks via `END_BLOCK_<chainId>` environment variables (e.g. `END_BLOCK_1`, `END_BLOCK_8453`), mirroring the `RPC_URL_<chainId>` convention. Each constrains the indexing end block of its chain independently and MAY be set across any number of indexed chains (the legacy global blockrange was restricted to single-chain indexing). This enables deterministic, reproducible multichain checkpoints where every indexed chain stops at a block corresponding to a shared timestamp.
5 changes: 5 additions & 0 deletions .changeset/ensnode-sdk-indexed-blockranges-per-chain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensnode-sdk": minor
---

`buildIndexedBlockranges` now takes a per-chain end-block map (`ReadonlyMap<ChainId, number>`) instead of a single global end block, supporting ENSIndexer's per-chain `END_BLOCK_<chainId>` configuration.
58 changes: 33 additions & 25 deletions apps/ensindexer/src/config/config.schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ChainId } from "enssdk";
import { prettifyError, ZodError, z } from "zod/v4";

import { buildBlockNumberRange, PluginName, uniq } from "@ensnode/ensnode-sdk";
import { PluginName, uniq } from "@ensnode/ensnode-sdk";
import {
buildRpcConfigsFromEnv,
ENSNamespaceSchema,
Expand All @@ -19,7 +20,7 @@ import { applyDefaults, EnvironmentDefaults } from "@/config/environment-default
import { derive_indexedChainIds } from "./derived-params";
import type { EnsIndexerConfig } from "./types";
import {
invariant_globalBlockrange,
invariant_chainEndBlocks,
invariant_requiredDatasources,
invariant_requiredDatasourcesSubsetOfAll,
invariant_rpcConfigsSpecifiedForIndexedChains,
Expand All @@ -38,24 +39,34 @@ const makeEnvStringBoolSchema = (envVarKey: string) =>
)
.transform((val) => val === "true");

const makeBlockNumberSchema = (envVarKey: string) =>
z.coerce
.number({ error: `${envVarKey} must be a positive integer.` })
.int({ error: `${envVarKey} must be a positive integer.` })
.min(0, { error: `${envVarKey} must be a positive integer.` })
.optional();
const ChainEndBlocksSchema = z.map(z.number().int().nonnegative(), z.number().int().nonnegative());

const BlockrangeSchema = z
.object({
startBlock: makeBlockNumberSchema("START_BLOCK"),
endBlock: makeBlockNumberSchema("END_BLOCK"),
})
.refine(
(val) =>
val.startBlock === undefined || val.endBlock === undefined || val.startBlock <= val.endBlock,
{ error: "START_BLOCK must be less than or equal to END_BLOCK." },
)
.transform(({ startBlock, endBlock }) => buildBlockNumberRange(startBlock, endBlock));
/**
* Parses chain-specific end blocks from `END_BLOCK_<chainId>` environment variables into a map.
*
* Mirrors the `RPC_URL_<chainId>` convention. Pins a deterministic end block per chain (e.g. all
* chains stopping at blocks corresponding to a shared timestamp) for reproducible checkpoints; may
* be set across any number of indexed chains.
*
* @throws if any `END_BLOCK_<chainId>` value is not a non-negative integer
*/
export function buildChainEndBlocksFromEnv(env: ENSIndexerEnvironment): Map<ChainId, number> {
const chainEndBlocks = new Map<ChainId, number>();

for (const [key, value] of Object.entries(env)) {
const match = /^END_BLOCK_(\d+)$/.exec(key);
if (!match || value === undefined) continue;

const endBlock = Number(value);
if (!Number.isInteger(endBlock) || endBlock < 0) {
throw new Error(`${key} must be a non-negative integer.`);
}

chainEndBlocks.set(Number(match[1]), endBlock);
Comment on lines +60 to +65
}

return chainEndBlocks;
}

const PluginsSchema = z.coerce
.string()
Expand Down Expand Up @@ -93,7 +104,7 @@ const ENSIndexerConfigSchema = z
namespace: ENSNamespaceSchema,
plugins: PluginsSchema,
isSubgraphCompatible: IsSubgraphCompatibleSchema,
globalBlockrange: BlockrangeSchema,
chainEndBlocks: ChainEndBlocksSchema,
ensRainbowUrl: EnsRainbowUrlSchema,
clientLabelSet: LabelSetSchema,

Expand Down Expand Up @@ -134,7 +145,7 @@ const ENSIndexerConfigSchema = z
.check(invariant_unigraphRequiresProtocolAcceleration)
.check(invariant_isSubgraphCompatibleRequirements)
.check(invariant_rpcConfigsSpecifiedForIndexedChains)
.check(invariant_globalBlockrange);
.check(invariant_chainEndBlocks);

/**
* Builds the ENSIndexer configuration object from an ENSIndexerEnvironment object.
Expand Down Expand Up @@ -174,10 +185,7 @@ export function buildConfigFromEnvironment(_env: ENSIndexerEnvironment): EnsInde

plugins: env.PLUGINS,
isSubgraphCompatible: env.SUBGRAPH_COMPAT,
globalBlockrange: {
startBlock: env.START_BLOCK,
endBlock: env.END_BLOCK,
},
chainEndBlocks: buildChainEndBlocksFromEnv(env),
ensRainbowUrl: env.ENSRAINBOW_URL,
clientLabelSet: {
labelSetId: env.LABEL_SET_ID,
Expand Down
81 changes: 27 additions & 54 deletions apps/ensindexer/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
getENSNamespace,
maybeGetDatasource,
} from "@ensnode/datasources";
import { buildBlockNumberRange, ENSNamespaceIds, PluginName } from "@ensnode/ensnode-sdk";
import { ENSNamespaceIds, PluginName } from "@ensnode/ensnode-sdk";
import type { RpcConfig } from "@ensnode/ensnode-sdk/internal";

import { buildConfigFromEnvironment } from "@/config/config.schema";
Expand Down Expand Up @@ -88,7 +88,6 @@ describe("config (with base env)", () => {
it("returns a valid config object using environment variables", async () => {
const config = await getConfig();
expect(config.namespace).toBe("mainnet");
expect(config.globalBlockrange).toEqual(buildBlockNumberRange(undefined, undefined));
expect(config.ensIndexerSchemaName).toBe("ensindexer_test");
expect(config.plugins).toEqual(["subgraph"]);
expect(config.ensRainbowUrl).toStrictEqual(new URL("http://localhost:3223"));
Expand All @@ -105,64 +104,45 @@ describe("config (with base env)", () => {
});
});

describe(".globalBlockrange", () => {
it("returns both startBlock and endBlock as numbers when both are set", async () => {
vi.stubEnv("START_BLOCK", "10");
vi.stubEnv("END_BLOCK", "20");
describe(".chainEndBlocks", () => {
it("defaults to an empty map when no END_BLOCK_<chainId> is set", async () => {
const config = await getConfig();
expect(config.globalBlockrange).toEqual(buildBlockNumberRange(10, 20));
expect(config.chainEndBlocks).toEqual(new Map());
});

it("returns only startBlock when only START_BLOCK is set", async () => {
vi.stubEnv("START_BLOCK", "5");
it("parses END_BLOCK_<chainId> into the chainEndBlocks map", async () => {
vi.stubEnv("END_BLOCK_1", "100");
const config = await getConfig();
expect(config.globalBlockrange).toEqual(buildBlockNumberRange(5, undefined));
expect(config.chainEndBlocks).toEqual(new Map([[1, 100]]));
});

it("returns only endBlock when only END_BLOCK is set", async () => {
vi.stubEnv("END_BLOCK", "15");
const config = await getConfig();
expect(config.globalBlockrange).toEqual(buildBlockNumberRange(undefined, 15));
});

it("returns both as undefined when neither is set", async () => {
it("allows per-chain end blocks across multiple indexed chains", async () => {
vi.stubEnv("PLUGINS", "subgraph,basenames");
stubRpcUrlsForNamespace("mainnet");
vi.stubEnv("END_BLOCK_1", "100");
vi.stubEnv("END_BLOCK_8453", "200");
const config = await getConfig();
expect(config.globalBlockrange).toEqual(buildBlockNumberRange(undefined, undefined));
});

it("throws if START_BLOCK is negative", async () => {
vi.stubEnv("START_BLOCK", "-1");
await expect(getConfig()).rejects.toThrow(/START_BLOCK must be a positive integer/i);
});

it("throws if END_BLOCK is negative", async () => {
vi.stubEnv("END_BLOCK", "-5");
await expect(getConfig()).rejects.toThrow(/END_BLOCK must be a positive integer/i);
});

it("throws if START_BLOCK is not a number", async () => {
vi.stubEnv("START_BLOCK", "foo");
await expect(getConfig()).rejects.toThrow(/START_BLOCK must be a positive integer/i);
expect(config.chainEndBlocks).toEqual(
new Map([
[1, 100],
[8453, 200],
]),
);
});

it("throws if END_BLOCK is not a number", async () => {
vi.stubEnv("END_BLOCK", "bar");
await expect(getConfig()).rejects.toThrow(/END_BLOCK must be a positive integer/i);
it("throws if END_BLOCK_<chainId> targets a chain that is not indexed", async () => {
vi.stubEnv("END_BLOCK_8453", "100");
await expect(getConfig()).rejects.toThrow(/no active plugin indexes chain 8453/i);
});

it("throws if START_BLOCK > END_BLOCK", async () => {
vi.stubEnv("START_BLOCK", "100");
vi.stubEnv("END_BLOCK", "50");
await expect(getConfig()).rejects.toThrow(
/START_BLOCK must be less than or equal to END_BLOCK/i,
);
it("throws if END_BLOCK_<chainId> is negative", async () => {
vi.stubEnv("END_BLOCK_1", "-5");
await expect(getConfig()).rejects.toThrow(/END_BLOCK_1 must be a non-negative integer/i);
});

it("does not throw if START_BLOCK == END_BLOCK", async () => {
vi.stubEnv("START_BLOCK", "100");
vi.stubEnv("END_BLOCK", "100");
const config = await getConfig();
expect(config.globalBlockrange).toEqual(buildBlockNumberRange(100, 100));
it("throws if END_BLOCK_<chainId> is not a number", async () => {
vi.stubEnv("END_BLOCK_1", "foo");
await expect(getConfig()).rejects.toThrow(/END_BLOCK_1 must be a non-negative integer/i);
});
});

Expand Down Expand Up @@ -565,13 +545,6 @@ describe("config (with base env)", () => {
vi.stubEnv("PLUGINS", "subgraph,basenames");
await expect(getConfig()).rejects.toThrow(/RPC_URL_\d+/i);
});

it("cannot constrain blockrange with multiple chains", async () => {
vi.stubEnv("PLUGINS", "subgraph,basenames");
stubRpcUrlsForNamespace("mainnet");
vi.stubEnv("END_BLOCK", "1");
await expect(getConfig()).rejects.toThrow(/multiple chains/i);
});
});

describe(".clientLabelSet", () => {
Expand Down
5 changes: 3 additions & 2 deletions apps/ensindexer/src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export type ENSIndexerEnvironment = EnsDbEnvironment &
PLUGINS?: string;
SUBGRAPH_COMPAT?: string;

START_BLOCK?: string;
END_BLOCK?: string;
// Chain-specific end blocks, keyed by chain id (e.g. END_BLOCK_1, END_BLOCK_8453). Mirrors the
// RPC_URL_<chainId> convention. See ENSIndexerConfig.chainEndBlocks.
[x: `END_BLOCK_${number}`]: string | undefined;

ENSRAINBOW_URL?: string;
LABEL_SET_ID?: string;
Expand Down
1 change: 0 additions & 1 deletion apps/ensindexer/src/config/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export function serializeRedactedENSIndexerConfig(
ensDbUrl: redactedConfig.ensDbUrl,
ensRainbowUrl: serializeUrl(redactedConfig.ensRainbowUrl),
clientLabelSet: redactedConfig.clientLabelSet,
globalBlockrange: redactedConfig.globalBlockrange,
indexedChainIds: serializeIndexedChainIds(redactedConfig.indexedChainIds),
isSubgraphCompatible: redactedConfig.isSubgraphCompatible,
namespace: redactedConfig.namespace,
Expand Down
5 changes: 4 additions & 1 deletion apps/ensindexer/src/config/serialized-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ export interface SerializedRpcConfig extends Omit<RpcConfig, "httpRPCs" | "webso
* Serialized representation of {@link ENSIndexerConfig}
*/
export interface SerializedENSIndexerConfig
extends Omit<ENSIndexerConfig, "ensRainbowUrl" | "indexedChainIds" | "rpcConfigs" | "plugins"> {
extends Omit<
ENSIndexerConfig,
"ensRainbowUrl" | "indexedChainIds" | "rpcConfigs" | "plugins" | "chainEndBlocks"
> {
Comment on lines 28 to +32
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 chainEndBlocks is omitted from SerializedENSIndexerConfig with no replacement property. Every other Map-typed field that's omitted gets a serialized counterpart (indexedChainIdsChainId[], rpcConfigsRecord<ChainIdString, SerializedRpcConfig>). Without a replacement, the active per-chain end-block configuration becomes invisible in whatever endpoint or log surfaces the serialized config, making it hard to confirm or debug which END_BLOCK_<chainId> values are in effect.

/**
* Serialized representation of {@link ENSIndexerConfig.ensRainbowUrl}.
*/
Expand Down
24 changes: 10 additions & 14 deletions apps/ensindexer/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ChainId } from "enssdk";

import type { ENSNamespaceId } from "@ensnode/datasources";
import type { EnsDbConfig } from "@ensnode/ensdb-sdk";
import type { BlockNumberRange, PluginName } from "@ensnode/ensnode-sdk";
import type { PluginName } from "@ensnode/ensnode-sdk";
import { RpcConfig, type RpcConfigs } from "@ensnode/ensnode-sdk/internal";
import type { EnsRainbowClientLabelSet } from "@ensnode/ensrainbow-sdk";

Expand Down Expand Up @@ -120,23 +120,19 @@ export interface EnsIndexerConfig {
ensDbUrl: EnsDbConfig["ensDbUrl"];

/**
* Constrains the global blockrange for indexing, useful for testing purposes.
* Chain-specific end blocks, keyed by {@link ChainId}, parsed from `END_BLOCK_<chainId>` env vars.
*
* This is strictly designed for testing and development and its usage in production will result
* in incorrect or out-of-date indexes.
*
* ENSIndexer will constrain all indexed contracts to the provided {@link BlockNumberRange.startBlock}
* and {@link BlockNumberRange.endBlock} if specified.
* Constrains the indexing end block of each specified chain — strictly for testing/checkpoints;
* usage in production results in incorrect or out-of-date indexes. Designed for producing
* deterministic, reproducible checkpoints where every indexed chain stops at a block corresponding
* to a shared timestamp. Empty when no `END_BLOCK_<chainId>` vars are set.
*
* Invariants:
* - both `startBlock` and `endBlock` are optional, and expected to be undefined
* - if defined, startBlock must be an integer greater than 0
* - if defined, endBlock must be an integer greater than 0
* - if defined, endBlock must be greater than startBlock
* - if either `startBlock` or `endBlock` are defined, the number of indexed chains described
* by {@link plugins} must be 1
* - each key must be a {@link ChainId} indexed by the active {@link plugins}
* - each value must be a non-negative integer
* - may be set across any number of indexed chains
*/
globalBlockrange: BlockNumberRange;
chainEndBlocks: Map<ChainId, number>;

/**
* A feature flag to enable/disable ENSIndexer's Subgraph Compatible Indexing Behavior.
Expand Down
26 changes: 6 additions & 20 deletions apps/ensindexer/src/config/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,32 +78,18 @@ export function invariant_rpcConfigsSpecifiedForIndexedChains(
}
}

// Invariant: if a global blockrange is defined, only one chain is indexed
export function invariant_globalBlockrange(
ctx: ZodCheckFnInput<
Pick<EnsIndexerConfig, "globalBlockrange" | "indexedChainIds" | "namespace" | "plugins">
>,
// Invariant: each END_BLOCK_<chainId> targets a chain that is actually indexed
export function invariant_chainEndBlocks(
ctx: ZodCheckFnInput<Pick<EnsIndexerConfig, "chainEndBlocks" | "indexedChainIds">>,
) {
const { value: config } = ctx;
const { globalBlockrange } = config;

if (globalBlockrange.startBlock !== undefined || globalBlockrange.endBlock !== undefined) {
if (config.indexedChainIds.size > 1) {
for (const chainId of config.chainEndBlocks.keys()) {
if (!config.indexedChainIds.has(chainId)) {
ctx.issues.push({
code: "custom",
input: config,
message: `ENSIndexer's behavior when indexing _multiple chains_ with a _specific blockrange_ is considered undefined (for now). If you're using this feature, you're likely interested in snapshotting at a specific END_BLOCK, and may have unintentially activated plugins that source events from multiple chains. The config currently is:

NAMESPACE=${config.namespace}
PLUGINS=${config.plugins.join(",")}
START_BLOCK=${globalBlockrange.startBlock || "n/a"}
END_BLOCK=${globalBlockrange.endBlock || "n/a"}

The usage you're most likely interested in is:
NAMESPACE=(mainnet|sepolia) PLUGINS=subgraph END_BLOCK=x pnpm run start
which runs just the 'subgraph' plugin with a specific end block, suitable for snapshotting ENSNode and comparing to Subgraph snapshots.

In the future, indexing multiple chains with chain-specific blockrange constraints may be possible.`,
message: `END_BLOCK_${chainId} is set, but no active plugin indexes chain ${chainId}.`,
});
}
}
Expand Down
14 changes: 1 addition & 13 deletions apps/ensindexer/src/lib/__test__/mockConfig.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { vi } from "vitest";

import { buildBlockNumberRange } from "@ensnode/ensnode-sdk";

import { buildConfigFromEnvironment } from "@/config/config.schema";
import type { ENSIndexerConfig } from "@/config/types";
import { deepClone } from "@/lib/lib-helpers";
Expand Down Expand Up @@ -119,19 +117,9 @@ export function setupConfigMock() {
* @example
* // In the test
* updateMockConfig({
* globalBlockrange: { startBlock: 100, endBlock: 200 }
* chainEndBlocks: new Map([[1, 200]])
* });
*/
export function updateMockConfig(updates: Partial<ENSIndexerConfig>) {
Object.assign(currentMockConfig, updates);
}

/**
* Sets up the global blockrange in the current mock config
*
* @param startBlock Optional start block
* @param endBlock Optional end block
*/
export function setGlobalBlockrange(startBlock?: number, endBlock?: number) {
updateMockConfig({ globalBlockrange: buildBlockNumberRange(startBlock, endBlock) });
}
2 changes: 1 addition & 1 deletion apps/ensindexer/src/lib/local-ponder-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { localPonderContext } from "./local-ponder-context";
const pluginsAllDatasourceNames = getPluginsAllDatasourceNames(config.plugins);
const indexedBlockranges = buildIndexedBlockranges(
config.namespace,
config.globalBlockrange.endBlock,
config.chainEndBlocks,
pluginsAllDatasourceNames,
);

Expand Down
Loading
Loading