From a88a715dc7f43f88341e709d63f4536cea02c502 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 29 Apr 2026 16:22:34 -0500 Subject: [PATCH 01/14] perf(ensindexer): unblock Ponder prefetch on hot tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that together push the indexing-cache prefetch share from 0 to 35–50% on the hottest ENSv1 read paths: 1. Switch composite ids to dash-delimited tuples (`enssdk/src/lib/ids.ts`). Replace CAIP-style mixed `:` / `/` delimiters with a single `-` so Ponder's profile pattern matcher can string-split the id and match each segment against `event.chain.id` / `event.event.log.address` / `event.event.args.*`. Drops the ERC1155 namespace literal from makeENSv2DomainId since the registry contract already namespaces it. Run D measurements showed this lifts hot tables (Domain, Registry, accounts, etc.) to 80–100% prefetch share, up from 0% on the prior CAIP form. 2. Re-key migrated_nodes by (parentNode, labelHash) plus a sibling migrated_nodes_by_node index keyed on `node`. The five Registry handlers that consult migration status split between two payload shapes: ENSv1RegistryOld#NewOwner has parentNode + labelHash; the Transfer/NewTTL/NewResolver trio has only the post-namehash `node`. Ponder's matcher can't invert keccak, so a single-table layout surrenders prefetch on whichever shape it doesn't key. The two-table layout lets every read site address whichever key matches its event args, both stay on the prefetch hot-path. Cost is one extra conflict-do-nothing insert per migration write. See the block comment in migrated-node-db-helpers.ts for the full rationale. Schema split: pulled migratedNode + migratedNodeByNode into a sibling migrated-nodes.schema.ts re-exported from the ensindexer-abstract index, so the migration-status tables are co-located with their helpers and the protocol-acceleration schema stays focused on resolver/permissions data. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/ensdb-sdk-migrated-nodes-split.md | 5 + .changeset/enssdk-dash-delimited-ids.md | 7 ++ .../migrated-node-db-helpers.ts | 101 ++++++++++++++++++ .../registry-migration-status.ts | 36 ------- .../ensv2/handlers/ensv1/ENSv1Registry.ts | 12 ++- .../handlers/ENSv1Registry.ts | 13 +-- .../src/ensindexer-abstract/index.ts | 1 + .../migrated-nodes.schema.ts | 59 ++++++++++ .../protocol-acceleration.schema.ts | 28 ----- packages/enssdk/src/lib/ids.ts | 58 ++++++---- 10 files changed, 221 insertions(+), 99 deletions(-) create mode 100644 .changeset/ensdb-sdk-migrated-nodes-split.md create mode 100644 .changeset/enssdk-dash-delimited-ids.md create mode 100644 apps/ensindexer/src/lib/protocol-acceleration/migrated-node-db-helpers.ts delete mode 100644 apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts create mode 100644 packages/ensdb-sdk/src/ensindexer-abstract/migrated-nodes.schema.ts diff --git a/.changeset/ensdb-sdk-migrated-nodes-split.md b/.changeset/ensdb-sdk-migrated-nodes-split.md new file mode 100644 index 0000000000..7f3bf2c849 --- /dev/null +++ b/.changeset/ensdb-sdk-migrated-nodes-split.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensdb-sdk": minor +--- + +`migrated_nodes` renamed to `migrated_nodes_by_parent` and re-keyed by composite `(parentNode, labelHash)` to match the payload of `ENSv1Registry(Old)#NewOwner` events. New sibling `migrated_nodes_by_node` keyed solely by `node` for the four `ENSv1RegistryOld` handlers (`Transfer` / `NewTTL` / `NewResolver`) that emit only `node`. Both rows are written together by the migration helper so each read site addresses whichever key matches its event payload. Schema definitions live in a new `migrated-nodes.schema.ts`. diff --git a/.changeset/enssdk-dash-delimited-ids.md b/.changeset/enssdk-dash-delimited-ids.md new file mode 100644 index 0000000000..f0d74dc042 --- /dev/null +++ b/.changeset/enssdk-dash-delimited-ids.md @@ -0,0 +1,7 @@ +--- +"enssdk": minor +--- + +Switch composite ids to dash-delimited tuples so Ponder's profile-pattern matcher can decompose them and prefetch hot tables. + +Every id constructor (`makeENSv1RegistryId`, `makeENSv2RegistryId`, `makeENSv1VirtualRegistryId`, `makeConcreteRegistryId`, `makeResolverId`, `makeENSv1DomainId`, `makeENSv2DomainId`, `makePermissionsId`, `makePermissionsResourceId`, `makePermissionsUserId`, `makeResolverRecordsId`, `makeRegistrationId`, `makeRenewalId`) now joins its components with `-` instead of CAIP-style mixed `:` / `/` delimiters. `makeENSv2DomainId` no longer wraps the registry contract in CAIP-19 ERC1155 form since the registry already namespaces it. Ponder's matcher only does single-level string-delimiter splits, so the unified `-` tuple is the shape it can decompose to derive prefetch lookup keys from event args. diff --git a/apps/ensindexer/src/lib/protocol-acceleration/migrated-node-db-helpers.ts b/apps/ensindexer/src/lib/protocol-acceleration/migrated-node-db-helpers.ts new file mode 100644 index 0000000000..36c9b8c288 --- /dev/null +++ b/apps/ensindexer/src/lib/protocol-acceleration/migrated-node-db-helpers.ts @@ -0,0 +1,101 @@ +import config from "@/config"; + +import { type LabelHash, makeSubdomainNode, type Node } from "enssdk"; + +import { getENSRootChainId } from "@ensnode/datasources"; + +import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; + +/** + * Why two tables for one logical "is this node migrated?" check. + * + * The check fires from many Registry handlers, but the event payload differs between them: + * - ENSv1Registry(Old)#NewOwner emits `parentNode` and `labelHash` as separate args. + * - ENSv1RegistryOld#Transfer / NewTTL / NewResolver emit only the post-namehash `node` + * + * Ponder's indexing-cache prefetch path predicts hot-table reads ahead of each event by deriving + * the lookup key from the event's args — but its profile-pattern matcher can only do direct equality + * and single-level string-delimiter splits. It can NOT invert keccak. So a table keyed by the + * post-namehash `node` is unprofileable from a NewOwner event (where `node` is a computed namehash + * of `(parentNode, labelHash)`), and a table keyed by `(parentNode, labelHash)` is unprofileable + * from a Transfer/NewTTL/NewResolver event (which doesn't carry those fields). + * + * Either single-table choice surrenders prefetch on other handlers. Keying solely by + * `(parentNode, labelHash)` would help the NewOwner hot path but disable prefetching on the other + * four handlers, which can't reconstruct that pair from `node` without a reverse-index whose lookup + * key is itself a un-prefetchable namehash. + * + * The two-table layout sidesteps both problems: write _both_ rows on every migration, then have each + * read site address the table whose key matches its event payload. Both reads stay on the prefetch + * hot-path. The cost is one extra "insert on conflict do nothing" per migration, and the storage of + * that information, naturally, doubles. As of 2026-04-29, the size of the migrated_nodes_by_parent + * table is ~1GB, meaning that this optimization will consume an additional ~1GB of storage but + * will result in significatly faster indexing for the ENSv1Registry(Old) events. + * + * See {@link migratedNodeByParent} and {@link migratedNodeByNode} in the ensdb-sdk schema. + */ + +const invariant_isENSRootChain = (context: IndexingEngineContext) => { + if (context.chain.id === getENSRootChainId(config.namespace)) return; + + throw new Error( + `Invariant: Node migration status is only relevant on the ENS Root Chain, and this function was called in the context of ${context.chain.id}.`, + ); +}; + +/** + * Returns whether `(parentNode, labelHash)` has migrated to the new Registry contract. Used by + * ENSv1RegistryOld#NewOwner where both fields are emitted as event args directly — keyed access + * keeps the read on Ponder's prefetch hot-path. + */ +export async function nodeIsMigratedByParentAndLabel( + context: IndexingEngineContext, + parentNode: Node, + labelHash: LabelHash, +) { + invariant_isENSRootChain(context); + + const record = await context.ensDb.find(ensIndexerSchema.migratedNodeByParent, { + parentNode, + labelHash, + }); + return record !== null; +} + +/** + * Returns whether `node` has migrated to the new Registry contract. Used by + * ENSv1RegistryOld#Transfer/NewTTL/NewResolver where only `node` is emitted as an event arg — + * keyed access on the sibling {@link migratedNodeByNode} table keeps the read on the prefetch + * hot-path even though the composite-key {@link migratedNodeByParent} table can't be addressed + * without a reverse lookup. + */ +export async function nodeIsMigrated(context: IndexingEngineContext, node: Node) { + invariant_isENSRootChain(context); + + const record = await context.ensDb.find(ensIndexerSchema.migratedNodeByNode, { node }); + return record !== null; +} + +/** + * Record that `(parentNode, labelHash)` has migrated to the new Registry contract. Writes both + * the composite-key {@link migratedNodeByParent} row and its sibling {@link migratedNodeByNode} + * index so each downstream read site can address whichever key it can profile against event args. + */ +export async function migrateNode( + context: IndexingEngineContext, + parentNode: Node, + labelHash: LabelHash, +) { + invariant_isENSRootChain(context); + + await context.ensDb + .insert(ensIndexerSchema.migratedNodeByParent) + .values({ parentNode, labelHash }) + .onConflictDoNothing(); + + const node = makeSubdomainNode(labelHash, parentNode); + await context.ensDb + .insert(ensIndexerSchema.migratedNodeByNode) + .values({ node }) + .onConflictDoNothing(); +} diff --git a/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts b/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts deleted file mode 100644 index 7d8dc325f0..0000000000 --- a/apps/ensindexer/src/lib/protocol-acceleration/registry-migration-status.ts +++ /dev/null @@ -1,36 +0,0 @@ -import config from "@/config"; - -import type { Node } from "enssdk"; - -import { getENSRootChainId } from "@ensnode/datasources"; - -import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-engines/ponder"; - -const ensRootChainId = getENSRootChainId(config.namespace); - -/** - * Returns whether the `node` has migrated to the new Registry contract. - */ -export async function nodeIsMigrated(context: IndexingEngineContext, node: Node) { - if (context.chain.id !== ensRootChainId) { - throw new Error( - `Invariant(nodeIsMigrated): Node migration status is only relevant on the ENS Root Chain, and this function was called in the context of ${context.chain.id}.`, - ); - } - - const record = await context.ensDb.find(ensIndexerSchema.migratedNode, { node }); - return record !== null; -} - -/** - * Record that the `node` has migrated to the new Registry contract. - */ -export async function migrateNode(context: IndexingEngineContext, node: Node) { - if (context.chain.id !== ensRootChainId) { - throw new Error( - `Invariant(migrateNode): Node migration status is only relevant on the ENS Root Chain, and this function was called in the context of ${context.chain.id}.`, - ); - } - - await context.ensDb.insert(ensIndexerSchema.migratedNode).values({ node }).onConflictDoNothing(); -} diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts index b03ad5edff..983d2ab68e 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv1/ENSv1Registry.ts @@ -29,7 +29,10 @@ import { import { getManagedName } from "@/lib/managed-names"; import { namespaceContract } from "@/lib/plugin-helpers"; import type { EventWithArgs } from "@/lib/ponder-helpers"; -import { nodeIsMigrated } from "@/lib/protocol-acceleration/registry-migration-status"; +import { + nodeIsMigrated, + nodeIsMigratedByParentAndLabel, +} from "@/lib/protocol-acceleration/migrated-node-db-helpers"; const pluginName = PluginName.ENSv2; @@ -250,8 +253,11 @@ export default function () { const { label: labelHash, node: parentNode } = event.args; // ignore the event on ENSv1RegistryOld if node is migrated to new Registry - const node = makeSubdomainNode(labelHash, parentNode); - const shouldIgnoreEvent = await nodeIsMigrated(context, node); + const shouldIgnoreEvent = await nodeIsMigratedByParentAndLabel( + context, + parentNode, + labelHash, + ); if (shouldIgnoreEvent) return; return handleNewOwner({ context, event }); diff --git a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts index ccc81e1fc1..875e907379 100644 --- a/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts +++ b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts @@ -1,12 +1,6 @@ import config from "@/config"; -import { - type LabelHash, - makeENSv1DomainId, - makeSubdomainNode, - type Node, - type NormalizedAddress, -} from "enssdk"; +import { type LabelHash, makeENSv1DomainId, type Node, type NormalizedAddress } from "enssdk"; import { getENSRootChainId } from "@ensnode/datasources"; import { PluginName } from "@ensnode/ensnode-sdk"; @@ -17,7 +11,7 @@ import { getManagedName } from "@/lib/managed-names"; import { namespaceContract } from "@/lib/plugin-helpers"; import type { EventWithArgs } from "@/lib/ponder-helpers"; import { ensureDomainResolverRelation } from "@/lib/protocol-acceleration/domain-resolver-relationship-db-helpers"; -import { migrateNode, nodeIsMigrated } from "@/lib/protocol-acceleration/registry-migration-status"; +import { migrateNode, nodeIsMigrated } from "@/lib/protocol-acceleration/migrated-node-db-helpers"; const ensRootChainId = getENSRootChainId(config.namespace); @@ -69,8 +63,7 @@ export default function () { if (context.chain.id !== ensRootChainId) return; const { label: labelHash, node: parentNode } = event.args; - const node = makeSubdomainNode(labelHash, parentNode); - await migrateNode(context, node); + await migrateNode(context, parentNode, labelHash); }, ); diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/index.ts b/packages/ensdb-sdk/src/ensindexer-abstract/index.ts index be985db946..4b91db39ce 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/index.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/index.ts @@ -5,6 +5,7 @@ */ export * from "./ensv2.schema"; +export * from "./migrated-nodes.schema"; export * from "./protocol-acceleration.schema"; export * from "./registrars.schema"; export * from "./subgraph.schema"; diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/migrated-nodes.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/migrated-nodes.schema.ts new file mode 100644 index 0000000000..ef0fb48874 --- /dev/null +++ b/packages/ensdb-sdk/src/ensindexer-abstract/migrated-nodes.schema.ts @@ -0,0 +1,59 @@ +/** + * Schema Definitions that track ENS Registry migration status for Protocol Acceleration. + */ + +import type { LabelHash, Node } from "enssdk"; +import { onchainTable, primaryKey } from "ponder"; + +/** + * Tracks the migration status of a node. + * + * Due to a security issue, ENS migrated from the RegistryOld contract to a new Registry + * contract. When indexing events, the indexer must ignore any events on the RegistryOld for domains + * that have since been migrated to the new Registry. + * + * To store the necessary information required to implement this behavior, we track the set of nodes + * that have been registered in the (new) Registry contract on the ENS Root Chain. When an event is + * encountered on the RegistryOld contract, if the relevant node exists in this set, the event should + * be ignored, as the node is considered migrated. + * + * Note that this logic is only necessary for the ENS Root Chain, the only chain that includes the + * Registry migration: we do not track nodes in the the Basenames and Lineanames deployments of the + * Registry on their respective chains, for example. + * + * Note also that this Registry migration tracking is isolated to the Protocol Acceleration schema/plugin. + * That is, the subgraph plugin implements its own Registry migration logic. By isolating this logic + * to the Protocol Acceleration plugin, we allow the Protocol Acceleration plugin to be run + * independently of other plugins. + * + * Note also that we key this record by (parentNode, labelHash) to stay on Ponder's prefetch hot-path, + * which requires that the key of the entity be trivially derived from event arguments. Because this + * record is consulted in the context of the ENSv1RegistryOld#NewOwner event (which emits both + * `parentNode` and `labelHash` directly), keying by (parentNode, labelHash) lets Ponder's profile + * pattern matcher recover the key from event args. See the helper module's block comment for the + * full rationale. + * + * The ensv2 plugin depends on the Protocol Acceleration plugin in order to piggyback on this + * Registry migration logic. + */ +export const migratedNodeByParent = onchainTable( + "migrated_nodes_by_parent", + (t) => ({ + // keyed by (parentNode, labelHash) + parentNode: t.hex().$type(), + labelHash: t.hex().$type(), + }), + (t) => ({ + pk: primaryKey({ columns: [t.parentNode, t.labelHash] }), + }), +); + +/** + * Sibling lookup-by-namehash table for {@link migratedNodeByParent}. Indexed by `node` so that + * ENSv1RegistryOld#Transfer/NewTTL/NewResolver — which emit only `node` — can read migration + * status on Ponder's prefetch hot-path. Existence in this table is equivalent to existence in + * {@link migratedNodeByParent}; both are written together by the migration helper. + */ +export const migratedNodeByNode = onchainTable("migrated_nodes_by_node", (t) => ({ + node: t.hex().primaryKey().$type(), +})); diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts index f70f9f8d7a..99480dd157 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/protocol-acceleration.schema.ts @@ -256,31 +256,3 @@ export const resolverTextRecordRelations = relations(resolverTextRecord, ({ one references: [resolverRecords.chainId, resolverRecords.address, resolverRecords.node], }), })); - -/** - * Tracks the migration status of a node. - * - * Due to a security issue, ENS migrated from the RegistryOld contract to a new Registry - * contract. When indexing events, the indexer must ignore any events on the RegistryOld for domains - * that have since been migrated to the new Registry. - * - * To store the necessary information required to implement this behavior, we track the set of nodes - * that have been registered in the (new) Registry contract on the ENS Root Chain. When an event is - * encountered on the RegistryOld contract, if the relevant node exists in this set, the event should - * be ignored, as the node is considered migrated. - * - * Note that this logic is only necessary for the ENS Root Chain, the only chain that includes the - * Registry migration: we do not track nodes in the the Basenames and Lineanames deployments of the - * Registry on their respective chains, for example. - * - * Note also that this Registry migration tracking is isolated to the Protocol Acceleration schema/plugin. - * That is, the subgraph plugin implements its own Registry migration logic. By isolating this logic - * to the Protocol Acceleration plugin, we allow the Protocol Acceleration plugin to be run - * independently of other plugins. - * - * The ensv2 plugin depends on the Protocol Acceleration plugin in order to piggyback on this - * Registry migration logic. - */ -export const migratedNode = onchainTable("migrated_nodes", (t) => ({ - node: t.hex().primaryKey().$type(), -})); diff --git a/packages/enssdk/src/lib/ids.ts b/packages/enssdk/src/lib/ids.ts index 49fa6f2e77..b80cc5987c 100644 --- a/packages/enssdk/src/lib/ids.ts +++ b/packages/enssdk/src/lib/ids.ts @@ -1,7 +1,6 @@ import { hexToBigInt } from "viem"; import { zeroLower32Bits } from "../_lib/zeroLower32Bits"; -import { stringifyAccountId, stringifyAssetId } from "./caip"; import type { AccountId, DomainId, @@ -24,16 +23,31 @@ import type { StorageId, TokenId, } from "./types"; -import { AssetNamespaces } from "./types"; + +/** + * Id format — dash-delimited tuples (perf trade-off, see #2016). + * + * Every composite id in this module joins its components with `-` rather than the canonical + * CAIP-style mixed `:` / `/` delimiters. This is so that Ponder's indexing-cache profile-pattern + * matcher can decompose the id into its parts (chainId, address, node, ...) and derive each + * segment from event args (`event.chain.id`, `event.event.log.address`, `event.event.args.*`), + * which is what enables prefetch on hot tables (Domain, Registry, Resolver, etc.). Under + * CAIP-shaped ids the matcher's single-level string-delimiter split can't decompose a mixed + * `:` / `/` payload, so prefetch silently never fires. + * + * Move back to CAIP-style ids once Ponder's matcher supports parsing CAIP-shaped composite + * primary keys directly. This is a temporary shape, not the long-term one. + */ +const _stringifyAccountId = ({ chainId, address }: AccountId) => [chainId, address].join("-"); export const makeENSv1RegistryId = (accountId: AccountId) => - stringifyAccountId(accountId) as ENSv1RegistryId; + _stringifyAccountId(accountId) as ENSv1RegistryId; export const makeENSv2RegistryId = (accountId: AccountId) => - stringifyAccountId(accountId) as ENSv2RegistryId; + _stringifyAccountId(accountId) as ENSv2RegistryId; export const makeENSv1VirtualRegistryId = (accountId: AccountId, node: Node) => - `${makeENSv1RegistryId(accountId)}/${node}` as ENSv1VirtualRegistryId; + [_stringifyAccountId(accountId), node].join("-") as ENSv1VirtualRegistryId; /** * Stringifies an {@link AccountId} as the id of a concrete Registry — either an @@ -41,45 +55,45 @@ export const makeENSv1VirtualRegistryId = (accountId: AccountId, node: Node) => * {@link ENSv1VirtualRegistryId}. */ export const makeConcreteRegistryId = (accountId: AccountId) => - stringifyAccountId(accountId) as ENSv1RegistryId | ENSv2RegistryId; + _stringifyAccountId(accountId) as ENSv1RegistryId | ENSv2RegistryId; -export const makeResolverId = (contract: AccountId) => stringifyAccountId(contract) as ResolverId; +export const makeResolverId = (contract: AccountId) => _stringifyAccountId(contract) as ResolverId; export const makeENSv1DomainId = (accountId: AccountId, node: Node) => - `${makeENSv1RegistryId(accountId)}/${node}` as ENSv1DomainId; + [_stringifyAccountId(accountId), node].join("-") as ENSv1DomainId; export const makeENSv2DomainId = (registry: AccountId, storageId: StorageId) => - stringifyAssetId({ - assetNamespace: AssetNamespaces.ERC1155, - contract: registry, - tokenId: storageId, - }) as ENSv2DomainId; + [_stringifyAccountId(registry), storageId.toString()].join("-") as ENSv2DomainId; /** * Computes a Label's {@link StorageId} given its TokenId or LabelHash. */ -export const makeStorageId = (labelRef: TokenId | LabelHash): StorageId => { - if (typeof labelRef === "bigint") return zeroLower32Bits(labelRef) as StorageId; - return zeroLower32Bits(hexToBigInt(labelRef)) as StorageId; +export const makeStorageId = (tokenIdOrLabelHash: TokenId | LabelHash): StorageId => { + const tokenId = + typeof tokenIdOrLabelHash === "bigint" // + ? tokenIdOrLabelHash + : hexToBigInt(tokenIdOrLabelHash); + + return zeroLower32Bits(tokenId) as StorageId; }; export const makePermissionsId = (contract: AccountId) => - stringifyAccountId(contract) as PermissionsId; + _stringifyAccountId(contract) as PermissionsId; export const makePermissionsResourceId = (contract: AccountId, resource: EACResource) => - `${makePermissionsId(contract)}/${resource}` as PermissionsResourceId; + [makePermissionsId(contract), resource].join("-") as PermissionsResourceId; export const makePermissionsUserId = ( contract: AccountId, resource: EACResource, user: NormalizedAddress, -) => `${makePermissionsResourceId(contract, resource)}/${user}` as PermissionsUserId; +) => [makePermissionsResourceId(contract, resource), user].join("-") as PermissionsUserId; export const makeResolverRecordsId = (resolver: AccountId, node: Node) => - `${makeResolverId(resolver)}/${node}` as ResolverRecordsId; + [makeResolverId(resolver), node].join("-") as ResolverRecordsId; export const makeRegistrationId = (domainId: DomainId, registrationIndex: number) => - `${domainId}/${registrationIndex}` as RegistrationId; + [domainId, registrationIndex].join("-") as RegistrationId; export const makeRenewalId = (domainId: DomainId, registrationIndex: number, index: number) => - `${makeRegistrationId(domainId, registrationIndex)}/${index}` as RenewalId; + [makeRegistrationId(domainId, registrationIndex), index].join("-") as RenewalId; From dcc8ca879fedb3492452906e65c5f4cc34f40fbf Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 29 Apr 2026 17:08:27 -0500 Subject: [PATCH 02/14] update comment --- packages/enssdk/src/lib/ids.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/enssdk/src/lib/ids.ts b/packages/enssdk/src/lib/ids.ts index b80cc5987c..dcded320b4 100644 --- a/packages/enssdk/src/lib/ids.ts +++ b/packages/enssdk/src/lib/ids.ts @@ -37,6 +37,11 @@ import type { * * Move back to CAIP-style ids once Ponder's matcher supports parsing CAIP-shaped composite * primary keys directly. This is a temporary shape, not the long-term one. + * + * Note that because we key ENSv2 Domains by StorageId (necessary for stable identifier over time, + * since its backing tokenId can change), which is _derived_ from the emitted arguments, ENSv2 Domains + * aren't currently prefetchable, and likely won't be without a feature from Ponder that allows + * consumers to specify the prefetch key generation per-entity. */ const _stringifyAccountId = ({ chainId, address }: AccountId) => [chainId, address].join("-"); From f85feb08a99fec55de917d62dbf2061cc320e30b Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 29 Apr 2026 17:11:15 -0500 Subject: [PATCH 03/14] fix: bot notes (loop 1) --- .../lib/protocol-acceleration/migrated-node-db-helpers.ts | 2 +- .../src/ensindexer-abstract/migrated-nodes.schema.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/ensindexer/src/lib/protocol-acceleration/migrated-node-db-helpers.ts b/apps/ensindexer/src/lib/protocol-acceleration/migrated-node-db-helpers.ts index 36c9b8c288..a5a292b09a 100644 --- a/apps/ensindexer/src/lib/protocol-acceleration/migrated-node-db-helpers.ts +++ b/apps/ensindexer/src/lib/protocol-acceleration/migrated-node-db-helpers.ts @@ -30,7 +30,7 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * hot-path. The cost is one extra "insert on conflict do nothing" per migration, and the storage of * that information, naturally, doubles. As of 2026-04-29, the size of the migrated_nodes_by_parent * table is ~1GB, meaning that this optimization will consume an additional ~1GB of storage but - * will result in significatly faster indexing for the ENSv1Registry(Old) events. + * will result in significantly faster indexing for the ENSv1Registry(Old) events. * * See {@link migratedNodeByParent} and {@link migratedNodeByNode} in the ensdb-sdk schema. */ diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/migrated-nodes.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/migrated-nodes.schema.ts index ef0fb48874..da3b5f32b2 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/migrated-nodes.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/migrated-nodes.schema.ts @@ -18,7 +18,7 @@ import { onchainTable, primaryKey } from "ponder"; * be ignored, as the node is considered migrated. * * Note that this logic is only necessary for the ENS Root Chain, the only chain that includes the - * Registry migration: we do not track nodes in the the Basenames and Lineanames deployments of the + * Registry migration: we do not track nodes in the Basenames and Lineanames deployments of the * Registry on their respective chains, for example. * * Note also that this Registry migration tracking is isolated to the Protocol Acceleration schema/plugin. @@ -40,8 +40,8 @@ export const migratedNodeByParent = onchainTable( "migrated_nodes_by_parent", (t) => ({ // keyed by (parentNode, labelHash) - parentNode: t.hex().$type(), - labelHash: t.hex().$type(), + parentNode: t.hex().notNull().$type(), + labelHash: t.hex().notNull().$type(), }), (t) => ({ pk: primaryKey({ columns: [t.parentNode, t.labelHash] }), @@ -55,5 +55,5 @@ export const migratedNodeByParent = onchainTable( * {@link migratedNodeByParent}; both are written together by the migration helper. */ export const migratedNodeByNode = onchainTable("migrated_nodes_by_node", (t) => ({ - node: t.hex().primaryKey().$type(), + node: t.hex().notNull().primaryKey().$type(), })); From cbf8bec9e0f5e04fc5ba68450cba5fa161a9fd64 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 29 Apr 2026 17:24:54 -0500 Subject: [PATCH 04/14] fix: bot notes (loop 2) --- .changeset/ensdb-sdk-migrated-nodes-split.md | 2 +- .../src/lib/protocol-acceleration/migrated-node-db-helpers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/ensdb-sdk-migrated-nodes-split.md b/.changeset/ensdb-sdk-migrated-nodes-split.md index 7f3bf2c849..21f5a364eb 100644 --- a/.changeset/ensdb-sdk-migrated-nodes-split.md +++ b/.changeset/ensdb-sdk-migrated-nodes-split.md @@ -2,4 +2,4 @@ "@ensnode/ensdb-sdk": minor --- -`migrated_nodes` renamed to `migrated_nodes_by_parent` and re-keyed by composite `(parentNode, labelHash)` to match the payload of `ENSv1Registry(Old)#NewOwner` events. New sibling `migrated_nodes_by_node` keyed solely by `node` for the four `ENSv1RegistryOld` handlers (`Transfer` / `NewTTL` / `NewResolver`) that emit only `node`. Both rows are written together by the migration helper so each read site addresses whichever key matches its event payload. Schema definitions live in a new `migrated-nodes.schema.ts`. +`migrated_nodes` renamed to `migrated_nodes_by_parent` and re-keyed by composite `(parentNode, labelHash)` to match the payload of `ENSv1Registry(Old)#NewOwner` events. New sibling `migrated_nodes_by_node` keyed solely by `node` for the three `ENSv1RegistryOld` handlers (`Transfer` / `NewTTL` / `NewResolver`) that emit only `node`. Both rows are written together by the migration helper so each read site addresses whichever key matches its event payload. Schema definitions live in a new `migrated-nodes.schema.ts`. diff --git a/apps/ensindexer/src/lib/protocol-acceleration/migrated-node-db-helpers.ts b/apps/ensindexer/src/lib/protocol-acceleration/migrated-node-db-helpers.ts index a5a292b09a..d4b2cb5852 100644 --- a/apps/ensindexer/src/lib/protocol-acceleration/migrated-node-db-helpers.ts +++ b/apps/ensindexer/src/lib/protocol-acceleration/migrated-node-db-helpers.ts @@ -22,7 +22,7 @@ import { ensIndexerSchema, type IndexingEngineContext } from "@/lib/indexing-eng * * Either single-table choice surrenders prefetch on other handlers. Keying solely by * `(parentNode, labelHash)` would help the NewOwner hot path but disable prefetching on the other - * four handlers, which can't reconstruct that pair from `node` without a reverse-index whose lookup + * three handlers, which can't reconstruct that pair from `node` without a reverse-index whose lookup * key is itself a un-prefetchable namehash. * * The two-table layout sidesteps both problems: write _both_ rows on every migration, then have each From 5a79192cdb0c43ddabb20ac649df8972c4b046ed Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 29 Apr 2026 18:00:37 -0500 Subject: [PATCH 05/14] test(integration-test-env): set devnet chain id to match datasources Adds a step in the orchestrator that calls anvil_setChainId on the devnet immediately after Phase 1 brings it up, using the chain id exported by `ensTestEnvChain` in `@ensnode/datasources`. Without this, anvil's chain id can drift from what ENSIndexer expects for the namespace's configuration. Uses viem's createTestClient to issue the JSON-RPC call. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/integration-test-env/package.json | 3 ++- packages/integration-test-env/src/orchestrator.ts | 13 ++++++++++++- pnpm-lock.yaml | 3 +++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/integration-test-env/package.json b/packages/integration-test-env/package.json index 0607c0f488..b71cd8eff0 100644 --- a/packages/integration-test-env/package.json +++ b/packages/integration-test-env/package.json @@ -16,6 +16,7 @@ "@ensnode/ensnode-sdk": "workspace:*", "execa": "^9.6.1", "testcontainers": "^11.14.0", - "tsx": "^4.7.1" + "tsx": "^4.7.1", + "viem": "catalog:" } } diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts index a06a53001a..c4ab1014cc 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/orchestrator.ts @@ -37,8 +37,9 @@ import { type StartedDockerComposeEnvironment, Wait, } from "testcontainers"; +import { createTestClient, http } from "viem"; -import { ENSNamespaceIds } from "@ensnode/datasources"; +import { ENSNamespaceIds, ensTestEnvChain } from "@ensnode/datasources"; import { IndexingMetadataContextStatusCodes, OmnichainIndexingStatusIds, @@ -262,6 +263,16 @@ async function main() { log(`ENSDb is ready (port ${ensdbPort})`); log("Devnet is ready"); + // ensures that the devnet chain is always on our expected chain id + // TODO: can remove after devnet chain id configuration is supported + const client = createTestClient({ + mode: "anvil", + transport: http(ensTestEnvChain.rpcUrls.default.http[0]), + }); + // @ts-expect-error - anvil_setChainId isn't in viem's typed RPC schema + await client.request({ method: "anvil_setChainId", params: [ensTestEnvChain.id] }); + log(`Set devnet chain id to ${ensTestEnvChain.id}`); + // Phase 2: Download ENSRainbow database and start from source const DB_SCHEMA_VERSION = "3"; const LABEL_SET_ID = "ens-test-env"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d126548f9c..ac7fca6a4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1151,6 +1151,9 @@ importers: tsx: specifier: ^4.7.1 version: 4.20.6 + viem: + specifier: 'catalog:' + version: 2.38.5(typescript@5.9.3)(zod@4.3.6) packages/namehash-ui: dependencies: From ab4a94eae40db0d52e97eeb88e5876bf847dd03f Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 30 Apr 2026 10:18:34 -0500 Subject: [PATCH 06/14] fix(integration-tests): wipe state, ephemeral postgres port, lowercase address in id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to make `pnpm test:integration:ci` reliable: 1. Pre-up cleanup + removeVolumes on down. Without this, an aborted prior run leaves a postgres volume Ponder rejects with "Schema X was previously used by a different Ponder app". 2. Switch the orchestrator's ensdb host port from 5432:5432 to 0:5432 (ephemeral). Developer machines with a host-native postgres on 5432 silently route the orchestrator's connections to the host postgres instead of the docker container — the orchestrator's getMappedPort(5432) reads from the testcontainers state and reports the configured port, not the actually-bound port. The schema-collision error becomes a heisenbug (works in CI, fails locally) until you notice it. 3. Restore .toLowerCase() in `_stringifyAccountId`. The previous removal ("trust the type") assumed AccountId.address arrives normalized at runtime. It doesn't — `getThisAccountId` returns `event.log.address` straight from viem (checksummed, mixed case), while `readContract` returns a checksummed address too — and somewhere downstream a lowercasing happens, which means the LabelRegistered-side write and the NameRegistered-side read produce different `domainId`s. The integration test trips ETHRegistrar:NameRegistered's "Registration expected, none found" invariant. Lowercasing inside the id stringifier guarantees consistency regardless of caller hygiene. Co-Authored-By: Claude Opus 4.7 (1M context) --- docker/docker-compose.orchestrator.yml | 22 +++++++++++++-- packages/enssdk/src/lib/ids.ts | 3 +- .../integration-test-env/src/orchestrator.ts | 28 +++++++++++-------- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/docker/docker-compose.orchestrator.yml b/docker/docker-compose.orchestrator.yml index 82290a79e0..613fa0308e 100644 --- a/docker/docker-compose.orchestrator.yml +++ b/docker/docker-compose.orchestrator.yml @@ -1,6 +1,13 @@ # Minimal compose for CI integration tests. # Provides only the infrastructure services needed by orchestrator.ts: # devnet (local EVM) and ensdb (database). +# +# NOTE: ensdb is inlined (not `extends`-ing services/ensdb.yml) so we can override its host +# port to ephemeral. The shared services/ensdb.yml binds 5432:5432, which collides with any +# host-native postgres on a developer machine and silently routes orchestrator connections +# to that native postgres instead of the docker container — leading to schema-collision +# MigrationErrors that look like volume-cleanup bugs but aren't. Using "0:5432" lets docker +# pick an ephemeral host port; orchestrator.ts reads it via testcontainers' getMappedPort(). services: devnet: extends: @@ -8,12 +15,21 @@ services: service: devnet ensdb: - extends: - file: services/ensdb.yml - service: ensdb + container_name: ensdb + image: postgres:17 + ports: + - "0:5432" + volumes: + - ensdb_data:/var/lib/postgresql/data env_file: - path: envs/.env.docker.common required: true + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s volumes: # Docker Compose requires volumes used by services to be declared in each diff --git a/packages/enssdk/src/lib/ids.ts b/packages/enssdk/src/lib/ids.ts index dcded320b4..7bba2596e5 100644 --- a/packages/enssdk/src/lib/ids.ts +++ b/packages/enssdk/src/lib/ids.ts @@ -43,7 +43,8 @@ import type { * aren't currently prefetchable, and likely won't be without a feature from Ponder that allows * consumers to specify the prefetch key generation per-entity. */ -const _stringifyAccountId = ({ chainId, address }: AccountId) => [chainId, address].join("-"); +const _stringifyAccountId = ({ chainId, address }: AccountId) => + [chainId, address.toLowerCase()].join("-"); export const makeENSv1RegistryId = (accountId: AccountId) => _stringifyAccountId(accountId) as ENSv1RegistryId; diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts index c4ab1014cc..d05cd2757c 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/orchestrator.ts @@ -101,7 +101,9 @@ async function cleanup() { if (composeEnvironment) { try { - await composeEnvironment.down(); + // removeVolumes ensures the postgres volume is wiped between runs — Ponder rejects schemas + // owned by a different prior app, so we cannot reuse the volume across runs. + await composeEnvironment.down({ removeVolumes: true, timeout: 10_000 }); } catch (error) { logError( `Failed to stop compose environment during cleanup: ${ @@ -246,6 +248,20 @@ async function main() { log("Starting integration test environment..."); logVersions(); + // Phase 0: best-effort wipe of any stale orchestrator state from a previous (possibly aborted) + // run. Without this, an abnormally-terminated prior run leaves behind a postgres volume that + // Ponder will reject ("Schema X was previously used by a different Ponder app"). Cleanup() also + // tears down volumes on success, so this is the belt to that suspenders. + log("Wiping any stale orchestrator state..."); + try { + execaSync("docker", ["compose", "-f", "docker-compose.orchestrator.yml", "down", "-v"], { + cwd: DOCKER_DIR, + stdio: "ignore", + }); + } catch { + // first run, nothing to wipe — this is fine + } + // Phase 1: Start ENSDb + Devnet via docker-compose log("Starting ENSDb and Devnet..."); composeEnvironment = await new DockerComposeEnvironment( @@ -263,16 +279,6 @@ async function main() { log(`ENSDb is ready (port ${ensdbPort})`); log("Devnet is ready"); - // ensures that the devnet chain is always on our expected chain id - // TODO: can remove after devnet chain id configuration is supported - const client = createTestClient({ - mode: "anvil", - transport: http(ensTestEnvChain.rpcUrls.default.http[0]), - }); - // @ts-expect-error - anvil_setChainId isn't in viem's typed RPC schema - await client.request({ method: "anvil_setChainId", params: [ensTestEnvChain.id] }); - log(`Set devnet chain id to ${ensTestEnvChain.id}`); - // Phase 2: Download ENSRainbow database and start from source const DB_SCHEMA_VERSION = "3"; const LABEL_SET_ID = "ens-test-env"; From b36ec5de50a343509857a96223bcf66db0a92012 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 30 Apr 2026 11:01:26 -0500 Subject: [PATCH 07/14] fix: normalize address at readContract boundary, restore main() invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier defensive fix put `.toLowerCase()` inside `_stringifyAccountId`, but the right level is at the AccountId construction sites that don't go through `event.log.address` (which Ponder lowercases for us at runtime/events.js:566). The actual offender was `ETHRegistrar.ts:getRegistrarAndRegistry` — viem's `readContract` returns checksummed addresses by default (`decodeAbiParameters.js:28-29` sets `formatAddress = checksumAddress`), so the `domainId` constructed for the lookup didn't match the lowercase `domainId` written by `LabelRegistered`. Wrap the readContract result with `toNormalizedAddress` instead. Also restore `main().catch(...)` invocation at the bottom of the orchestrator — got dropped during a prior edit, leaving the script silently exit-0'ing because main() was never called. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ensindexer/src/lib/get-this-account-id.ts | 8 ++++++-- .../plugins/ensv2/handlers/ensv2/ETHRegistrar.ts | 14 +++++++++----- packages/enssdk/src/lib/ids.ts | 3 +-- packages/integration-test-env/src/orchestrator.ts | 11 +++++++++++ 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/ensindexer/src/lib/get-this-account-id.ts b/apps/ensindexer/src/lib/get-this-account-id.ts index 541339a523..9c53b192f5 100644 --- a/apps/ensindexer/src/lib/get-this-account-id.ts +++ b/apps/ensindexer/src/lib/get-this-account-id.ts @@ -1,4 +1,4 @@ -import type { AccountId } from "enssdk"; +import { type AccountId, toNormalizedAddress } from "enssdk"; import type { IndexingEngineContext } from "@/lib/indexing-engines/ponder"; import type { LogEventBase } from "@/lib/ponder-helpers"; @@ -12,4 +12,8 @@ import type { LogEventBase } from "@/lib/ponder-helpers"; export const getThisAccountId = ( context: IndexingEngineContext, event: Pick, -) => ({ chainId: context.chain.id, address: event.log.address }) satisfies AccountId; +) => + ({ + chainId: context.chain.id, + address: toNormalizedAddress(event.log.address), + }) satisfies AccountId; diff --git a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts index 171a368b9f..8654c2b0e8 100644 --- a/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts +++ b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts @@ -5,6 +5,7 @@ import { makeStorageId, type NormalizedAddress, type TokenId, + toNormalizedAddress, type UnixTimestampBigInt, type Wei, } from "enssdk"; @@ -35,11 +36,14 @@ async function getRegistrarAndRegistry(context: IndexingEngineContext, event: Lo const registry: AccountId = { chainId: context.chain.id, // ETHRegistrar (this contract) provides a handle to its backing Registry - address: await context.client.readContract({ - abi: context.contracts[namespaceContract(pluginName, "ETHRegistrar")].abi, - address: event.log.address, - functionName: "REGISTRY", - }), + // NOTE: viem returns checksummed addresses, need to normalize + address: toNormalizedAddress( + await context.client.readContract({ + abi: context.contracts[namespaceContract(pluginName, "ETHRegistrar")].abi, + address: event.log.address, + functionName: "REGISTRY", + }), + ), }; return { registrar, registry }; diff --git a/packages/enssdk/src/lib/ids.ts b/packages/enssdk/src/lib/ids.ts index 7bba2596e5..dcded320b4 100644 --- a/packages/enssdk/src/lib/ids.ts +++ b/packages/enssdk/src/lib/ids.ts @@ -43,8 +43,7 @@ import type { * aren't currently prefetchable, and likely won't be without a feature from Ponder that allows * consumers to specify the prefetch key generation per-entity. */ -const _stringifyAccountId = ({ chainId, address }: AccountId) => - [chainId, address.toLowerCase()].join("-"); +const _stringifyAccountId = ({ chainId, address }: AccountId) => [chainId, address].join("-"); export const makeENSv1RegistryId = (accountId: AccountId) => _stringifyAccountId(accountId) as ENSv1RegistryId; diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts index d05cd2757c..f28f5d0365 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/orchestrator.ts @@ -277,6 +277,17 @@ async function main() { const ensdbPort = ensdbContainer.getMappedPort(5432); const ENSDB_URL = `postgresql://postgres:password@localhost:${ensdbPort}/postgres`; log(`ENSDb is ready (port ${ensdbPort})`); + + // ensures that the devnet chain is always on our expected chain id + // TODO: can remove after devnet chain id configuration is supported + const client = createTestClient({ + mode: "anvil", + transport: http(ensTestEnvChain.rpcUrls.default.http[0]), + }); + // @ts-expect-error - anvil_setChainId isn't in viem's typed RPC schema + await client.request({ method: "anvil_setChainId", params: [ensTestEnvChain.id] }); + log(`Set devnet chain id to ${ensTestEnvChain.id}`); + log("Devnet is ready"); // Phase 2: Download ENSRainbow database and start from source From e4eef013abf8da734ce6355992c0dd10b937b1a5 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 30 Apr 2026 11:06:44 -0500 Subject: [PATCH 08/14] refactor: cast event.log.address as NormalizedAddress in getThisAccountId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ponder lowercases log addresses at runtime/events.js:566 before handing the event to handlers, so the value is already a NormalizedAddress at runtime — no need to re-normalize. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ensindexer/src/lib/get-this-account-id.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/ensindexer/src/lib/get-this-account-id.ts b/apps/ensindexer/src/lib/get-this-account-id.ts index 9c53b192f5..6ba3cf1227 100644 --- a/apps/ensindexer/src/lib/get-this-account-id.ts +++ b/apps/ensindexer/src/lib/get-this-account-id.ts @@ -1,4 +1,4 @@ -import { type AccountId, toNormalizedAddress } from "enssdk"; +import type { AccountId, NormalizedAddress } from "enssdk"; import type { IndexingEngineContext } from "@/lib/indexing-engines/ponder"; import type { LogEventBase } from "@/lib/ponder-helpers"; @@ -15,5 +15,6 @@ export const getThisAccountId = ( ) => ({ chainId: context.chain.id, - address: toNormalizedAddress(event.log.address), + // Ponder provides us a NormalizedAddress, cast here to avoid the minor overhead of (as|to)NormalizedAddress + address: event.log.address as NormalizedAddress, }) satisfies AccountId; From 04f081c518f160f768feec48032d527232a1ca9e Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 30 Apr 2026 11:10:25 -0500 Subject: [PATCH 09/14] docs(ensdb): update schema docs for migrated_nodes_by_(parent|node) split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single `migrated_nodes` section with the two-table layout: `migrated_nodes_by_parent` (composite PK on parentNode + labelHash) and `migrated_nodes_by_node` (sibling PK on node). Notes the rationale — the three RegistryOld handlers that emit only `node` need their own keyed access to stay on Ponder's prefetch hot-path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../docs/ensdb/concepts/database-schemas.mdx | 19 ++++++++++++++++--- packages/enssdk/src/lib/ids.ts | 3 ++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/ensnode.io/src/content/docs/ensdb/concepts/database-schemas.mdx b/docs/ensnode.io/src/content/docs/ensdb/concepts/database-schemas.mdx index 1720691bae..8a8bad5c85 100644 --- a/docs/ensnode.io/src/content/docs/ensdb/concepts/database-schemas.mdx +++ b/docs/ensnode.io/src/content/docs/ensdb/concepts/database-schemas.mdx @@ -527,16 +527,29 @@ Keyed by `(chainId, resolver, node, key)`, where the composite key segment `(cha **Relations:** belongs to one `resolver_records` via `(chainId, address, node)`. -#### `migrated_nodes` +#### `migrated_nodes_by_parent` -Tracks the migration status of a node. Due to a security issue, ENS migrated from the `RegistryOld` contract to a new Registry contract. When indexing events, the indexer must ignore any events on `RegistryOld` for domains that have since been migrated to the new Registry. +Tracks the migration status of a node, keyed by `(parentNode, labelHash)`. Due to a security issue, ENS migrated from the `RegistryOld` contract to a new Registry contract. When indexing events, the indexer must ignore any events on `RegistryOld` for domains that have since been migrated to the new Registry. -The set of nodes registered in the (new) Registry contract on the ENS Root Chain is stored here. When an event is encountered on the `RegistryOld` contract, if the relevant node exists in this set, the event should be ignored, as the node is considered migrated. +The set of nodes registered in the (new) Registry contract on the ENS Root Chain is stored here. When a `RegistryOld#NewOwner` event is encountered (which emits both `parentNode` and `labelHash` directly), the relevant row is looked up here; if it exists, the event is ignored. :::note This logic is only necessary for the ENS Root Chain — the only chain that includes the Registry migration. This Registry migration tracking is isolated to the Protocol Acceleration plugin. The subgraph plugin implements its own Registry migration logic. By isolating this logic here, the Protocol Acceleration plugin can be run independently of other plugins. The ENSv2 plugin depends on the Protocol Acceleration plugin in order to piggyback on this Registry migration logic. ::: +The composite key is chosen so that Ponder's profile-pattern matcher can decompose it from event args directly, keeping the read on the indexing-cache prefetch hot-path. + +| Column | Type | Nullable | +|--------|------|----------| +| `parentNode` | `text` | no | +| `labelHash` | `text` | no | + +**Primary key:** `(parentNode, labelHash)`. + +#### `migrated_nodes_by_node` + +Sibling lookup-by-namehash table for `migrated_nodes_by_parent`, keyed by `node`. The three `RegistryOld` handlers (`Transfer` / `NewTTL` / `NewResolver`) emit only the post-namehash `node` and cannot reconstruct the `(parentNode, labelHash)` pair without an unprofileable reverse lookup. Existence in this table is equivalent to existence in `migrated_nodes_by_parent`; both rows are written together by the migration helper. See `apps/ensindexer/src/lib/protocol-acceleration/migrated-node-db-helpers.ts` for the full rationale. + | Column | Type | Nullable | |--------|------|----------| | `node` | `text` | no | diff --git a/packages/enssdk/src/lib/ids.ts b/packages/enssdk/src/lib/ids.ts index dcded320b4..f5c748f704 100644 --- a/packages/enssdk/src/lib/ids.ts +++ b/packages/enssdk/src/lib/ids.ts @@ -36,7 +36,8 @@ import type { * `:` / `/` payload, so prefetch silently never fires. * * Move back to CAIP-style ids once Ponder's matcher supports parsing CAIP-shaped composite - * primary keys directly. This is a temporary shape, not the long-term one. + * primary keys directly. This is a temporary shape, not the long-term one. Tracked in + * https://github.com/namehash/ensnode/issues/2034. * * Note that because we key ENSv2 Domains by StorageId (necessary for stable identifier over time, * since its backing tokenId can change), which is _derived_ from the emitted arguments, ENSv2 Domains From 3db9b6d6853c0b2bbe3e3edc81d18ee3a617ff4a Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 30 Apr 2026 11:15:57 -0500 Subject: [PATCH 10/14] docs(enssdk): genericize id type comments and point at ids.ts The shape of each id (CAIP-10/CAIP-19/dash-tuple etc.) is a ids.ts-implementation detail that drifts when the format changes (just did, in #2016). The brand-only doc comments now describe the entity identity without leaking the encoding, and reference ids.ts as the canonical source for the current shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migrated-nodes.schema.ts | 2 +- packages/enssdk/src/lib/types/ensv2.ts | 54 ++++++++++++------- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/packages/ensdb-sdk/src/ensindexer-abstract/migrated-nodes.schema.ts b/packages/ensdb-sdk/src/ensindexer-abstract/migrated-nodes.schema.ts index da3b5f32b2..a4adb5ba9b 100644 --- a/packages/ensdb-sdk/src/ensindexer-abstract/migrated-nodes.schema.ts +++ b/packages/ensdb-sdk/src/ensindexer-abstract/migrated-nodes.schema.ts @@ -55,5 +55,5 @@ export const migratedNodeByParent = onchainTable( * {@link migratedNodeByParent}; both are written together by the migration helper. */ export const migratedNodeByNode = onchainTable("migrated_nodes_by_node", (t) => ({ - node: t.hex().notNull().primaryKey().$type(), + node: t.hex().primaryKey().$type(), })); diff --git a/packages/enssdk/src/lib/types/ensv2.ts b/packages/enssdk/src/lib/types/ensv2.ts index 46ee21056a..795bb65872 100644 --- a/packages/enssdk/src/lib/types/ensv2.ts +++ b/packages/enssdk/src/lib/types/ensv2.ts @@ -1,20 +1,24 @@ import type { AccountIdString } from "./shared"; /** - * Serialized CAIP-10 Asset ID that uniquely identifies a concrete ENSv1 Registry contract. + * An ID that uniquely identifies a concrete ENSv1 Registry contract. + * + * @dev see packages/enssdk/src/lib/ids.ts for context */ export type ENSv1RegistryId = AccountIdString & { __brand: "ENSv1RegistryId" }; /** - * Serialized CAIP-10 Asset ID that uniquely identifies an ENSv2 Registry contract. + * An ID that uniquely identifies an ENSv2 Registry contract. + * + * @dev see packages/enssdk/src/lib/ids.ts for context */ export type ENSv2RegistryId = AccountIdString & { __brand: "ENSv2RegistryId" }; /** - * Uniquely identifies an ENSv1 Virtual Registry — a virtual registry managed by an ENSv1 domain - * that has children. Shape: `${ENSv1RegistryId}/${node}`, where `(chainId, address)` from the - * ENSv1RegistryId is the concrete Registry that housed the parent domain, and `node` is the - * parent's namehash. + * An ID that uniquely identifies an ENSv1 Virtual Registry — a virtual registry managed by an + * ENSv1 domain that has children. + * + * @dev see packages/enssdk/src/lib/ids.ts for context */ export type ENSv1VirtualRegistryId = string & { __brand: "ENSv1VirtualRegistryId" }; @@ -32,16 +36,16 @@ export type RegistryId = ENSv1RegistryId | ENSv1VirtualRegistryId | ENSv2Registr export type StorageId = bigint & { __brand: "StorageId" }; /** - * Uniquely identifies an ENSv1 Domain. Shape: `${ENSv1RegistryId}/${node}`. + * An ID that uniquely identifies an ENSv1 Domain. * - * Same shape as {@link ENSv1VirtualRegistryId} (registry + node), but distinct entity kinds living - * in distinct tables. + * @dev see packages/enssdk/src/lib/ids.ts for context */ export type ENSv1DomainId = string & { __brand: "ENSv1DomainId" }; /** - * The Serialized CAIP-19 Asset ID (using Storage Id instead of TokenId) that uniquely identifies - * an ENSv2 name. + * An ID that uniquely identifies an ENSv2 Domain. + * + * @dev see packages/enssdk/src/lib/ids.ts for context */ export type ENSv2DomainId = string & { __brand: "ENSv2DomainId" }; @@ -51,37 +55,51 @@ export type ENSv2DomainId = string & { __brand: "ENSv2DomainId" }; export type DomainId = ENSv1DomainId | ENSv2DomainId; /** - * Uniquely identifies a Permissions entity. + * An ID that uniquely identifies a Permissions entity. + * + * @dev see packages/enssdk/src/lib/ids.ts for context */ export type PermissionsId = AccountIdString & { __brand: "PermissionsId" }; /** - * Uniquely identifies a PermissionsResource entity. + * An ID that uniquely identifies a PermissionsResource entity. + * + * @dev see packages/enssdk/src/lib/ids.ts for context */ export type PermissionsResourceId = string & { __brand: "PermissionsResourceId" }; /** - * Uniquely identifies a PermissionsUser entity. + * An ID that uniquely identifies a PermissionsUser entity. + * + * @dev see packages/enssdk/src/lib/ids.ts for context */ export type PermissionsUserId = string & { __brand: "PermissionsUserId" }; /** - * Uniquely identifies a Resolver entity. + * An ID that uniquely identifies a Resolver entity. + * + * @dev see packages/enssdk/src/lib/ids.ts for context */ export type ResolverId = AccountIdString & { __brand: "ResolverId" }; /** - * Uniquely identifies a ResolverRecords entity. + * An ID that uniquely identifies a ResolverRecords entity. + * + * @dev see packages/enssdk/src/lib/ids.ts for context */ export type ResolverRecordsId = string & { __brand: "ResolverRecordsId" }; /** - * Uniquely identifies a Registration entity. + * An ID that uniquely identifies a Registration entity. + * + * @dev see packages/enssdk/src/lib/ids.ts for context */ export type RegistrationId = string & { __brand: "RegistrationId" }; /** - * Uniquely identifies a Renewal entity. + * An ID that uniquely identifies a Renewal entity. + * + * @dev see packages/enssdk/src/lib/ids.ts for context */ export type RenewalId = string & { __brand: "RenewalId" }; From cc93429ef8ad7d28ad39a4de8df0ac189e3c26d4 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 30 Apr 2026 11:18:13 -0500 Subject: [PATCH 11/14] refactor(orchestrator): drop unnecessary pre-up wipe step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The testcontainers DockerComposeEnvironment generates a unique project name per up() call (`testcontainers-`), and each project gets its own volume namespace. A stale volume from a prior run is scoped under the prior project's name and is invisible to a new run's project — so there's no path for state to leak between runs. The original schema-collision error this was guarding against came from a host-native postgres on localhost:5432, fixed separately by the ephemeral host port mapping. With that in place, no scenario justifies the pre-up wipe. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/integration-test-env/src/orchestrator.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts index f28f5d0365..f5305dd527 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/orchestrator.ts @@ -248,20 +248,6 @@ async function main() { log("Starting integration test environment..."); logVersions(); - // Phase 0: best-effort wipe of any stale orchestrator state from a previous (possibly aborted) - // run. Without this, an abnormally-terminated prior run leaves behind a postgres volume that - // Ponder will reject ("Schema X was previously used by a different Ponder app"). Cleanup() also - // tears down volumes on success, so this is the belt to that suspenders. - log("Wiping any stale orchestrator state..."); - try { - execaSync("docker", ["compose", "-f", "docker-compose.orchestrator.yml", "down", "-v"], { - cwd: DOCKER_DIR, - stdio: "ignore", - }); - } catch { - // first run, nothing to wipe — this is fine - } - // Phase 1: Start ENSDb + Devnet via docker-compose log("Starting ENSDb and Devnet..."); composeEnvironment = await new DockerComposeEnvironment( From 97b96fda9680f948201c854520ff0afc40f8d804 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 30 Apr 2026 11:21:31 -0500 Subject: [PATCH 12/14] refactor(orchestrator): drop container_name on ensdb, fix lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes: - docker-compose.orchestrator.yml: drop `container_name: ensdb` from the inlined ensdb override. Without an explicit container name the testcontainers project prefix (testcontainers-) scopes the container globally, so concurrent orchestrators or co-resident dev stacks can't collide. - orchestrator.ts: the testcontainers wait-strategy + getContainer lookups parse the container name as `--` and return everything after the project prefix. Without a container_name that means `ensdb-1` (single-replica suffix), so update both call sites accordingly. Tried `extends + !override` to preserve DRY-ness against services/ensdb.yml, but standard YAML language servers don't recognize the compose-spec !override / !reset tags — kept the inline form for editor cleanliness. Co-Authored-By: Claude Opus 4.7 (1M context) --- docker/docker-compose.orchestrator.yml | 7 ++++++- packages/integration-test-env/src/orchestrator.ts | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.orchestrator.yml b/docker/docker-compose.orchestrator.yml index 613fa0308e..4999029b53 100644 --- a/docker/docker-compose.orchestrator.yml +++ b/docker/docker-compose.orchestrator.yml @@ -2,6 +2,8 @@ # Provides only the infrastructure services needed by orchestrator.ts: # devnet (local EVM) and ensdb (database). # +# NOTE: not using container_name so testcontainers gives it a unique one and avoids collisions +# # NOTE: ensdb is inlined (not `extends`-ing services/ensdb.yml) so we can override its host # port to ephemeral. The shared services/ensdb.yml binds 5432:5432, which collides with any # host-native postgres on a developer machine and silently routes orchestrator connections @@ -14,8 +16,11 @@ services: file: services/devnet.yml service: devnet + # ensdb is inlined (not `extends`-ing services/ensdb.yml) because the shared definition's + # `5432:5432` port mapping and `container_name: ensdb` are global resources we want to + # override here, and compose-spec's !override / !reset tags aren't recognized by standard + # YAML language servers — the inline form keeps the editor lint clean. ensdb: - container_name: ensdb image: postgres:17 ports: - "0:5432" diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts index f5305dd527..155b61efc0 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/orchestrator.ts @@ -255,11 +255,14 @@ async function main() { "docker-compose.orchestrator.yml", ) .withWaitStrategy("devnet", Wait.forHealthCheck()) - .withWaitStrategy("ensdb", Wait.forListeningPorts()) + // ensdb has no explicit container_name (see docker-compose.orchestrator.yml), so + // testcontainers' parsed container name is "ensdb-1" (project prefix stripped). devnet + // keeps its container_name from the shared services/devnet.yml so it stays "devnet". + .withWaitStrategy("ensdb-1", Wait.forListeningPorts()) .withStartupTimeout(120_000) .up(["ensdb", "devnet"]); - const ensdbContainer = composeEnvironment.getContainer("ensdb"); + const ensdbContainer = composeEnvironment.getContainer("ensdb-1"); const ensdbPort = ensdbContainer.getMappedPort(5432); const ENSDB_URL = `postgresql://postgres:password@localhost:${ensdbPort}/postgres`; log(`ENSDb is ready (port ${ensdbPort})`); From 952c514a913fd65003ffe50be6296e52f8e548c9 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 30 Apr 2026 11:22:56 -0500 Subject: [PATCH 13/14] docs(orchestrator): tighten compose file comments Co-Authored-By: Claude Opus 4.7 (1M context) --- docker/docker-compose.orchestrator.yml | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/docker/docker-compose.orchestrator.yml b/docker/docker-compose.orchestrator.yml index 4999029b53..1c07d631da 100644 --- a/docker/docker-compose.orchestrator.yml +++ b/docker/docker-compose.orchestrator.yml @@ -5,21 +5,17 @@ # NOTE: not using container_name so testcontainers gives it a unique one and avoids collisions # # NOTE: ensdb is inlined (not `extends`-ing services/ensdb.yml) so we can override its host -# port to ephemeral. The shared services/ensdb.yml binds 5432:5432, which collides with any -# host-native postgres on a developer machine and silently routes orchestrator connections -# to that native postgres instead of the docker container — leading to schema-collision -# MigrationErrors that look like volume-cleanup bugs but aren't. Using "0:5432" lets docker -# pick an ephemeral host port; orchestrator.ts reads it via testcontainers' getMappedPort(). +# port to ephemeral without using docker-compose-specific !override syntax. The shared +# services/ensdb.yml binds 5432:5432, which collides with any host-native postgres on a developer +# machine and silently routes orchestrator connections to that native postgres instead of the +# docker container — leading to schema-collision errors. Using "0:5432" lets docker pick an ephemeral +# host port; orchestrator.ts reads it via testcontainers' getMappedPort() services: devnet: extends: file: services/devnet.yml service: devnet - # ensdb is inlined (not `extends`-ing services/ensdb.yml) because the shared definition's - # `5432:5432` port mapping and `container_name: ensdb` are global resources we want to - # override here, and compose-spec's !override / !reset tags aren't recognized by standard - # YAML language servers — the inline form keeps the editor lint clean. ensdb: image: postgres:17 ports: @@ -30,14 +26,12 @@ services: - path: envs/.env.docker.common required: true healthcheck: - test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ] interval: 5s timeout: 5s retries: 5 start_period: 10s volumes: - # Docker Compose requires volumes used by services to be declared in each - # compose file that references them — they cannot be inherited via `extends`. ensdb_data: driver: local From 01f2fa3a92fcc8f48c70702af5e125dc25d421b1 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 30 Apr 2026 11:29:46 -0500 Subject: [PATCH 14/14] fix(enssdk): drop AccountIdString brand on composite id types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ENSv1RegistryId / ENSv2RegistryId / PermissionsId / ResolverId were typed as `AccountIdString & { __brand: ... }`, advertising a CAIP-10 shape. After the dash-delimited id refactor (#2016) the runtime values no longer satisfy that contract — they're tuple-joined strings, not CAIP-10. Anything that took these ids and fed them into a CAIP-10 parser would silently fail. Switch to plain `string & { __brand: ... }` so the type doesn't lie about its shape. AccountIdString itself is still `string` under the hood, so this is a documentation/intent change rather than a structural one — no callers needed updating. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/enssdk/src/lib/types/ensv2.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/enssdk/src/lib/types/ensv2.ts b/packages/enssdk/src/lib/types/ensv2.ts index 795bb65872..7bac38f638 100644 --- a/packages/enssdk/src/lib/types/ensv2.ts +++ b/packages/enssdk/src/lib/types/ensv2.ts @@ -1,18 +1,16 @@ -import type { AccountIdString } from "./shared"; - /** * An ID that uniquely identifies a concrete ENSv1 Registry contract. * * @dev see packages/enssdk/src/lib/ids.ts for context */ -export type ENSv1RegistryId = AccountIdString & { __brand: "ENSv1RegistryId" }; +export type ENSv1RegistryId = string & { __brand: "ENSv1RegistryId" }; /** * An ID that uniquely identifies an ENSv2 Registry contract. * * @dev see packages/enssdk/src/lib/ids.ts for context */ -export type ENSv2RegistryId = AccountIdString & { __brand: "ENSv2RegistryId" }; +export type ENSv2RegistryId = string & { __brand: "ENSv2RegistryId" }; /** * An ID that uniquely identifies an ENSv1 Virtual Registry — a virtual registry managed by an @@ -59,7 +57,7 @@ export type DomainId = ENSv1DomainId | ENSv2DomainId; * * @dev see packages/enssdk/src/lib/ids.ts for context */ -export type PermissionsId = AccountIdString & { __brand: "PermissionsId" }; +export type PermissionsId = string & { __brand: "PermissionsId" }; /** * An ID that uniquely identifies a PermissionsResource entity. @@ -80,7 +78,7 @@ export type PermissionsUserId = string & { __brand: "PermissionsUserId" }; * * @dev see packages/enssdk/src/lib/ids.ts for context */ -export type ResolverId = AccountIdString & { __brand: "ResolverId" }; +export type ResolverId = string & { __brand: "ResolverId" }; /** * An ID that uniquely identifies a ResolverRecords entity.