diff --git a/.changeset/ensdb-sdk-migrated-nodes-split.md b/.changeset/ensdb-sdk-migrated-nodes-split.md new file mode 100644 index 000000000..21f5a364e --- /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 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/.changeset/enssdk-dash-delimited-ids.md b/.changeset/enssdk-dash-delimited-ids.md new file mode 100644 index 000000000..f0d74dc04 --- /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/get-this-account-id.ts b/apps/ensindexer/src/lib/get-this-account-id.ts index 541339a52..6ba3cf122 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, NormalizedAddress } from "enssdk"; import type { IndexingEngineContext } from "@/lib/indexing-engines/ponder"; import type { LogEventBase } from "@/lib/ponder-helpers"; @@ -12,4 +12,9 @@ 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, + // Ponder provides us a NormalizedAddress, cast here to avoid the minor overhead of (as|to)NormalizedAddress + address: event.log.address as NormalizedAddress, + }) satisfies AccountId; 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 000000000..d4b2cb585 --- /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 + * 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 + * 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}.`, + ); +}; + +/** + * 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 7d8dc325f..000000000 --- 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 b03ad5edf..983d2ab68 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/ensv2/handlers/ensv2/ETHRegistrar.ts b/apps/ensindexer/src/plugins/ensv2/handlers/ensv2/ETHRegistrar.ts index 171a368b9..8654c2b0e 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/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts b/apps/ensindexer/src/plugins/protocol-acceleration/handlers/ENSv1Registry.ts index ccc81e1fc..875e90737 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/docker/docker-compose.orchestrator.yml b/docker/docker-compose.orchestrator.yml index 82290a79e..1c07d631d 100644 --- a/docker/docker-compose.orchestrator.yml +++ b/docker/docker-compose.orchestrator.yml @@ -1,6 +1,15 @@ # 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: @@ -8,15 +17,21 @@ services: 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 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 1720691ba..8a8bad5c8 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/ensdb-sdk/src/ensindexer-abstract/index.ts b/packages/ensdb-sdk/src/ensindexer-abstract/index.ts index be985db94..4b91db39c 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 000000000..a4adb5ba9 --- /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 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().notNull().$type(), + labelHash: t.hex().notNull().$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 f70f9f8d7..99480dd15 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 49fa6f2e7..f5c748f70 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,37 @@ 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. 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 + * 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("-"); 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 +61,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; diff --git a/packages/enssdk/src/lib/types/ensv2.ts b/packages/enssdk/src/lib/types/ensv2.ts index 46ee21056..7bac38f63 100644 --- a/packages/enssdk/src/lib/types/ensv2.ts +++ b/packages/enssdk/src/lib/types/ensv2.ts @@ -1,20 +1,22 @@ -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" }; +export type ENSv1RegistryId = string & { __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" }; +export type ENSv2RegistryId = string & { __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 +34,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 +53,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" }; +export type PermissionsId = string & { __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" }; +export type ResolverId = string & { __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" }; diff --git a/packages/integration-test-env/package.json b/packages/integration-test-env/package.json index 0607c0f48..b71cd8eff 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 a06a53001..155b61efc 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, @@ -100,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: ${ @@ -252,14 +255,28 @@ 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})`); + + // 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d126548f9..ac7fca6a4 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: