Skip to content
Merged
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/ensdb-sdk-migrated-nodes-split.md
Original file line number Diff line number Diff line change
@@ -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 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`.
7 changes: 7 additions & 0 deletions .changeset/enssdk-dash-delimited-ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"enssdk": minor
Comment thread
shrugs marked this conversation as resolved.
---

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.
9 changes: 7 additions & 2 deletions apps/ensindexer/src/lib/get-this-account-id.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AccountId } from "enssdk";
import type { AccountId, NormalizedAddress } from "enssdk";

import type { IndexingEngineContext } from "@/lib/indexing-engines/ponder";
import type { LogEventBase } from "@/lib/ponder-helpers";
Expand All @@ -12,4 +12,9 @@ import type { LogEventBase } from "@/lib/ponder-helpers";
export const getThisAccountId = (
context: IndexingEngineContext,
event: Pick<LogEventBase, "log">,
) => ({ chainId: context.chain.id, address: event.log.address }) satisfies AccountId;
) =>
({
chainId: context.chain.id,
// Ponder provides us a NormalizedAddress, cast here to avoid the minor overhead of (as|to)NormalizedAddress
address: event.log.address as NormalizedAddress,
}) satisfies AccountId;
Comment thread
shrugs marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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
* 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
* 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
Comment thread
shrugs marked this conversation as resolved.
* will result in significantly 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}.`,
);
Comment thread
shrugs marked this conversation as resolved.
};

/**
* 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;
}
Comment thread
shrugs marked this conversation as resolved.

/**
* 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();
Comment thread
shrugs marked this conversation as resolved.
Comment thread
shrugs marked this conversation as resolved.
}
Comment thread
shrugs marked this conversation as resolved.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
makeStorageId,
type NormalizedAddress,
type TokenId,
toNormalizedAddress,
type UnixTimestampBigInt,
type Wei,
} from "enssdk";
Expand Down Expand Up @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);

Expand Down Expand Up @@ -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);
},
);

Expand Down
25 changes: 20 additions & 5 deletions docker/docker-compose.orchestrator.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,37 @@
# Minimal compose for CI integration tests.
# 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 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:
extends:
file: services/ensdb.yml
service: 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
# compose file that references them — they cannot be inherited via `extends`.
ensdb_data:
driver: local
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions packages/ensdb-sdk/src/ensindexer-abstract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading