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/eth-getlogs-block-range.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensindexer": minor
---

ENSIndexer now supports tuning Ponder's `eth_getLogs` block range per chain. Set `ETH_GET_LOGS_BLOCK_RANGE` to apply a default cap across all chains, or `ETH_GET_LOGS_BLOCK_RANGE_<chainId>` to override it for a specific chain (use `0` to opt a chain out and let Ponder auto-determine its range). This makes it easy to work with RPC providers that require a smaller `eth_getLogs` window. Like RPC connection settings, these are performance-only knobs: changing them does not affect indexed data and does not trigger a re-index.
24 changes: 24 additions & 0 deletions apps/ensindexer/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,30 @@
# provided HTTP/HTTPS RPC. More details at: https://ponder.sh/docs/config/chains#rpc-endpoints
#

# eth_getLogs block range configuration
# Optional. Caps the maximum block range Ponder uses for eth_getLogs requests.
#
# Ponder auto-determines a safe eth_getLogs block range per chain. Set these only when an RPC provider
# rejects Ponder's default range (for example, when it requires a smaller window). Each value is a
# non-negative integer (0 included): a positive value is the number of blocks per eth_getLogs request,
# and 0 disables the override so Ponder auto-determines the range.
#
# - ETH_GET_LOGS_BLOCK_RANGE sets a default applied to every chain.
# - ETH_GET_LOGS_BLOCK_RANGE_${chainId} overrides that default for a specific chain.
# - ETH_GET_LOGS_BLOCK_RANGE_${chainId}=0 disables the cap for that chain (ignoring the default), so
# Ponder auto-determines its range. ETH_GET_LOGS_BLOCK_RANGE=0 is equivalent to leaving it unset.
#
# Chains without an effective value continue to use Ponder's auto-determined range.
# More details at: https://ponder.sh/docs/config/chains#eth_getlogs-block-range
#
# Changing these values does not trigger a re-index (they are not part of Ponder's build id), the
# same as changing RPC_URL_${chainId}.
#
# Example (default 1000 for all chains, 500 for Base, disabled for mainnet):
# ETH_GET_LOGS_BLOCK_RANGE=1000
# ETH_GET_LOGS_BLOCK_RANGE_8453=500
# ETH_GET_LOGS_BLOCK_RANGE_1=0

# === ENS Namespace: Mainnet ===
# Ethereum Mainnet
# - required if the configured namespace is mainnet
Expand Down
9 changes: 8 additions & 1 deletion apps/ensindexer/src/config/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import type { ENSIndexerEnvironment } from "@/config/environment";
import { applyDefaults, EnvironmentDefaults } from "@/config/environment-defaults";

import { derive_indexedChainIds } from "./derived-params";
import {
buildEthGetLogsBlockRangesFromEnv,
EthGetLogsBlockRangesSchema,
} from "./eth-get-logs-block-ranges";
import type { EnsIndexerConfig } from "./types";
import {
invariant_globalBlockrange,
Expand Down Expand Up @@ -89,6 +93,7 @@ const IsSubgraphCompatibleSchema =
const ENSIndexerConfigSchema = z
.object({
rpcConfigs: RpcConfigsSchema,
ethGetLogsBlockRanges: EthGetLogsBlockRangesSchema,

namespace: ENSNamespaceSchema,
plugins: PluginsSchema,
Expand Down Expand Up @@ -163,14 +168,16 @@ export function buildConfigFromEnvironment(_env: ENSIndexerEnvironment): EnsInde
// apply the partial defaults to the provided env
const env = applyDefaults(_env, environmentDefaults);

// and use that to generate rpcConfigs
// and use that to build the per-chain rpcConfigs and eth_getLogs block ranges
const namespace = ENSNamespaceSchema.parse(env.NAMESPACE);
const rpcConfigs = buildRpcConfigsFromEnv(env, namespace);
const ethGetLogsBlockRanges = buildEthGetLogsBlockRangesFromEnv(env, namespace);

// parse/validate with ENSIndexerConfigSchema
return ENSIndexerConfigSchema.parse({
namespace: env.NAMESPACE,
rpcConfigs,
ethGetLogsBlockRanges,

plugins: env.PLUGINS,
isSubgraphCompatible: env.SUBGRAPH_COMPAT,
Expand Down
38 changes: 38 additions & 0 deletions apps/ensindexer/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,44 @@ describe("config (with base env)", () => {
});
});

describe(".ethGetLogsBlockRanges", () => {
it("defaults to an empty Map when no ETH_GET_LOGS_BLOCK_RANGE var is set", async () => {
const config = await getConfig();
expect(config.ethGetLogsBlockRanges).toStrictEqual(new Map());
});

it("includes a chain's configured eth_getLogs block range", async () => {
vi.stubEnv("ETH_GET_LOGS_BLOCK_RANGE_1", "1000");
const config = await getConfig();
expect(config.ethGetLogsBlockRanges).toStrictEqual(new Map([[1, 1000]]));
});

it("applies the global ETH_GET_LOGS_BLOCK_RANGE default to indexed chains", async () => {
vi.stubEnv("ETH_GET_LOGS_BLOCK_RANGE", "1000");
const config = await getConfig();
expect(Object.fromEntries(config.ethGetLogsBlockRanges)).toMatchObject({ "1": 1000 });
});

it("lets a chain-specific override take precedence over the global default", async () => {
vi.stubEnv("ETH_GET_LOGS_BLOCK_RANGE", "1000");
vi.stubEnv("ETH_GET_LOGS_BLOCK_RANGE_1", "500");
const config = await getConfig();
expect(Object.fromEntries(config.ethGetLogsBlockRanges)).toMatchObject({ "1": 500 });
});

it("omits a chain disabled with 0 even when a global default is set", async () => {
vi.stubEnv("ETH_GET_LOGS_BLOCK_RANGE", "1000");
vi.stubEnv("ETH_GET_LOGS_BLOCK_RANGE_1", "0");
const config = await getConfig();
expect(Object.fromEntries(config.ethGetLogsBlockRanges)).not.toHaveProperty("1");
});

it("throws if a configured eth_getLogs block range is not a non-negative integer", async () => {
vi.stubEnv("ETH_GET_LOGS_BLOCK_RANGE_1", "abc");
await expect(getConfig()).rejects.toThrow(/non-negative integer/i);
});
});

describe(".chains", () => {
it("returns the chains if it is a valid object (one HTTP protocol URL)", async () => {
vi.stubEnv("RPC_URL_1", VALID_RPC_URL);
Expand Down
17 changes: 16 additions & 1 deletion apps/ensindexer/src/config/environment.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import type { EnsDbEnvironment, RpcEnvironment } from "@ensnode/ensnode-sdk/internal";

/**
* Environment variables for `eth_getLogs` block range overrides.
*
* `ETH_GET_LOGS_BLOCK_RANGE` sets a default applied to every chain, and each
* `ETH_GET_LOGS_BLOCK_RANGE_${chainId}` overrides that default for a specific chain (a value of `0`
* disables the override for that chain, so Ponder auto-determines its range). These cap Ponder's
* auto-determined maximum `eth_getLogs` block range.
* @see https://ponder.sh/docs/config/chains#eth_getlogs-block-range
*/
export interface EthGetLogsBlockRangeEnvironment {
ETH_GET_LOGS_BLOCK_RANGE?: string;
[x: `ETH_GET_LOGS_BLOCK_RANGE_${number}`]: string | undefined;
}

/**
* Represents the raw, unvalidated environment variables for the ENSIndexer application.
*
Expand All @@ -8,7 +22,8 @@ import type { EnsDbEnvironment, RpcEnvironment } from "@ensnode/ensnode-sdk/inte
* mapped/parsed into a structured configuration object like `ENSIndexerConfig`.
*/
export type ENSIndexerEnvironment = EnsDbEnvironment &
RpcEnvironment & {
RpcEnvironment &
EthGetLogsBlockRangeEnvironment & {
NAMESPACE?: string;
PLUGINS?: string;
SUBGRAPH_COMPAT?: string;
Expand Down
73 changes: 73 additions & 0 deletions apps/ensindexer/src/config/eth-get-logs-block-ranges.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";

import {
buildEthGetLogsBlockRangesFromEnv,
EthGetLogsBlockRangesSchema,
} from "./eth-get-logs-block-ranges";

describe("buildEthGetLogsBlockRangesFromEnv", () => {
it("returns an empty record when no ETH_GET_LOGS_BLOCK_RANGE or ETH_GET_LOGS_BLOCK_RANGE_<chainId> vars are set", () => {
expect(buildEthGetLogsBlockRangesFromEnv({}, "mainnet")).toStrictEqual({});
});

it("collects ETH_GET_LOGS_BLOCK_RANGE_<chainId> for chains in the namespace", () => {
expect(
buildEthGetLogsBlockRangesFromEnv({ ETH_GET_LOGS_BLOCK_RANGE_1: "1000" }, "mainnet"),
).toStrictEqual({ "1": "1000" });
});

it("applies the global ETH_GET_LOGS_BLOCK_RANGE default to every chain in the namespace", () => {
expect(
buildEthGetLogsBlockRangesFromEnv({ ETH_GET_LOGS_BLOCK_RANGE: "1000" }, "mainnet"),
).toMatchObject({ "1": "1000", "8453": "1000" });
});

it("lets a chain-specific value override the global default", () => {
expect(
buildEthGetLogsBlockRangesFromEnv(
{ ETH_GET_LOGS_BLOCK_RANGE: "1000", ETH_GET_LOGS_BLOCK_RANGE_8453: "500" },
"mainnet",
),
).toMatchObject({ "1": "1000", "8453": "500" });
});

it("lets a chain-specific 0 take precedence over the global default", () => {
expect(
buildEthGetLogsBlockRangesFromEnv(
{ ETH_GET_LOGS_BLOCK_RANGE: "1000", ETH_GET_LOGS_BLOCK_RANGE_1: "0" },
"mainnet",
),
).toMatchObject({ "1": "0", "8453": "1000" });
});

it("ignores ETH_GET_LOGS_BLOCK_RANGE_<chainId> for chains outside the namespace", () => {
expect(
buildEthGetLogsBlockRangesFromEnv({ ETH_GET_LOGS_BLOCK_RANGE_999999: "1000" }, "mainnet"),
).toStrictEqual({});
});
});

describe("EthGetLogsBlockRangesSchema", () => {
it("parses an empty record to an empty Map", () => {
expect(EthGetLogsBlockRangesSchema.parse({})).toStrictEqual(new Map());
});

it("parses chain-id strings to numeric block ranges", () => {
expect(EthGetLogsBlockRangesSchema.parse({ "1": "1000", "8453": "500" })).toStrictEqual(
new Map([
[1, 1000],
[8453, 500],
]),
);
});

it("treats 0 as a disable sentinel and omits that chain", () => {
expect(EthGetLogsBlockRangesSchema.parse({ "1": "0", "8453": "1000" })).toStrictEqual(
new Map([[8453, 1000]]),
);
});

it.each(["abc", "-5", "1.5"])("rejects invalid block range %j", (value) => {
expect(() => EthGetLogsBlockRangesSchema.parse({ "1": value })).toThrow();
});
});
70 changes: 70 additions & 0 deletions apps/ensindexer/src/config/eth-get-logs-block-ranges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { ChainId, ChainIdString } from "enssdk";
import { z } from "zod/v4";

import { type Datasource, type ENSNamespaceId, getENSNamespace } from "@ensnode/datasources";
import { deserializeChainId, serializeChainId } from "@ensnode/ensnode-sdk";
import { makeChainIdStringSchema } from "@ensnode/ensnode-sdk/internal";

import type { EthGetLogsBlockRangeEnvironment } from "@/config/environment";

/**
* Builds the raw, per-chain `eth_getLogs` block range overrides from the environment, scoped to the
* chain IDs that appear in the specified `namespace`.
*
* For each chain in the namespace, a chain-specific `ETH_GET_LOGS_BLOCK_RANGE_${chainId}` takes
* precedence over the global `ETH_GET_LOGS_BLOCK_RANGE` default (a chain-specific `0` wins over the
* default and, once validated, disables the override for that chain). Variables for chains outside
* the namespace are ignored (same behavior as `RPC_URL_*`).
*
* NOTE: returns raw (unvalidated) string values; validation and the `0` disable semantics are
* applied in {@link EthGetLogsBlockRangesSchema}.
*/
export function buildEthGetLogsBlockRangesFromEnv(
env: EthGetLogsBlockRangeEnvironment,
namespace: ENSNamespaceId,
): Record<ChainIdString, string> {
const defaultValue = env.ETH_GET_LOGS_BLOCK_RANGE || undefined;

const chainsInNamespace = Object.entries(getENSNamespace(namespace)).map(
([, datasource]) => (datasource as Datasource).chain,
);

const ethGetLogsBlockRanges: Record<ChainIdString, string> = {};

for (const chain of chainsInNamespace) {
// a chain-specific value (including "0" to disable) takes precedence over the global default
const value = (env[`ETH_GET_LOGS_BLOCK_RANGE_${chain.id}`] || undefined) ?? defaultValue;
if (value !== undefined) {
ethGetLogsBlockRanges[serializeChainId(chain.id)] = value;
}
}

return ethGetLogsBlockRanges;
}

const EthGetLogsBlockRangeValueSchema = z.coerce
.number({ error: "ETH_GET_LOGS_BLOCK_RANGE must be a non-negative integer." })
.int({ error: "ETH_GET_LOGS_BLOCK_RANGE must be a non-negative integer." })
.min(0, { error: "ETH_GET_LOGS_BLOCK_RANGE must be a non-negative integer." });

/**
* Parses the raw per-chain `eth_getLogs` block range overrides into a `Map<ChainId, number>`,
* dropping any chain configured with `0` (the disable sentinel) so Ponder auto-determines its range.
*/
export const EthGetLogsBlockRangesSchema = z
.record(makeChainIdStringSchema("ETH_GET_LOGS_BLOCK_RANGE"), EthGetLogsBlockRangeValueSchema, {
error:
"ETH_GET_LOGS_BLOCK_RANGE configuration must be an object mapping valid chain IDs to non-negative integers.",
})
.transform((records) => {
const ethGetLogsBlockRanges = new Map<ChainId, number>();

for (const [chainIdString, blockRange] of Object.entries(records)) {
// 0 disables the override for a chain, so it is omitted (Ponder auto-determines the range)
if (blockRange > 0) {
ethGetLogsBlockRanges.set(deserializeChainId(chainIdString), blockRange);
}
}

return ethGetLogsBlockRanges;
});
16 changes: 16 additions & 0 deletions apps/ensindexer/src/config/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ function serializeRpcConfigs(
return serializedRpcConfigs;
}

/**
* Serialize eth_getLogs block ranges {@link ENSIndexerConfig.ethGetLogsBlockRanges}.
*/
function serializeEthGetLogsBlockRanges(
ethGetLogsBlockRanges: ENSIndexerConfig["ethGetLogsBlockRanges"],
): SerializedENSIndexerConfig["ethGetLogsBlockRanges"] {
const serializedEthGetLogsBlockRanges: SerializedENSIndexerConfig["ethGetLogsBlockRanges"] = {};

for (const [chainId, blockRange] of ethGetLogsBlockRanges.entries()) {
serializedEthGetLogsBlockRanges[serializeChainId(chainId)] = blockRange;
}

return serializedEthGetLogsBlockRanges;
}

/**
* Serialize redacted {@link ENSIndexerConfig} object.
*
Expand All @@ -51,5 +66,6 @@ export function serializeRedactedENSIndexerConfig(
namespace: redactedConfig.namespace,
plugins: redactedConfig.plugins,
rpcConfigs: serializeRpcConfigs(redactedConfig.rpcConfigs),
ethGetLogsBlockRanges: serializeEthGetLogsBlockRanges(redactedConfig.ethGetLogsBlockRanges),
} satisfies SerializedENSIndexerConfig;
}
10 changes: 9 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" | "ethGetLogsBlockRanges" | "plugins"
> {
/**
* Serialized representation of {@link ENSIndexerConfig.ensRainbowUrl}.
*/
Expand All @@ -47,6 +50,11 @@ export interface SerializedENSIndexerConfig
*/
rpcConfigs: Record<ChainIdString, SerializedRpcConfig>;

/**
* Serialized representation of {@link ENSIndexerConfig.ethGetLogsBlockRanges}.
*/
ethGetLogsBlockRanges: Record<ChainIdString, number>;

/**
* Serialized representation of {@link ENSIndexerConfig.plugins}.
*
Expand Down
33 changes: 33 additions & 0 deletions apps/ensindexer/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import type { BlockNumberRange, PluginName } from "@ensnode/ensnode-sdk";
import { RpcConfig, type RpcConfigs } from "@ensnode/ensnode-sdk/internal";
import type { EnsRainbowClientLabelSet } from "@ensnode/ensrainbow-sdk";

/**
* Effective per-chain cap on Ponder's maximum `eth_getLogs` block range (after resolving the global
* default and per-chain overrides), keyed by chain id.
*
* @invariant Each value is a positive integer.
*/
export type EthGetLogsBlockRanges = Map<ChainId, number>;

/**
* The complete runtime configuration for an ENSIndexer instance.
*/
Expand Down Expand Up @@ -104,6 +112,31 @@ export interface EnsIndexerConfig {
*/
rpcConfigs: RpcConfigs;

/**
* Effective per-chain cap on Ponder's maximum `eth_getLogs` block range, keyed by chain id, after
* resolving environment configuration.
*
* Configured via environment variables:
* - `ETH_GET_LOGS_BLOCK_RANGE` sets a default applied to every chain.
* - `ETH_GET_LOGS_BLOCK_RANGE_<chainId>` overrides that default for a specific chain.
* - `ETH_GET_LOGS_BLOCK_RANGE_<chainId>=0` disables the cap for that chain (ignoring the default),
* so Ponder auto-determines its range. `ETH_GET_LOGS_BLOCK_RANGE=0` is equivalent to unset.
*
* Ponder auto-determines a safe `eth_getLogs` block range per chain; these overrides let operators
* set a manual cap for RPC providers that reject Ponder's default range.
* @see https://ponder.sh/docs/config/chains#eth_getlogs-block-range
*
* Like {@link rpcConfigs}, this is a performance/connection tuning knob only: it does NOT affect
* indexed data and is intentionally excluded from the indexing-behavior Build ID. Changing it does
* not trigger a re-index.
*
* Invariants:
* - Keys are a subset of the chains in the configured {@link namespace}. A chain is absent when it
* has no effective cap (unset, or disabled with `0`), in which case Ponder auto-determines its
* range.
*/
ethGetLogsBlockRanges: EthGetLogsBlockRanges;

/**
* Indexed Chain IDs
*
Expand Down
Loading