From b347e05a33aab57fac8f1ffd23271c591ca10abe Mon Sep 17 00:00:00 2001 From: shrugs Date: Fri, 7 Mar 2025 15:50:47 -0600 Subject: [PATCH 01/15] refactor: ens-deployment type names and variable names for clarity --- apps/ensindexer/ponder.config.ts | 22 +++--- apps/ensindexer/src/lib/globals.ts | 27 ++++--- apps/ensindexer/src/lib/plugin-helpers.ts | 4 +- apps/ensindexer/src/lib/types.ts | 18 +++-- packages/ens-deployments/src/filters.ts | 8 +-- packages/ens-deployments/src/sepolia.ts | 2 +- packages/ens-deployments/src/types.ts | 86 ++++++++--------------- 7 files changed, 78 insertions(+), 89 deletions(-) diff --git a/apps/ensindexer/ponder.config.ts b/apps/ensindexer/ponder.config.ts index 8d0d62f45..4310afd53 100644 --- a/apps/ensindexer/ponder.config.ts +++ b/apps/ensindexer/ponder.config.ts @@ -9,7 +9,7 @@ import * as ethPlugin from "./src/plugins/eth/ponder.plugin"; import * as lineaEthPlugin from "./src/plugins/linea/ponder.plugin"; //////// -// First, generate AllPluginConfigs type representing the merged types of each plugin's `config`, +// Generate AllPluginConfigs type representing the merged types of each plugin's `config`, // so ponder's typechecking of the indexing handlers and their event arguments is correct. //////// @@ -18,31 +18,33 @@ const ALL_PLUGINS = [ethPlugin, baseEthPlugin, lineaEthPlugin, ensV2Plugin] as c type AllPluginConfigs = MergedTypes<(typeof ALL_PLUGINS)[number]["config"]>; //////// -// Next, filter ALL_PLUGINS by those that are available and that the user has activated. +// Filter ALL_PLUGINS by those that are 'available' (i.e. defined by the SELECTED_DEPLOYMENT_CONFIG) //////// -// the available PluginNames are those that the selected ENS Deployment defines as available const availablePluginNames = Object.keys(SELECTED_DEPLOYMENT_CONFIG) as PluginName[]; -// filter the set of available plugins by those that are 'active' in the env +//////// +// Filter ALL_PLUGINS by those that are 'active' in the env (i.e. via ACTIVE_PLUGINS) +//////// + const activePlugins = getActivePlugins(ALL_PLUGINS, availablePluginNames); //////// -// Next, merge the plugins' configs into a single ponder config and activate their handlers. +// Merge the plugins' configs into a single ponder config. //////// -// merge the resulting configs const activePluginsMergedConfig = activePlugins .map((plugin) => plugin.config) .reduce((acc, val) => deepMergeRecursive(acc, val), {}) as AllPluginConfigs; -// load indexing handlers from the active plugins into the runtime +//////// +// 'activate' each plugin, registering its indexing handlers with ponder +//////// + activePlugins.forEach((plugin) => plugin.activate()); //////// -// Finally, return the merged config for ponder to use for type inference and runtime behavior. +// Finally, return the merged config (typed as AllPluginConfigs) for ponder. //////// -// The type of the default export is a merge of all active plugin configs -// configs so that each plugin can be correctly typechecked export default activePluginsMergedConfig; diff --git a/apps/ensindexer/src/lib/globals.ts b/apps/ensindexer/src/lib/globals.ts index 0c0f7aff5..21830d2e9 100644 --- a/apps/ensindexer/src/lib/globals.ts +++ b/apps/ensindexer/src/lib/globals.ts @@ -14,21 +14,28 @@ import { getEnsDeploymentChain } from "./ponder-helpers"; export const SELECTED_DEPLOYMENT_CONFIG = DeploymentConfigs[getEnsDeploymentChain()]; /** - * Note that here, we define the global DEPLOYMENT_CONFIG as the _merge_ of all possible deployments - * (therefore fully specifying all plugin configs), overrided with the SELECTED_DEPLOYMENT_CONFIG. - * - * This ensures that at type-check-time and in `ALL_PLUGINS` every plugin's `config` has valid values - * (and therefore its type can continue to be inferred). This means that initially upon building the - * plugin configs, if the user is selecting a deployment that does not fully specify every available - * plugin, the plugins that are not in that deployment's specification are technically pointing at - * the mainnet deployment. This is never an issue, however, as those plugin are filtered out - * (see ponder.config.ts and `getActivePlugins`) and never activated. + * Here, we define the global MERGED_DEPLOYMENT_CONIG as the merge of all possible deployment configs + * (therefore fully specifying all possible AddressBooks). */ -export const DEPLOYMENT_CONFIG = { +const MERGED_DEPLOYMENT_CONIG = { ...DeploymentConfigs.mainnet, ...DeploymentConfigs.sepolia, ...DeploymentConfigs.holesky, ...DeploymentConfigs["ens-test-env"], +}; + +/** + * Here we override the MERGED_DEPLOYMENT_CONIG object with the SELECTED_DEPLOYMENT_CONFIG. + * + * This ensures that at type-check-time every plugin's `config` has valid values (and therefore its + * type can be inferred). This means that initially upon building the plugin configs, if the user is + * selecting a deployment that does not fully specify every available plugin, the plugins that are + * not in that deployment's specification are technically referencing an AddressBook from another + * deployment. This is never an issue, however, as those plugin are filtered out at runtime + * and never activated (see ponder.config.ts and `getActivePlugins`). + */ +export const DEPLOYMENT_CONFIG = { + ...MERGED_DEPLOYMENT_CONIG, ...SELECTED_DEPLOYMENT_CONFIG, }; diff --git a/apps/ensindexer/src/lib/plugin-helpers.ts b/apps/ensindexer/src/lib/plugin-helpers.ts index 66dbbcd7b..88b26db57 100644 --- a/apps/ensindexer/src/lib/plugin-helpers.ts +++ b/apps/ensindexer/src/lib/plugin-helpers.ts @@ -1,4 +1,4 @@ -import type { SubregistryContractConfig } from "@ensnode/ens-deployments"; +import type { ContractConfig } from "@ensnode/ens-deployments"; import type { NetworkConfig } from "ponder"; import { http, Chain } from "viem"; import { END_BLOCK, START_BLOCK } from "./globals"; @@ -221,7 +221,7 @@ export function networksConfigForChain(chain: Chain) { * Defines a `ponder#ContractConfig['network']` given a contract's config, injecting the global * start/end blocks to constrain indexing range. */ -export function networkConfigForContract( +export function networkConfigForContract( chain: Chain, contractConfig: CONTRACT_CONFIG, ) { diff --git a/apps/ensindexer/src/lib/types.ts b/apps/ensindexer/src/lib/types.ts index 3e16a3db7..7ce4ab022 100644 --- a/apps/ensindexer/src/lib/types.ts +++ b/apps/ensindexer/src/lib/types.ts @@ -9,10 +9,18 @@ export type OwnedName = string; /** - * In this project we use the notion of 'plugins' to describe which registries and subregistries - * of a given ENS deployment are being indexed by ponder. In this project, a plugin's name is the - * name of the subregistry it indexes. Note that this type definition is 1:1 with that of - * @ensnode/ens-deployments SubregistryName, simplifying the relationship between an ENSDeploymentConfig - * and the plugins in this project. + * In ENSIndexer we use the notion of 'plugins' to describe a relationship between a set of contracts + * and the handler logic that indexes their events. For ENSv1, a plugin's name is the + * name of the subregistry it indexes ('eth', 'base', 'linea'). + * + * The ENSv2 plugin ('ens-v2') represents the configuration and indexing logic associated with + * indexing ENSv2. + * + * The PluginName type is necessary for uniquely identifying each plugin's config and filtering + * which are 'activated' at runtime. + * + * Note that this type is an exact equivalent of `keyof ENSDeploymentConfig`, which we over-specify + * to illustrate the connection between a ENSIndexer plugin and the AddressBook that it is configured + * with. */ export type PluginName = "eth" | "base" | "linea" | "ens-v2"; diff --git a/packages/ens-deployments/src/filters.ts b/packages/ens-deployments/src/filters.ts index 34246d6a9..f39cfe20a 100644 --- a/packages/ens-deployments/src/filters.ts +++ b/packages/ens-deployments/src/filters.ts @@ -1,4 +1,4 @@ -import { SubregistryContractConfig } from "./types"; +import { ContractConfig } from "./types"; export const ETHResolverFilter = [ { event: "AddrChanged", args: {} }, @@ -21,7 +21,7 @@ export const ETHResolverFilter = [ { event: "DNSRecordChanged", args: {} }, { event: "DNSRecordDeleted", args: {} }, { event: "DNSZonehashChanged", args: {} }, -] as const satisfies SubregistryContractConfig["filter"]; +] as const satisfies ContractConfig["filter"]; export const BaseResolverFilter = [ { event: "AddrChanged", args: {} }, @@ -36,7 +36,7 @@ export const BaseResolverFilter = [ { event: "DNSRecordChanged", args: {} }, { event: "DNSRecordDeleted", args: {} }, { event: "DNSZonehashChanged", args: {} }, -] as const satisfies SubregistryContractConfig["filter"]; +] as const satisfies ContractConfig["filter"]; export const LineaResolverFilter = [ { event: "AddrChanged", args: {} }, @@ -51,4 +51,4 @@ export const LineaResolverFilter = [ { event: "DNSRecordChanged", args: {} }, { event: "DNSRecordDeleted", args: {} }, { event: "DNSZonehashChanged", args: {} }, -] as const satisfies SubregistryContractConfig["filter"]; +] as const satisfies ContractConfig["filter"]; diff --git a/packages/ens-deployments/src/sepolia.ts b/packages/ens-deployments/src/sepolia.ts index 0f0a8abab..dbe03e2d0 100644 --- a/packages/ens-deployments/src/sepolia.ts +++ b/packages/ens-deployments/src/sepolia.ts @@ -93,7 +93,7 @@ export default { }, OwnedResolver: { abi: ensV2_OwnedResolver, - factory: { + address: { address: "0x33d438bb85B76C9211c4F259109D94Fe83F5A5eC", event: parseAbiItem( "event ProxyDeployed(address indexed sender, address indexed proxyAddress, uint256 salt, address implementation)", diff --git a/packages/ens-deployments/src/types.ts b/packages/ens-deployments/src/types.ts index c8eb3295a..e632b3ee9 100644 --- a/packages/ens-deployments/src/types.ts +++ b/packages/ens-deployments/src/types.ts @@ -1,76 +1,48 @@ -import type { factory } from "ponder"; -import type { Abi, Address, Chain } from "viem"; +import type { ContractConfig as PonderContractConfig } from "ponder"; +import type { Chain } from "viem"; /** * Encodes a set of chains known to provide an "ENS deployment". * - * Each "ENS deployment" is a single, unified namespace of ENS names with: - * - A root Registry deployed to the "ENS Deployment" chain. - * - A capability to expand from that root Registry across any number of chains, subregistries, and offchain resources. + * Each "ENS deployment" is a single, unified, and isolated namespace of ENS names. * - * 'ens-test-env' represents an "ENS deployment" running on a local Anvil chain for testing - * protocol changes, running deterministic test suites, and local development. + * A deployment include ENSv1 contracts with: + * - A root Registry deployed to the L1 indicated by the `ENSDeploymentChain`. + * - A capability to expand from that root Registry across any number of chains, subregistries, and + * offchain resources. + * + * A deployment may include ENSv2 contracts. + * + * NOTE: the 'ens-test-env' deployment represents an ENS deployment running on a local Anvil chain + * for testing protocol changes, running deterministic test suites, and local development. * https://github.com/ensdomains/ens-test-env */ export type ENSDeploymentChain = "mainnet" | "sepolia" | "holesky" | "ens-test-env"; -/** - * Encodes a set of known subregistries. - */ -export type SubregistryName = "eth" | "base" | "linea" | "ens-v2"; - -/** - * EventFilter specifies a given event's name and arguments to filter that event by. - * It is intentionally a subset of ponder's `ContractConfig['filter']`. - */ -export interface EventFilter { - event: string; - args: Record; -} - /** * Defines the abi, address, filter, and startBlock of a contract relevant to indexing a subregistry. - * A contract is located on-chain either by a static `address` or the event signatures (`filter`) - * one should filter the chain for. + * See Ponder's [Contracts and Networks Documentation](https://ponder.sh/docs/contracts-and-networks) + * for more information. * * @param abi - the ABI of the contract - * @param address - (optional) address of the contract + * @param address - (optional) address of the contract or a factory spec * @param filter - (optional) array of event signatures to filter the log by - * @param startBlock - block number the contract was deployed in + * @param startBlock - (required) block to start indexing from */ -export type SubregistryContractConfig = - | { - readonly abi: Abi; - readonly address: Address; - readonly filter?: never; - readonly factory?: never; - readonly startBlock: number; - } - | { - readonly abi: Abi; - readonly address?: never; - readonly filter: EventFilter[]; - readonly factory?: never; - readonly startBlock: number; - } - | { - readonly abi: Abi; - readonly address?: never; - readonly filter?: never; - readonly factory: Parameters[0]; - readonly startBlock: number; - }; +export type ContractConfig = Pick & { + startBlock: number; +}; /** - * Encodes the deployment of a subregistry, including the target chain and contracts. + * Encodes a set of contract configs on a given chain. */ -export interface SubregistryDeploymentConfig { +export interface AddressBook { chain: Chain; - contracts: Record; + contracts: Record; } /** - * Encodes the set of known subregistries for an "ENS deployment". + * Encodes the set of known contract configs for a given "ENS deployment" root */ export type ENSDeploymentConfig = { /** @@ -78,26 +50,26 @@ export type ENSDeploymentConfig = { * * Required for each "ENS deployment". */ - eth: SubregistryDeploymentConfig; + eth: AddressBook; /** * Subregistry for direct subnames of 'base.eth'. * * Optional for each "ENS deployment". */ - base?: SubregistryDeploymentConfig; + base?: AddressBook; /** * Subregistry for direct subnames of 'linea.eth'. * * Optional for each "ENS deployment". */ - linea?: SubregistryDeploymentConfig; + linea?: AddressBook; /** * ENS v2 Contracts - * TODO: the naming in this package is no longer accurate — perhaps this should be 'AddressBook' - * or something like that. + * + * Optional for each "ENS deployment". */ - "ens-v2"?: SubregistryDeploymentConfig; + "ens-v2"?: AddressBook; }; From 31d265a2684acc1bd9e8d001341f2b4594b0650e Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 10 Mar 2025 14:31:57 -0500 Subject: [PATCH 02/15] feat: implement ens-v2-friendly basic schema --- packages/ponder-schema/src/ponder.schema.ts | 256 ++++++++++++++------ 1 file changed, 181 insertions(+), 75 deletions(-) diff --git a/packages/ponder-schema/src/ponder.schema.ts b/packages/ponder-schema/src/ponder.schema.ts index f672c6b7d..c34c6a292 100644 --- a/packages/ponder-schema/src/ponder.schema.ts +++ b/packages/ponder-schema/src/ponder.schema.ts @@ -1,4 +1,4 @@ -import { index, onchainTable, relations } from "ponder"; +import { index, onchainTable, relations, uniqueIndex } from "ponder"; import type { Address } from "viem"; import { monkeypatchCollate } from "./collate"; @@ -685,99 +685,205 @@ export const versionChangedRelations = relations(versionChanged, ({ one }) => ({ * NOTE: These entities kept namespaced for rapid prototyping—see v2 plans for additional context. * https://www.ensnode.io/ensnode/reference/ensnode-v2-notes/ * - * Original Schema from https://github.com/ensdomains/ens-ponder + * The core design principle here is that the label-based hierarchical namespace is logically + * separated from 'implementation details'. + * + * Resolver and Registry entities are long-lived and never deleted. Label entities _are_ deleted + * when they are no longer present in the namespace (i.e. representative tokens are burned). + * + * Labels store flags for their referenced subRegistry and resolver. + * + * open questions: + * - how do v1 subregistries other than .eth handle the migration? + * - what does v2.eth registry `reliquishing` accomplish, from an indexing perspective? + * + * todo in schema: + * - events & event relations + * */ -export const v2_domain = onchainTable("v2_domain", (t) => ({ - id: t.text().primaryKey(), - label: t.text(), - name: t.text().array(), // Will store serialized array as JSON string - labelHash: t.text(), - owner: t.text(), - registry: t.text(), - isTld: t.boolean(), - createdAt: t.bigint("createdAt").notNull(), - updatedAt: t.bigint("updatedAt").notNull(), -})); +// TODO: we _need_ to be able to configure ponder's handling of null values in order to correctly index ENSv2 +// https://github.com/ponder-sh/ponder/issues/1456 +// because ENSv2 doesn't emit the namehash/labelhash, only human-readable args, which may contain null bytes + +/** + * A Label entity represents a label in the hierarchical namespace. + */ +export const v2_label = onchainTable( + "v2_labels", + (t) => ({ + /** + * Labels are keyed by `node`, i.e. the result of `namehash()`, ensuring uniqueness within the + * ENS namespace. + */ + id: t.hex().primaryKey(), + /** + * All Labels have a parent, save the Root label. This creates a hierarchical tree namespace, + * in which any label's parentage can be traced to the Root label. + */ + parentId: t.hex(), + /** + * A Label entity represents a given labelHash value i.e. the result of `labelhash()`. This is + * _not_ a UUID value, and collisions are expected (i.e. there will be a Label entity representing + * the `hello` in `hello.example.eth` and a Label representing the `hello` in `hello.eth` that + * have identical labelHash values). + */ + labelHash: t.hex().notNull(), + /** + * The human-readable representation of a given Label. In ENSv2, this `label` is always known, + * but in ENSv1, labels may or may not be known, hence this field is optional. When rendering + * and unknown label, an 'encoded' labelHash is used. + */ + label: t.text(), + /** + * A Label stores a materialized `name`, representing its place in the label hierarchy. In the + * event that a Label within the hierarchy is unknown, this name will contain encoded labelHash + * segments. + * + * NOTE: in the future, name construction will be done dynamically instead of materialized at + * index-time. + * + * ex. sub.example.eth + * ex. [0xabcd].example.eth + * ex. known.[0xabcd].example.eth + */ + name: t.text(), + + /** + * A Label has one `owner` address. + * + * TODO: This `owner` is _always_ set, using ZeroAddress where appropriate? or optional? depends on + * contract state/assurances + */ + owner: t.hex().notNull(), + + /** + * A Label can be assigned a (sub)Registry with flags. + */ + subregistryId: t.text(), + subregistryFlags: t.bigint().notNull(), -export const v2_domainRelations = relations(v2_domain, ({ one }) => ({ + /** + * A Label can be configured with a given Resolver with flags. + */ + resolverId: t.text(), + resolverFlags: t.bigint().notNull(), + }), + (t) => ({ + // a Label is unique by (parentId, labelHash) + parentLabelHashIndex: uniqueIndex().on(t.parentId, t.labelHash), + }), +); + +export const v2_labelRelations = relations(v2_label, ({ one, many }) => ({ + // label has one parent Label + parent: one(v2_label, { + fields: [v2_label.parentId], + references: [v2_label.id], + }), + + // label has one registry registry: one(v2_registry, { - fields: [v2_domain.registry], + fields: [v2_label.subregistryId], references: [v2_registry.id], }), -})); -export const v2_registry = onchainTable("v2_registry", (t) => ({ - id: t.text().primaryKey(), - labelHash: t.text(), - label: t.text(), - subregistryId: t.text(), - resolver: t.text(), - flags: t.bigint(), - createdAt: t.bigint("createdAt").notNull(), - updatedAt: t.bigint("updatedAt").notNull(), -})); + // label has one resolver + resolver: one(v2_resolver, { + fields: [v2_label.resolverId], + references: [v2_resolver.id], + }), -export const v2_subregistryUpdateEvent = onchainTable("v2_subregistryUpdateEvent", (t) => ({ - id: t.text().primaryKey(), - registryId: t.text(), - labelHash: t.text(), - subregistryId: t.text(), - flags: t.bigint(), - createdAt: t.bigint("createdAt").notNull(), - updatedAt: t.bigint("updatedAt").notNull(), + // label has one records by pairwise composite key + records: one(v2_resolverRecords, { + fields: [v2_label.resolverId, v2_label.id], + references: [v2_resolverRecords.resolverId, v2_resolverRecords.labelId], + }), })); -export const v2_resolverUpdateEvent = onchainTable("v2_resolverUpdateEvent", (t) => ({ - id: t.text().primaryKey(), - registryId: t.text(), - labelHash: t.text(), - resolverId: t.text(), - flags: t.bigint(), - createdAt: t.bigint("createdAt").notNull(), - updatedAt: t.bigint("updatedAt").notNull(), -})); +export const v2_registry = onchainTable( + "v2_registries", + (t) => ({ + /** + * (sub)Registries are keyed by [CAIP-10](https://chainagnostic.org/CAIPs/caip-10) + * i.e. chainId:address + * + * TODO: introducing CAIP-10 for chain-scoping, may or may not be strictly necessary + * TODO: type as CAIP-10 + */ + id: t.text().primaryKey(), + }), + (t) => ({}), +); -export const v2_newSubnameEvent = onchainTable("v2_newSubnameEvent", (t) => ({ - id: t.text().primaryKey(), - registryId: t.text(), - label: t.text(), - labelHash: t.text(), - source: t.text(), // "EthRegistry" or "RootRegistry" - createdAt: t.bigint("createdAt").notNull(), - updatedAt: t.bigint("updatedAt").notNull(), +export const v2_registryRelations = relations(v2_registry, ({ one, many }) => ({ + // technically any number of labels can reference a given Registry + label: many(v2_label), })); -export const v2_registryRelations = relations(v2_registry, ({ one }) => ({ - subregistry: one(v2_registry, { - fields: [v2_registry.subregistryId], - references: [v2_registry.id], +/** + * A Resolver represents a given Resolver _contract_ on-chain, keyed by CAIP-10 address. + */ +export const v2_resolver = onchainTable( + "v2_resolvers", + (t) => ({ + /** + * Resolver are keyed by [CAIP-10](https://chainagnostic.org/CAIPs/caip-10) + * i.e. chainId:address + * + * TODO: introducing CAIP-10 for chain-scoping, may or may not be strictly necessary + * TODO: type as CAIP-10 + */ + id: t.text().primaryKey(), }), -})); + (t) => ({}), +); -export const v2_resolver = onchainTable("v2_resolver", (t) => ({ - id: t.text().primaryKey(), - address: t.text(), - node: t.text(), - createdAt: t.bigint("createdAt").notNull(), - updatedAt: t.bigint("updatedAt").notNull(), +export const v2_resolverRelations = relations(v2_resolver, ({ one, many }) => ({ + // any number of labels can reference a given Resolver + label: many(v2_label), + + // resolver has many records + records: many(v2_resolverRecords), })); -export const v2_registryResolverRelations = relations(v2_registry, ({ one }) => ({ +/** + * A ResolverRecords represents a pairwise relationship between a Resolver entity/contract + * and a given `node`. + */ +export const v2_resolverRecords = onchainTable( + "v2_resolver_records", + (t) => ({ + /** + * A ResolverRecords is keyed as `${resolverId}-${node}`. + */ + id: t.text().primaryKey(), + + /** + * A ResolverRecords maintains references to the Resolver contract and which label it stores + * records for. + */ + resolverId: t.text().notNull(), + labelId: t.hex().notNull(), + + // TODO: implement all record storage here + }), + (t) => ({ + // uniquely index against the composite key + idxResolverNode: uniqueIndex().on(t.resolverId, t.labelId), + }), +); + +export const v2_resolverRecordRelations = relations(v2_resolverRecords, ({ one, many }) => ({ + // records belongs to Resolver resolver: one(v2_resolver, { - fields: [v2_registry.resolver], + fields: [v2_resolverRecords.resolverId], references: [v2_resolver.id], }), -})); -export const v2_transferSingleEvent = onchainTable("v2_transferSingleEvent", (t) => ({ - id: t.text().primaryKey(), - registryId: t.text(), - tokenId: t.text(), - from: t.text(), - to: t.text(), - value: t.bigint(), - source: t.text(), // "EthRegistry" or "RootRegistry" - createdAt: t.bigint("createdAt").notNull(), - updatedAt: t.bigint("updatedAt").notNull(), + // records references Label + label: one(v2_label, { + fields: [v2_resolverRecords.labelId], + references: [v2_label.id], + }), })); From a5cd7cda8b7c9c430c331fd2b3829b0a9f6205a6 Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 10 Mar 2025 17:46:54 -0500 Subject: [PATCH 03/15] feat: implement basic indexing of ENSv2 --- apps/ensindexer/package.json | 3 +- apps/ensindexer/src/handlers/Registrar.ts | 14 +- apps/ensindexer/src/handlers/Registry.ts | 4 +- apps/ensindexer/src/lib/graphnode-helpers.ts | 4 +- apps/ensindexer/src/lib/ids.ts | 4 +- .../src/plugins/base/handlers/Registrar.ts | 4 +- .../plugins/ens-v2/handlers/EthRegistry.ts | 58 +---- .../plugins/ens-v2/handlers/OwnedResolver.ts | 26 --- .../ens-v2/handlers/RegistryDatastore.ts | 100 ++++----- .../src/plugins/ens-v2/handlers/Resolver.ts | 73 +++++++ .../plugins/ens-v2/handlers/RootRegistry.ts | 58 +---- .../ens-v2/handlers/shared/Registry.ts | 142 ++++++++++++ .../src/plugins/ens-v2/ponder.plugin.ts | 16 +- apps/ensindexer/src/plugins/ens-v2/v2-lib.ts | 170 +++++++-------- .../src/plugins/eth/handlers/EthRegistrar.ts | 4 +- .../plugins/linea/handlers/EthRegistrar.ts | 4 +- .../ensindexer/test/graphnode-helpers.spec.ts | 4 +- .../ens-v2/{OwnedResolver.ts => Resolver.ts} | 2 +- packages/ens-deployments/src/sepolia.ts | 27 +-- packages/ensnode-utils/src/types.ts | 4 +- packages/ensrainbow-sdk/src/client.ts | 10 +- packages/ensrainbow-sdk/src/label-utils.ts | 4 +- packages/ponder-schema/src/ponder.schema.ts | 203 ++++++++++++------ pnpm-lock.yaml | 19 +- 24 files changed, 549 insertions(+), 408 deletions(-) delete mode 100644 apps/ensindexer/src/plugins/ens-v2/handlers/OwnedResolver.ts create mode 100644 apps/ensindexer/src/plugins/ens-v2/handlers/Resolver.ts create mode 100644 apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts rename packages/ens-deployments/src/abis/ens-v2/{OwnedResolver.ts => Resolver.ts} (99%) diff --git a/apps/ensindexer/package.json b/apps/ensindexer/package.json index 20213d8b1..0088ae501 100644 --- a/apps/ensindexer/package.json +++ b/apps/ensindexer/package.json @@ -25,11 +25,12 @@ "dependencies": { "@ensdomains/ensjs": "^4.0.2", "@ensnode/ens-deployments": "workspace:*", - "@ensnode/utils": "workspace:*", "@ensnode/ensrainbow-sdk": "workspace:*", "@ensnode/ponder-metadata": "workspace:*", "@ensnode/ponder-schema": "workspace:*", "@ensnode/ponder-subgraph": "workspace:*", + "@ensnode/utils": "workspace:*", + "caip": "^1.1.1", "hono": "catalog:", "ponder": "catalog:", "ts-deepmerge": "^7.0.2", diff --git a/apps/ensindexer/src/handlers/Registrar.ts b/apps/ensindexer/src/handlers/Registrar.ts index 185f3176e..4a45e33fb 100644 --- a/apps/ensindexer/src/handlers/Registrar.ts +++ b/apps/ensindexer/src/handlers/Registrar.ts @@ -1,7 +1,7 @@ import { type Context } from "ponder:registry"; import schema from "ponder:schema"; import { isLabelIndexable, makeSubnodeNamehash } from "@ensnode/utils/subname-helpers"; -import type { Labelhash } from "@ensnode/utils/types"; +import type { LabelHash } from "@ensnode/utils/types"; import { type Hex, labelhash as _labelhash, namehash } from "viem"; import { createSharedEventValues, upsertAccount, upsertRegistration } from "../lib/db-helpers"; import { labelByHash } from "../lib/graphnode-helpers"; @@ -23,7 +23,7 @@ export const makeRegistrarHandlers = (ownedName: OwnedName) => { async function setNamePreimage( context: Context, name: string, - labelhash: Labelhash, + labelhash: LabelHash, cost: bigint, ) { // NOTE: ponder intentionally removes null bytes to spare users the footgun of @@ -68,7 +68,7 @@ export const makeRegistrarHandlers = (ownedName: OwnedName) => { }: { context: Context; event: EventWithArgs<{ - labelhash: Labelhash; + labelhash: LabelHash; owner: Hex; expires: bigint; }>; @@ -131,7 +131,7 @@ export const makeRegistrarHandlers = (ownedName: OwnedName) => { context: Context; event: EventWithArgs<{ name: string; - label: Labelhash; + label: LabelHash; cost: bigint; }>; }) { @@ -144,7 +144,7 @@ export const makeRegistrarHandlers = (ownedName: OwnedName) => { event, }: { context: Context; - event: EventWithArgs<{ name: string; label: Labelhash; cost: bigint }>; + event: EventWithArgs<{ name: string; label: LabelHash; cost: bigint }>; }) { const { name, label, cost } = event.args; await setNamePreimage(context, name, label, cost); @@ -155,7 +155,7 @@ export const makeRegistrarHandlers = (ownedName: OwnedName) => { event, }: { context: Context; - event: EventWithArgs<{ labelhash: Labelhash; expires: bigint }>; + event: EventWithArgs<{ labelhash: LabelHash; expires: bigint }>; }) { const { labelhash, expires } = event.args; @@ -186,7 +186,7 @@ export const makeRegistrarHandlers = (ownedName: OwnedName) => { event, }: { context: Context; - event: EventWithArgs<{ labelhash: Labelhash; from: Hex; to: Hex }>; + event: EventWithArgs<{ labelhash: LabelHash; from: Hex; to: Hex }>; }) { const { labelhash, to } = event.args; await upsertAccount(context, to); diff --git a/apps/ensindexer/src/handlers/Registry.ts b/apps/ensindexer/src/handlers/Registry.ts index 5e8127fa0..11f613d55 100644 --- a/apps/ensindexer/src/handlers/Registry.ts +++ b/apps/ensindexer/src/handlers/Registry.ts @@ -2,7 +2,7 @@ import { Context } from "ponder:registry"; import schema from "ponder:schema"; import { encodeLabelhash } from "@ensdomains/ensjs/utils"; import { ROOT_NODE, isLabelIndexable, makeSubnodeNamehash } from "@ensnode/utils/subname-helpers"; -import type { Labelhash, Node } from "@ensnode/utils/types"; +import type { LabelHash, Node } from "@ensnode/utils/types"; import { type Hex, zeroAddress } from "viem"; import { createSharedEventValues, upsertAccount, upsertResolver } from "../lib/db-helpers"; import { labelByHash } from "../lib/graphnode-helpers"; @@ -84,7 +84,7 @@ export const makeRegistryHandlers = (ownedName: OwnedName) => { event, }: { context: Context; - event: EventWithArgs<{ node: Node; label: Labelhash; owner: Hex }>; + event: EventWithArgs<{ node: Node; label: LabelHash; owner: Hex }>; }) => { const { label: labelhash, node, owner } = event.args; diff --git a/apps/ensindexer/src/lib/graphnode-helpers.ts b/apps/ensindexer/src/lib/graphnode-helpers.ts index 66d657128..5335b6dfe 100644 --- a/apps/ensindexer/src/lib/graphnode-helpers.ts +++ b/apps/ensindexer/src/lib/graphnode-helpers.ts @@ -1,5 +1,5 @@ import { EnsRainbowApiClient, ErrorCode, isHealError } from "@ensnode/ensrainbow-sdk"; -import type { Labelhash } from "@ensnode/utils/types"; +import type { LabelHash } from "@ensnode/utils/types"; import { ensRainbowEndpointUrl } from "./ponder-helpers"; const ensRainbowApiClient = new EnsRainbowApiClient({ @@ -24,7 +24,7 @@ if ( * @returns the original label if found, or null if not found for the labelhash. * @throws if the labelhash is not correctly formatted, or server error occurs, or connection error occurs. **/ -export async function labelByHash(labelhash: Labelhash): Promise { +export async function labelByHash(labelhash: LabelHash): Promise { const healResponse = await ensRainbowApiClient.heal(labelhash); if (!isHealError(healResponse)) { diff --git a/apps/ensindexer/src/lib/ids.ts b/apps/ensindexer/src/lib/ids.ts index 6480f9e9b..415e2ab1c 100644 --- a/apps/ensindexer/src/lib/ids.ts +++ b/apps/ensindexer/src/lib/ids.ts @@ -1,4 +1,4 @@ -import type { Labelhash, Node } from "@ensnode/utils/types"; +import type { LabelHash, Node } from "@ensnode/utils/types"; import type { Address, Hex } from "viem"; // NOTE: subgraph uses lowercase address here, viem provides us checksummed, so we lowercase it @@ -59,7 +59,7 @@ export const makeEventId = ( * @param node the node of the full name that was registered * @returns a unique registration id */ -export const makeRegistrationId = (registrarName: string, labelHash: Labelhash, node: Node) => { +export const makeRegistrationId = (registrarName: string, labelHash: LabelHash, node: Node) => { if (registrarName === "eth") { // For the "v1" of ENSIndexer (at a minimum) we want to preserve backwards // compatibility with Registration id's issued by the ENS Subgraph. diff --git a/apps/ensindexer/src/plugins/base/handlers/Registrar.ts b/apps/ensindexer/src/plugins/base/handlers/Registrar.ts index beee6923b..7e371b2bc 100644 --- a/apps/ensindexer/src/plugins/base/handlers/Registrar.ts +++ b/apps/ensindexer/src/plugins/base/handlers/Registrar.ts @@ -1,7 +1,7 @@ import { ponder } from "ponder:registry"; import schema from "ponder:schema"; import { makeSubnodeNamehash, uint256ToHex32 } from "@ensnode/utils/subname-helpers"; -import type { Labelhash } from "@ensnode/utils/types"; +import type { LabelHash } from "@ensnode/utils/types"; import { zeroAddress } from "viem"; import { makeRegistrarHandlers } from "../../../handlers/Registrar"; import { upsertAccount } from "../../../lib/db-helpers"; @@ -14,7 +14,7 @@ import { PonderENSPluginHandlerArgs } from "../../../lib/plugin-helpers"; * subname of base.eth that was registered. * https://github.com/base/basenames/blob/1b5c1ad/src/L2/RegistrarController.sol#L488 */ -const tokenIdToLabelhash = (tokenId: bigint): Labelhash => uint256ToHex32(tokenId); +const tokenIdToLabelhash = (tokenId: bigint): LabelHash => uint256ToHex32(tokenId); export default function ({ ownedName, namespace }: PonderENSPluginHandlerArgs<"base.eth">) { const { diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/EthRegistry.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/EthRegistry.ts index 28909e880..be947f8d2 100644 --- a/apps/ensindexer/src/plugins/ens-v2/handlers/EthRegistry.ts +++ b/apps/ensindexer/src/plugins/ens-v2/handlers/EthRegistry.ts @@ -1,54 +1,16 @@ import { ponder } from "ponder:registry"; -import schema from "ponder:schema"; import { PonderENSPluginHandlerArgs } from "../../../lib/plugin-helpers"; -import { createDomainId, createEventId, generateTokenId, updateDomainLabel } from "../v2-lib"; +import { + handleNewSubname, + handleTransferBatch, + handleTransferSingle, + handleURI, +} from "./shared/Registry"; export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { - ponder.on(namespace("EthRegistry:TransferSingle"), async ({ event, context }) => { - console.log("EthRegistry:TransferSingle", event.transaction.to); - const timestamp = event.block.timestamp; - const labelHash = event.args.id.toString(); - const domainId = createDomainId(event.transaction.to?.toString(), labelHash); - - await context.db.insert(schema.v2_domain).values({ - id: domainId, - labelHash: labelHash, - owner: event.args.to.toString(), - registry: event.transaction.to?.toString(), - createdAt: timestamp, - updatedAt: timestamp, - }); - - // Store the event data - const eventId = createEventId(event); - await context.db.insert(schema.v2_transferSingleEvent).values({ - id: eventId, - registryId: event.transaction.to?.toString(), - tokenId: event.args.id.toString(), - from: event.args.from.toString(), - to: event.args.to.toString(), - value: event.args.value, - source: "EthRegistry", - createdAt: timestamp, - updatedAt: timestamp, - }); - }); - - ponder.on(namespace("EthRegistry:NewSubname"), async ({ event, context }) => { - console.log("EthRegistry:NewSubname", event.transaction.to); - const tokenId = generateTokenId(event.args.label); - const registryId = event.transaction.to?.toString(); - const domainId = createDomainId(registryId, tokenId); - - await updateDomainLabel( - context, - domainId, - event.args.label, - tokenId, - event.block.timestamp, - event, - "EthRegistry", - ); - }); + ponder.on(namespace("EthRegistry:NewSubname"), handleNewSubname); + ponder.on(namespace("EthRegistry:TransferSingle"), handleTransferSingle); + ponder.on(namespace("EthRegistry:TransferBatch"), handleTransferBatch); + ponder.on(namespace("EthRegistry:URI"), handleURI); } diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/OwnedResolver.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/OwnedResolver.ts deleted file mode 100644 index ae2db4f69..000000000 --- a/apps/ensindexer/src/plugins/ens-v2/handlers/OwnedResolver.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ponder } from "ponder:registry"; -import schema from "ponder:schema"; - -import { PonderENSPluginHandlerArgs } from "../../../lib/plugin-helpers"; - -export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { - ponder.on(namespace("OwnedResolver:AddressChanged"), async ({ event, context }) => { - const timestamp = event.block.timestamp; - const resolverId = event.transaction.to?.toString(); - if (!resolverId) return; - - console.log("OwnedResolver:AddressChanged", event.args, resolverId); - const record = await context.db.find(schema.v2_resolver, { id: resolverId }); - if (record) { - console.log("OwnedResolver:AddressChanged", "Record found", record); - await context.db.update(schema.v2_resolver, { id: record.id }).set({ - ...record, - address: event.args.newAddress.toString(), - updatedAt: timestamp, - node: event.args.node.toString(), - }); - } else { - console.log("OwnedResolver:AddressChanged", "No record found"); - } - }); -} diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts index 8254be625..d9c235568 100644 --- a/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts +++ b/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts @@ -2,70 +2,56 @@ import { ponder } from "ponder:registry"; import schema from "ponder:schema"; import { PonderENSPluginHandlerArgs } from "../../../lib/plugin-helpers"; -import { createEventId } from "../v2-lib"; +import { makeContractId, makeLabelId, maskTokenId } from "../v2-lib"; export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { + // NOTE: can arrive in any order, must upsert all relevant entities ponder.on(namespace("RegistryDatastore:SubregistryUpdate"), async ({ context, event }) => { - console.log("RegistryDatastore:SubregistryUpdate", event.args); - const timestamp = event.block.timestamp; - await context.db.insert(schema.v2_registry).values({ - id: event.args.registry.toString(), - labelHash: event.args.labelHash.toString(), - subregistryId: event.args.subregistry, - flags: event.args.flags, - createdAt: timestamp, - updatedAt: timestamp, - }); - console.log(event); - const eventId = createEventId(event); - await context.db.insert(schema.v2_subregistryUpdateEvent).values({ - id: eventId, - registryId: event.args.registry.toString(), - labelHash: event.args.labelHash.toString(), - subregistryId: event.args.subregistry, - flags: event.args.flags, - createdAt: timestamp, - updatedAt: timestamp, - }); + console.table({ on: "RegistryDatastore:SubregistryUpdate", ...event.args }); + + const { registry, labelHash, subregistry, flags } = event.args; + + const tokenId = maskTokenId(labelHash); // NOTE: ensure tokenId is masked correctly + const registryId = makeContractId(context.network.chainId, registry); + const labelId = makeLabelId(registryId, tokenId); + const subregistryId = makeContractId(context.network.chainId, subregistry); + + // ensure registry entity + await context.db.insert(schema.v2_registry).values({ id: registryId }).onConflictDoNothing(); + + // ensure subregistry entity + await context.db.insert(schema.v2_registry).values({ id: subregistryId }).onConflictDoNothing(); + + await context.db + .insert(schema.v2_label) + // insert label with subregistry info + .values({ id: labelId, registryId, tokenId, subregistryId, subregistryFlags: flags }) + // or update existing label with subregistry info + .onConflictDoUpdate({ subregistryId, subregistryFlags: flags }); }); + // NOTE: can arrive in any order, must upsert all relevant entities ponder.on(namespace("RegistryDatastore:ResolverUpdate"), async ({ context, event }) => { - console.log("RegistryDatastore:ResolverUpdate", event.args); - const timestamp = event.block.timestamp; - const record2 = await context.db.find(schema.v2_registry, { - id: event.args.registry.toString(), - }); - if (record2) { - console.log("RegistryDatastore:ResolverUpdate", "Record found", record2); - await context.db - .update(schema.v2_registry, { id: record2.id }) - .set({ ...record2, resolver: event.args.resolver.toString() }); + console.table({ on: "RegistryDatastore:ResolverUpdate", ...event.args }); + + const { registry, labelHash, resolver, flags } = event.args; + + const tokenId = maskTokenId(labelHash); // NOTE: ensure tokenId is masked correctly + const registryId = makeContractId(context.network.chainId, registry); + const labelId = makeLabelId(registryId, tokenId); + const resolverId = makeContractId(context.network.chainId, resolver); + + // ensure registry entity + await context.db.insert(schema.v2_registry).values({ id: registryId }).onConflictDoNothing(); - const record3 = await context.db.find(schema.v2_resolver, { - id: event.args.resolver.toString(), - }); - if (!record3) { - console.log("RegistryDatastore:ResolverUpdate", "Creating new resolver record"); - await context.db.insert(schema.v2_resolver).values({ - id: event.args.resolver.toString(), - createdAt: timestamp, - updatedAt: timestamp, - }); - } - } else { - console.log("RegistryDatastore:ResolverUpdate", "No record found"); - } + // ensure resolver entity + await context.db.insert(schema.v2_resolver).values({ id: resolverId }).onConflictDoNothing(); - // Store the event data - const eventId = createEventId(event); - await context.db.insert(schema.v2_resolverUpdateEvent).values({ - id: eventId, - registryId: event.args.registry.toString(), - labelHash: event.args.labelHash.toString(), - resolverId: event.args.resolver.toString(), - flags: event.args.flags, - createdAt: timestamp, - updatedAt: timestamp, - }); + await context.db + .insert(schema.v2_label) + // insert label with resolver info + .values({ id: labelId, registryId, tokenId, resolverId, resolverFlags: flags }) + // or update existing label with resolver info + .onConflictDoUpdate({ resolverId, resolverFlags: flags }); }); } diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/Resolver.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/Resolver.ts new file mode 100644 index 000000000..a28253d4e --- /dev/null +++ b/apps/ensindexer/src/plugins/ens-v2/handlers/Resolver.ts @@ -0,0 +1,73 @@ +import { Context, ponder } from "ponder:registry"; +import schema from "ponder:schema"; + +import { Node } from "@ensnode/utils/types"; +import { Address } from "viem"; +import { PonderENSPluginHandlerArgs } from "../../../lib/plugin-helpers"; +import { EventWithArgs } from "../../../lib/ponder-helpers"; +import { makeContractId, makeResolverRecordsAddressId, makeResolverRecordsId } from "../v2-lib"; + +export async function upsertResolver( + context: Context, + values: typeof schema.v2_resolver.$inferInsert, +) { + return context.db.insert(schema.v2_resolver).values(values).onConflictDoUpdate(values); +} + +export async function upsertResolverRecords( + context: Context, + values: typeof schema.v2_resolverRecords.$inferInsert, +) { + return context.db.insert(schema.v2_resolverRecords).values(values).onConflictDoUpdate(values); +} + +export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { + async function handleAddressChanged({ + context, + event, + }: { + context: Context; + event: EventWithArgs<{ + node: Node; + coinType: bigint; + newAddress: Address; + }>; + }) { + const { node, coinType, newAddress } = event.args; + + const resolverId = makeContractId(context.network.chainId, event.log.address); + const resolverRecordsId = makeResolverRecordsId(resolverId, node); + const resolverRecordsAddressId = makeResolverRecordsAddressId(resolverRecordsId, coinType); + + await upsertResolver(context, { id: resolverId }); + await upsertResolverRecords(context, { id: resolverRecordsId, resolverId, node }); + + await context.db + .insert(schema.v2_resolverRecordsAddress) + // create a new address entity + .values({ + id: resolverRecordsAddressId, + resolverRecordsId, + coinType, + address: newAddress, + }) + // or update the existing one + .onConflictDoUpdate({ address: newAddress }); + } + + ponder.on(namespace("Resolver:AddrChanged"), async ({ context, event }) => { + await handleAddressChanged({ + context, + event: { + ...event, + args: { + node: event.args.node, + newAddress: event.args.a, + coinType: 60n, // TODO: make sure this is correct ETH cointype + }, + }, + }); + }); + + ponder.on(namespace("Resolver:AddressChanged"), handleAddressChanged); +} diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/RootRegistry.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/RootRegistry.ts index a5f1d973a..eacc94427 100644 --- a/apps/ensindexer/src/plugins/ens-v2/handlers/RootRegistry.ts +++ b/apps/ensindexer/src/plugins/ens-v2/handlers/RootRegistry.ts @@ -1,54 +1,16 @@ import { ponder } from "ponder:registry"; -import schema from "ponder:schema"; import { PonderENSPluginHandlerArgs } from "../../../lib/plugin-helpers"; -import { createDomainId, createEventId, generateTokenId, updateDomainLabel } from "../v2-lib"; +import { + handleNewSubname, + handleTransferBatch, + handleTransferSingle, + handleURI, +} from "./shared/Registry"; export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { - ponder.on(namespace("RootRegistry:TransferSingle"), async ({ event, context }) => { - const timestamp = event.block.timestamp; - const tokenId = event.args.id.toString(); - const registryId = event.transaction.to?.toString(); - const domainId = createDomainId(registryId, tokenId); - const values = { - id: domainId, - labelHash: tokenId, - owner: event.args.to.toString(), - registry: registryId, - createdAt: timestamp, - updatedAt: timestamp, - }; - console.log("RootRegistry:TransferSingle", values); - await context.db.insert(schema.v2_domain).values(values); - // Store the event data - const eventId = createEventId(event); - await context.db.insert(schema.v2_transferSingleEvent).values({ - id: eventId, - registryId: registryId, - tokenId: tokenId, - from: event.args.from.toString(), - to: event.args.to.toString(), - value: event.args.value, - source: "RootRegistry", - createdAt: timestamp, - updatedAt: timestamp, - }); - }); - - ponder.on(namespace("RootRegistry:NewSubname"), async ({ event, context }) => { - console.log("RootRegistry:NewSubname", event.transaction.to); - const tokenId = generateTokenId(event.args.label); - const registryId = event.transaction.to?.toString(); - const domainId = createDomainId(registryId, tokenId); - - await updateDomainLabel( - context, - domainId, - event.args.label, - tokenId, - event.block.timestamp, - event, - "RootRegistry", - ); - }); + ponder.on(namespace("RootRegistry:NewSubname"), handleNewSubname); + ponder.on(namespace("RootRegistry:TransferSingle"), handleTransferSingle); + ponder.on(namespace("RootRegistry:TransferBatch"), handleTransferBatch); + ponder.on(namespace("RootRegistry:URI"), handleURI); } diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts new file mode 100644 index 000000000..be48fa432 --- /dev/null +++ b/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts @@ -0,0 +1,142 @@ +/// +/// Shared Registry Handlers +/// + +import { Context } from "ponder:registry"; +import schema from "ponder:schema"; +import { isLabelIndexable } from "@ensnode/utils/subname-helpers"; +import { Address, getAddress, labelhash } from "viem"; +import { EventWithArgs } from "../../../../lib/ponder-helpers"; +import { + labelHashToTokenId, + makeContractId, + makeLabelId, + maskTokenId, + materializeLabelName, +} from "../../v2-lib"; + +// NewSubname and DataStore events may arrive in any order +export async function handleNewSubname({ + context, + event, +}: { + context: Context; + event: EventWithArgs<{ label: string }>; +}) { + console.table({ on: "NewSubname", ...event.args }); + const { label } = event.args; + + const registryId = makeContractId(context.network.chainId, event.log.address); + const tokenId = labelHashToTokenId(labelhash(label)); + const labelId = makeLabelId(registryId, tokenId); + + // ensure that this registry exists + await context.db.insert(schema.v2_registry).values({ id: registryId }).onConflictDoNothing(); + + const indexableLabel = isLabelIndexable(label) ? label : null; + + await context.db + .insert(schema.v2_label) + // insert new Label with `label` value + .values({ + id: labelId, + registryId, + tokenId, + label: indexableLabel, + }) + // or upsert existing Label's `label` value + .onConflictDoUpdate({ label: indexableLabel }); + + // materialize name field + await materializeLabelName(context, labelId); +} + +export async function handleURI({ + context, + event, +}: { + context: Context; + event: EventWithArgs<{ + id: bigint; + value: string; + }>; +}) { + console.table({ on: "URI", ...event.args }); + + const { id, value: uri } = event.args; + + const registryId = makeContractId(context.network.chainId, event.log.address); + const tokenId = maskTokenId(id); // NOTE: ensure token id is masked + const labelId = makeLabelId(registryId, tokenId); + + await context.db + .insert(schema.v2_label) + // insert new Label with uri + .values({ + id: labelId, + registryId, + tokenId, + uri, + }) + // or update uri of existing Label + .onConflictDoUpdate({ uri }); +} + +// ERC1155 Transfer events may arrive in any order +// TODO: correctly burn tokens +async function handleTransfer({ + context, + event, +}: { context: Context; event: EventWithArgs<{ id: bigint; to: Address }> }) { + console.table({ on: "handleTransfer", ...event.args }); + + const { id, to } = event.args; + + const registryId = makeContractId(context.network.chainId, event.log.address); + const tokenId = maskTokenId(id); // NOTE: ensures that the tokenId emitted is masked + const labelId = makeLabelId(registryId, tokenId); + const owner = getAddress(to); // NOTE: ensures that owner is checksummed + + await context.db + .insert(schema.v2_label) + // insert new Label with owner + .values({ + id: labelId, + registryId, + tokenId, + owner, + }) + // or update owner of existing Label + .onConflictDoUpdate({ owner }); +} + +export async function handleTransferSingle({ + context, + event, +}: { + context: Context; + event: EventWithArgs<{ id: bigint; to: Address }>; +}) { + await handleTransfer({ context, event }); +} + +export async function handleTransferBatch({ + context, + event, +}: { + context: Context; + event: EventWithArgs<{ ids: readonly bigint[]; to: Address }>; +}) { + for (const [i, id] of event.args.ids.entries()) { + await handleTransfer({ + context, + event: { + ...event, + args: { + id, + to: event.args.to, + }, + }, + }); + } +} diff --git a/apps/ensindexer/src/plugins/ens-v2/ponder.plugin.ts b/apps/ensindexer/src/plugins/ens-v2/ponder.plugin.ts index bc0eca30f..cb2278528 100644 --- a/apps/ensindexer/src/plugins/ens-v2/ponder.plugin.ts +++ b/apps/ensindexer/src/plugins/ens-v2/ponder.plugin.ts @@ -15,21 +15,21 @@ const namespace = createPluginNamespace(pluginName); export const config = createConfig({ networks: networksConfigForChain(chain), contracts: { - [namespace("EthRegistry")]: { - network: networkConfigForContract(chain, contracts.EthRegistry), - abi: contracts.EthRegistry.abi, - }, [namespace("RegistryDatastore")]: { network: networkConfigForContract(chain, contracts.RegistryDatastore), abi: contracts.RegistryDatastore.abi, }, + [namespace("Resolver")]: { + network: networkConfigForContract(chain, contracts.Resolver), + abi: contracts.Resolver.abi, + }, [namespace("RootRegistry")]: { network: networkConfigForContract(chain, contracts.RootRegistry), abi: contracts.RootRegistry.abi, }, - [namespace("OwnedResolver")]: { - network: networkConfigForContract(chain, contracts.OwnedResolver), - abi: contracts.OwnedResolver.abi, + [namespace("EthRegistry")]: { + network: networkConfigForContract(chain, contracts.EthRegistry), + abi: contracts.EthRegistry.abi, }, }, }); @@ -41,6 +41,6 @@ export const activate = activateHandlers({ import("./handlers/EthRegistry"), import("./handlers/RegistryDatastore"), import("./handlers/RootRegistry"), - import("./handlers/OwnedResolver"), + import("./handlers/Resolver"), ], }); diff --git a/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts b/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts index 0e7d77100..bc92c2ba2 100644 --- a/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts +++ b/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts @@ -2,122 +2,96 @@ * This file temporarily located here for prototyping—should be moved to ensnode-utils. */ -import { Context, Event } from "ponder:registry"; +import { Context } from "ponder:registry"; import schema from "ponder:schema"; -import { eq } from "ponder"; -import { keccak256, toBytes } from "viem"; +import { LabelHash, Node } from "@ensnode/utils/types"; +import { AccountId } from "caip"; +import { Address, getAddress, hexToBigInt, namehash } from "viem"; const LABEL_HASH_MASK = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000n; -// Utility functions -export function createEventId(event: Event): string { - return [event.block.number, event.log.logIndex].join("-"); -} - -export function generateTokenId(label: string): string { - const hash = keccak256(toBytes(label)); +/// +/// UPSERTS +/// - // Convert the hash to BigInt and perform the bitwise operation - const hashBigInt = BigInt(hash); - const mask = BigInt(0x7); - const tokenId = hashBigInt & ~mask; // Equivalent to & ~0x7 - console.log("generateTokenId", label, hash, tokenId); - return tokenId.toString(); -} - -export function createDomainId(registryId: string | undefined, tokenId: string): string { - return `${registryId}-${tokenId}`; +export async function upsertRegistry( + context: Context, + values: typeof schema.v2_registry.$inferInsert, +) { + return await context.db.insert(schema.v2_registry).values(values).onConflictDoUpdate(values); } -export async function updateDomainLabel( +export async function upsertResolverRecords( context: Context, - domainId: string, - label: string, - tokenId: string, - timestamp: bigint, - event: any, - source: string, + values: typeof schema.v2_resolverRecords.$inferInsert, ) { - const domainRecord = await context.db.find(schema.v2_domain, { id: domainId }); - if (!domainRecord) { - console.log("Domain not found:", domainId); - return; - } - - console.log("Updating domain label:", domainRecord); + // ensure Resolver entity + await context.db + .insert(schema.v2_resolver) + .values({ id: values.resolverId }) + .onConflictDoNothing(); + + // ensure ResolverRecords entity + return context.db // + .insert(schema.v2_resolverRecords) + .values(values) + .onConflictDoUpdate(values); +} - // Update registry database if exists - const labelHash = BigInt(tokenId) & LABEL_HASH_MASK; - const registryRecord = await context.db.sql.query.v2_registry.findFirst({ - where: eq(schema.v2_registry.labelHash, labelHash.toString()), - }); +/// +/// IDS +/// - if (registryRecord) { - console.log("Registry record found:", registryRecord); - await context.db - .update(schema.v2_registry, { id: registryRecord.id }) - .set({ ...registryRecord, label: label }); +/** + * Encodes a contract's cross-chain unique address using a CAIP-10 AccountId. + * + * @param chainId source chain id + * @param address contract address + * @returns + */ +export const makeContractId = (chainId: number, address: Address) => { + // ensure checksummed + if (address !== getAddress(address)) { + throw new Error(`makeContractId: "${address}" is not checksummed`); } - let name = label; - if (source != "RootRegistry") { - let currentRegistryId = registryRecord!.id; - let currentName = name; - while (true) { - const parentRegistryRecord = await context.db.sql.query.v2_registry.findFirst({ - where: eq(schema.v2_registry.subregistryId, currentRegistryId), - }); + return new AccountId({ + chainId: { + namespace: "eip155", // ENSIndexer only ever indexes EVM chains namespaced by eip155 + reference: chainId.toString(), + }, + address, + }).toString(); +}; - if (!parentRegistryRecord) { - break; // We've reached the top level - } +export const makeResolverRecordsId = (resolverId: string, node: Node) => + [resolverId, node].join("-"); - console.log("Parent registry record found:", parentRegistryRecord); - let parentDomainRecord = await context.db.sql.query.v2_domain.findFirst({ - where: eq(schema.v2_domain.registry, parentRegistryRecord.id), - }); +export const makeLabelId = (registryId: string, tokenId: bigint) => [registryId, tokenId].join("-"); - if (!parentDomainRecord) break; +export const makeResolverRecordsAddressId = (resolverRecordsId: string, coinType: bigint) => + [resolverRecordsId, coinType].join("-"); - console.log("Parent domain record found:", parentDomainRecord); +/// +/// UTILS +/// - if (parentDomainRecord.isTld) { - currentName = currentName + "." + parentDomainRecord.label; - console.log("Reached TLD. Final name:", currentName); - break; - } +/** + * masks a given tokenId + */ - currentName = currentName + "." + parentDomainRecord.label; - currentRegistryId = parentRegistryRecord.id; - console.log("Current name:", currentName); - } +export const maskTokenId = (tokenId: bigint) => tokenId & LABEL_HASH_MASK; - name = currentName; - } - // Update the domain record - const nameArray = domainRecord.name ? [...domainRecord.name, name] : [name]; - const newDomainRecord = { - ...domainRecord, - label: label, - name: nameArray, - labelHash: tokenId, - isTld: source === "RootRegistry" ? true : false, - updatedAt: timestamp, - }; - - await context.db.update(schema.v2_domain, { id: domainId }).set(newDomainRecord); - - // Store the event data - const eventId = createEventId(event); - await context.db.insert(schema.v2_newSubnameEvent).values({ - id: eventId, - registryId: domainRecord.registry, - label: label, - labelHash: tokenId, - source: source, - createdAt: timestamp, - updatedAt: timestamp, - }); - - console.log("Domain updated:", domainId); +/** + * encodes a hex labelHash as bigint, masking the lower 32 bits + */ +export const labelHashToTokenId = (labelHash: LabelHash) => + maskTokenId(hexToBigInt(labelHash, { size: 32 })); + +/// +/// Helpers +/// + +export async function materializeLabelName(context: Context, labelId: string) { + // TODO: implement Label name materialization by traversing tree } diff --git a/apps/ensindexer/src/plugins/eth/handlers/EthRegistrar.ts b/apps/ensindexer/src/plugins/eth/handlers/EthRegistrar.ts index d00729fc0..2c4c2c9bc 100644 --- a/apps/ensindexer/src/plugins/eth/handlers/EthRegistrar.ts +++ b/apps/ensindexer/src/plugins/eth/handlers/EthRegistrar.ts @@ -1,6 +1,6 @@ import { ponder } from "ponder:registry"; import { uint256ToHex32 } from "@ensnode/utils/subname-helpers"; -import type { Labelhash } from "@ensnode/utils/types"; +import type { LabelHash } from "@ensnode/utils/types"; import { makeRegistrarHandlers } from "../../../handlers/Registrar"; import { PonderENSPluginHandlerArgs } from "../../../lib/plugin-helpers"; @@ -11,7 +11,7 @@ import { PonderENSPluginHandlerArgs } from "../../../lib/plugin-helpers"; * direct subname of .eth that was registered. * https://github.com/ensdomains/ens-contracts/blob/db613bc/contracts/ethregistrar/ETHRegistrarController.sol#L215 */ -const tokenIdToLabelhash = (tokenId: bigint): Labelhash => uint256ToHex32(tokenId); +const tokenIdToLabelhash = (tokenId: bigint): LabelHash => uint256ToHex32(tokenId); export default function ({ ownedName, namespace }: PonderENSPluginHandlerArgs<"eth">) { const { diff --git a/apps/ensindexer/src/plugins/linea/handlers/EthRegistrar.ts b/apps/ensindexer/src/plugins/linea/handlers/EthRegistrar.ts index 845681478..ec7c1a107 100644 --- a/apps/ensindexer/src/plugins/linea/handlers/EthRegistrar.ts +++ b/apps/ensindexer/src/plugins/linea/handlers/EthRegistrar.ts @@ -1,7 +1,7 @@ import { ponder } from "ponder:registry"; import schema from "ponder:schema"; import { makeSubnodeNamehash, uint256ToHex32 } from "@ensnode/utils/subname-helpers"; -import type { Labelhash } from "@ensnode/utils/types"; +import type { LabelHash } from "@ensnode/utils/types"; import { zeroAddress } from "viem"; import { makeRegistrarHandlers } from "../../../handlers/Registrar"; import { upsertAccount } from "../../../lib/db-helpers"; @@ -14,7 +14,7 @@ import { PonderENSPluginHandlerArgs } from "../../../lib/plugin-helpers"; * direct subname of linea.eth that was registered. * https://github.com/Consensys/linea-ens/blob/3a4f02f/packages/linea-ens-contracts/contracts/ethregistrar/ETHRegistrarController.sol#L447 */ -const tokenIdToLabelhash = (tokenId: bigint): Labelhash => uint256ToHex32(tokenId); +const tokenIdToLabelhash = (tokenId: bigint): LabelHash => uint256ToHex32(tokenId); export default function ({ ownedName, namespace }: PonderENSPluginHandlerArgs<"linea.eth">) { const { diff --git a/apps/ensindexer/test/graphnode-helpers.spec.ts b/apps/ensindexer/test/graphnode-helpers.spec.ts index 7937a53a3..3e3230c8a 100644 --- a/apps/ensindexer/test/graphnode-helpers.spec.ts +++ b/apps/ensindexer/test/graphnode-helpers.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { Labelhash } from "@ensnode/utils/types"; +import type { LabelHash } from "@ensnode/utils/types"; import { labelByHash } from "../src/lib/graphnode-helpers"; describe("labelByHash", () => { @@ -39,7 +39,7 @@ describe("labelByHash", () => { it("throws an error for an invalid labelhash missing 0x prefix", async () => { await expect( labelByHash( - "12ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0600" as Labelhash, + "12ca5d0b4ef1129e04bfe7d35ac9def2f4f91daeb202cbe6e613f1dd17b2da0600" as LabelHash, ), ).rejects.toThrow("Labelhash must be 0x-prefixed"); }); diff --git a/packages/ens-deployments/src/abis/ens-v2/OwnedResolver.ts b/packages/ens-deployments/src/abis/ens-v2/Resolver.ts similarity index 99% rename from packages/ens-deployments/src/abis/ens-v2/OwnedResolver.ts rename to packages/ens-deployments/src/abis/ens-v2/Resolver.ts index 2e161c1e6..c4716a9f8 100644 --- a/packages/ens-deployments/src/abis/ens-v2/OwnedResolver.ts +++ b/packages/ens-deployments/src/abis/ens-v2/Resolver.ts @@ -1,4 +1,4 @@ -export const OwnedResolver = [ +export const Resolver = [ { inputs: [ { diff --git a/packages/ens-deployments/src/sepolia.ts b/packages/ens-deployments/src/sepolia.ts index dbe03e2d0..7e969d3e3 100644 --- a/packages/ens-deployments/src/sepolia.ts +++ b/packages/ens-deployments/src/sepolia.ts @@ -16,8 +16,8 @@ import { Resolver as eth_Resolver } from "./abis/eth/Resolver"; // ENS v2 ABIs import { ETHRegistry as ensV2_ETHRegistry } from "./abis/ens-v2/ETHRegistry"; -import { OwnedResolver as ensV2_OwnedResolver } from "./abis/ens-v2/OwnedResolver"; import { RegistryDatastore as ensV2_RegistryDatastore } from "./abis/ens-v2/RegistryDatastore"; +import { Resolver as ensV2_Resolver } from "./abis/ens-v2/Resolver"; import { RootRegistry as ensV2_RootRegistry } from "./abis/ens-v2/RootRegistry"; /** @@ -76,32 +76,25 @@ export default { // Addresses and Start Blocks from ens-ponder // https://github.com/ensdomains/ens-ponder contracts: { - EthRegistry: { - abi: ensV2_ETHRegistry, - address: "0xFd8562F0B884b5f8d137ff50D25fc26b34868172", - startBlock: 7699319, - }, RegistryDatastore: { abi: ensV2_RegistryDatastore, address: "0x73308B430b61958e3d8C4a6db08153372d5eb125", startBlock: 7699319, }, + Resolver: { + abi: ensV2_Resolver, + startBlock: 7699319, + }, + EthRegistry: { + abi: ensV2_ETHRegistry, + address: "0xFd8562F0B884b5f8d137ff50D25fc26b34868172", + startBlock: 7699319, + }, RootRegistry: { abi: ensV2_RootRegistry, address: "0xc44D7201065190B290Aaaf6efaDFD49d530547A3", startBlock: 7699319, }, - OwnedResolver: { - abi: ensV2_OwnedResolver, - address: { - address: "0x33d438bb85B76C9211c4F259109D94Fe83F5A5eC", - event: parseAbiItem( - "event ProxyDeployed(address indexed sender, address indexed proxyAddress, uint256 salt, address implementation)", - ), - parameter: "proxyAddress", - }, - startBlock: 7699319, - }, }, }, /** diff --git a/packages/ensnode-utils/src/types.ts b/packages/ensnode-utils/src/types.ts index ecc526111..4adf5218d 100644 --- a/packages/ensnode-utils/src/types.ts +++ b/packages/ensnode-utils/src/types.ts @@ -14,8 +14,8 @@ export type Node = Hex; /** * A hash value that identifies only a single part or "label" of an ENS name. - * The labelhash is just the Keccak-256 output for the label. + * The labelHash is just the Keccak-256 output for the label. * * @link https://docs.ens.domains/ensip/1#labelhash-algorithm */ -export type Labelhash = Hex; +export type LabelHash = Hex; diff --git a/packages/ensrainbow-sdk/src/client.ts b/packages/ensrainbow-sdk/src/client.ts index 1baeef165..87a61dbdc 100644 --- a/packages/ensrainbow-sdk/src/client.ts +++ b/packages/ensrainbow-sdk/src/client.ts @@ -1,6 +1,6 @@ import type { Cache } from "@ensnode/utils/cache"; import { LruCache } from "@ensnode/utils/cache"; -import type { Labelhash } from "@ensnode/utils/types"; +import type { LabelHash } from "@ensnode/utils/types"; import { DEFAULT_ENSRAINBOW_URL, ErrorCode, StatusCode } from "./consts"; export namespace EnsRainbow { @@ -9,7 +9,7 @@ export namespace EnsRainbow { export interface ApiClient { count(): Promise; - heal(labelhash: Labelhash): Promise; + heal(labelhash: LabelHash): Promise; health(): Promise; @@ -132,7 +132,7 @@ export interface EnsRainbowApiClientOptions { */ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { private readonly options: EnsRainbowApiClientOptions; - private readonly cache: Cache; + private readonly cache: Cache; public static readonly DEFAULT_CACHE_CAPACITY = 1000; @@ -154,7 +154,7 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { ...options, }; - this.cache = new LruCache( + this.cache = new LruCache( this.options.cacheCapacity, ); } @@ -201,7 +201,7 @@ export class EnsRainbowApiClient implements EnsRainbow.ApiClient { * // } * ``` */ - async heal(labelhash: Labelhash): Promise { + async heal(labelhash: LabelHash): Promise { const cachedResult = this.cache.get(labelhash); if (cachedResult) { diff --git a/packages/ensrainbow-sdk/src/label-utils.ts b/packages/ensrainbow-sdk/src/label-utils.ts index 894583fe9..ce160c94b 100644 --- a/packages/ensrainbow-sdk/src/label-utils.ts +++ b/packages/ensrainbow-sdk/src/label-utils.ts @@ -1,4 +1,4 @@ -import type { Labelhash } from "@ensnode/utils/types"; +import type { LabelHash } from "@ensnode/utils/types"; import { ByteArray, hexToBytes } from "viem"; /** @@ -7,7 +7,7 @@ import { ByteArray, hexToBytes } from "viem"; * @returns A ByteArray containing the bytes * @throws Error if `labelHash` is not a valid 32-byte hex string */ -export function labelHashToBytes(labelHash: Labelhash): ByteArray { +export function labelHashToBytes(labelHash: LabelHash): ByteArray { try { if (labelHash.length !== 66) { throw new Error(`Invalid labelhash length ${labelHash.length} characters (expected 66)`); diff --git a/packages/ponder-schema/src/ponder.schema.ts b/packages/ponder-schema/src/ponder.schema.ts index c34c6a292..60a018791 100644 --- a/packages/ponder-schema/src/ponder.schema.ts +++ b/packages/ponder-schema/src/ponder.schema.ts @@ -1,5 +1,5 @@ import { index, onchainTable, relations, uniqueIndex } from "ponder"; -import type { Address } from "viem"; +import { type Address, zeroAddress } from "viem"; import { monkeypatchCollate } from "./collate"; /** @@ -685,56 +685,112 @@ export const versionChangedRelations = relations(versionChanged, ({ one }) => ({ * NOTE: These entities kept namespaced for rapid prototyping—see v2 plans for additional context. * https://www.ensnode.io/ensnode/reference/ensnode-v2-notes/ * - * The core design principle here is that the label-based hierarchical namespace is logically - * separated from 'implementation details'. - * - * Resolver and Registry entities are long-lived and never deleted. Label entities _are_ deleted - * when they are no longer present in the namespace (i.e. representative tokens are burned). - * - * Labels store flags for their referenced subRegistry and resolver. + * The core design principle here is that a Registry references many Labels which each reference a + * (sub)Registry which references many Labels... etc. This correctly represents the nature of the + * on-chain contracts and supports the dynamic re-arrangement of the hierarchical namespace as + * proposed by ENSv2. * * open questions: * - how do v1 subregistries other than .eth handle the migration? * - what does v2.eth registry `reliquishing` accomplish, from an indexing perspective? + * - a 'registry' is... any contract tht implements ERC1155 and NewSubname()? may mean that we need to + * index every instance of those events... not very good at all... would be nice if a Registry contract + * emitted an event like NewRegistry() in constructor that indicated whether it was an ENS registry. + * otherwise we'll need to track all of these. ponder doesn't handle dynamic address indexing for + * efficiency reasons. + * - could multiple tokenIds in the ENSv2 system have the same subregistry address? seems yes because + * datastore does not enforce uniqueness, but this results in a many-many mapping between labels + * and subregistries which seems very annoying to work with, in particular for traversals. the + * indexer could enforce uniqueness and if a name sets a registry address that's already assigned + * we could ignore that subtree? not ideal. since ENSv2 is on L2 we can likely include uniqueness + * check without too much of a penalty? + * - event order guarantees would be really nice as part of the v2 spec + * - i.e. registries must emit NewSubname before any ERC1155 events, must emit NewSubname before + * calling datastore, etc. + * - should token ids within registry contracts not be masked? what happens if registry mints + * multiple tokens with conflicting tokenIds? + * - we _need_ to be able to configure ponder's handling of null values in order to correctly index ENSv2 + * because ENSv2 doesn't emit the namehash/labelhash, only human-readable args, which may contain null bytes + * https://github.com/ponder-sh/ponder/issues/1456 * * todo in schema: * - events & event relations - * + * - CAIP typing, @ensnode/utils/types typing + * - store node on label in order to set up resolver record references? + * - createdAt and updatedAt values across the board + * - could use composite schemas more frequently instead of concatenated ids */ -// TODO: we _need_ to be able to configure ponder's handling of null values in order to correctly index ENSv2 -// https://github.com/ponder-sh/ponder/issues/1456 -// because ENSv2 doesn't emit the namehash/labelhash, only human-readable args, which may contain null bytes +export const v2_registry = onchainTable( + "v2_registries", + (t) => ({ + /** + * Registry is keyed by [CAIP-10](https://chainagnostic.org/CAIPs/caip-10) + * i.e. chainId:address + * + * TODO: introducing CAIP-10 for chain-scoping, may or may not be strictly necessary + * TODO: type as CAIP-10 + */ + id: t.text().primaryKey(), + + // TODO: reference registry-specific logic entities here (i.e. .eth registry expiries) + }), + (t) => ({}), +); + +export const v2_registryRelations = relations(v2_registry, ({ one, many }) => ({ + /** + * a registry has one label (i.e is that label's subregistry) + * + * TODO: this invariant seems incorrect — see above open questions + */ + label: one(v2_label, { + fields: [v2_registry.id], + references: [v2_label.subregistryId], + }), + + // a registry has many labels by label.registryId + labels: many(v2_label), +})); /** * A Label entity represents a label in the hierarchical namespace. + * + * TODO: pehaps should rename to `Name`/`Domain` or `Token`? + * + * In ENSv2 this maps 1:1 with a Registry contract's tokenId */ export const v2_label = onchainTable( "v2_labels", (t) => ({ /** - * Labels are keyed by `node`, i.e. the result of `namehash()`, ensuring uniqueness within the - * ENS namespace. + * Labels are unique by (registryId, tokenId), encoded as `${registryId}-${tokenId}` */ - id: t.hex().primaryKey(), + id: t.text().primaryKey(), + /** - * All Labels have a parent, save the Root label. This creates a hierarchical tree namespace, - * in which any label's parentage can be traced to the Root label. + * A Label belogs to a Registry. */ - parentId: t.hex(), + registryId: t.text().notNull(), + /** - * A Label entity represents a given labelHash value i.e. the result of `labelhash()`. This is - * _not_ a UUID value, and collisions are expected (i.e. there will be a Label entity representing + * A Label entity represents a given labelHash value i.e. the result of `labelhash()`, encoded as a bigint 'tokenId'. + * + * This is _not_ a UUID value, and collisions are expected (i.e. there will be a Label entity representing * the `hello` in `hello.example.eth` and a Label representing the `hello` in `hello.eth` that * have identical labelHash values). + * + * Note that in ENSv2, labelHashes (and tokenIds) have the lower 32 bits masked. */ - labelHash: t.hex().notNull(), + tokenId: t.bigint().notNull(), + /** * The human-readable representation of a given Label. In ENSv2, this `label` is always known, * but in ENSv1, labels may or may not be known, hence this field is optional. When rendering * and unknown label, an 'encoded' labelHash is used. */ label: t.text(), + /** * A Label stores a materialized `name`, representing its place in the label hierarchy. In the * event that a Label within the hierarchy is unknown, this name will contain encoded labelHash @@ -750,75 +806,63 @@ export const v2_label = onchainTable( name: t.text(), /** - * A Label has one `owner` address. - * - * TODO: This `owner` is _always_ set, using ZeroAddress where appropriate? or optional? depends on - * contract state/assurances + * A Label stores a materialized `node`, representing the result of `namehash()`. This is useful + * for relating to ResolverRecords */ - owner: t.hex().notNull(), + // node: t.hex(), + + /** + * A Label may have an URI. + */ + uri: t.text(), + + /** + * A Label has an `owner` address, potentially zeroAddress. + */ + owner: t.hex().notNull().default(zeroAddress), /** * A Label can be assigned a (sub)Registry with flags. */ subregistryId: t.text(), - subregistryFlags: t.bigint().notNull(), + subregistryFlags: t.bigint().notNull().default(0n), /** * A Label can be configured with a given Resolver with flags. */ resolverId: t.text(), - resolverFlags: t.bigint().notNull(), + resolverFlags: t.bigint().notNull().default(0n), }), (t) => ({ - // a Label is unique by (parentId, labelHash) - parentLabelHashIndex: uniqueIndex().on(t.parentId, t.labelHash), + // a Label is unique by (registryId, tokenId) + registryLabelHashIndex: uniqueIndex().on(t.registryId, t.tokenId), }), ); export const v2_labelRelations = relations(v2_label, ({ one, many }) => ({ - // label has one parent Label - parent: one(v2_label, { - fields: [v2_label.parentId], - references: [v2_label.id], + // label belongs to one (parent)registry + registry: one(v2_registry, { + fields: [v2_label.registryId], + references: [v2_registry.id], }), - // label has one registry - registry: one(v2_registry, { + // label references one (sub)registry + subregistry: one(v2_registry, { fields: [v2_label.subregistryId], references: [v2_registry.id], }), - // label has one resolver + // label references one resolver resolver: one(v2_resolver, { fields: [v2_label.resolverId], references: [v2_resolver.id], }), - // label has one records by pairwise composite key - records: one(v2_resolverRecords, { - fields: [v2_label.resolverId, v2_label.id], - references: [v2_resolverRecords.resolverId, v2_resolverRecords.labelId], - }), -})); - -export const v2_registry = onchainTable( - "v2_registries", - (t) => ({ - /** - * (sub)Registries are keyed by [CAIP-10](https://chainagnostic.org/CAIPs/caip-10) - * i.e. chainId:address - * - * TODO: introducing CAIP-10 for chain-scoping, may or may not be strictly necessary - * TODO: type as CAIP-10 - */ - id: t.text().primaryKey(), - }), - (t) => ({}), -); - -export const v2_registryRelations = relations(v2_registry, ({ one, many }) => ({ - // technically any number of labels can reference a given Registry - label: many(v2_label), + // label references one records by pairwise composite key + // records: one(v2_resolverRecords, { + // fields: [v2_label.resolverId, v2_label.node], + // references: [v2_resolverRecords.resolverId, v2_resolverRecords.node], + // }), })); /** @@ -864,13 +908,13 @@ export const v2_resolverRecords = onchainTable( * records for. */ resolverId: t.text().notNull(), - labelId: t.hex().notNull(), + node: t.hex().notNull(), // TODO: implement all record storage here }), (t) => ({ // uniquely index against the composite key - idxResolverNode: uniqueIndex().on(t.resolverId, t.labelId), + idxResolverNode: uniqueIndex().on(t.resolverId, t.node), }), ); @@ -881,9 +925,30 @@ export const v2_resolverRecordRelations = relations(v2_resolverRecords, ({ one, references: [v2_resolver.id], }), - // records references Label - label: one(v2_label, { - fields: [v2_resolverRecords.labelId], - references: [v2_label.id], - }), + // records has many addresses + addresses: many(v2_resolverRecordsAddress), })); + +export const v2_resolverRecordsAddress = onchainTable( + "v2_resolver_records_addresses", + (t) => ({ + id: t.text().primaryKey(), + resolverRecordsId: t.text().notNull(), + coinType: t.bigint().notNull(), + address: t.text().notNull(), + }), + (t) => ({ + byCoinType: index().on(t.id, t.coinType), + }), +); + +export const v2_resolverRecordsAddressRelations = relations( + v2_resolverRecordsAddress, + ({ one, many }) => ({ + // resolverrecordsaddress belongs to resolver records + records: one(v2_resolverRecords, { + fields: [v2_resolverRecordsAddress.resolverRecordsId], + references: [v2_resolverRecords.id], + }), + }), +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa812c2ca..43fccb907 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,6 +300,9 @@ importers: '@ensnode/utils': specifier: workspace:* version: link:../../packages/ensnode-utils + caip: + specifier: ^1.1.1 + version: 1.1.1 hono: specifier: 'catalog:' version: 4.6.17 @@ -2723,6 +2726,7 @@ packages: bun@1.2.2: resolution: {integrity: sha512-RUc8uVVTw8WoASUzXaEQJR1s7mnwoHm3P871qBUIqSaoOpuwcU+bSVX151/xoqDwnyv38SjOX7yQ3oO0IeT73g==} + cpu: [arm64, x64, aarch64] os: [darwin, linux, win32] hasBin: true @@ -2740,6 +2744,9 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + caip@1.1.1: + resolution: {integrity: sha512-a3v5lteUUOoyRI0U6qe5ayCCGkF2mCmJ5zQMDnOD2vRjgRg6sm9p8TsRC2h4D4beyqRN9RYniphAPnj/+jQC6g==} + call-bind-apply-helpers@1.0.1: resolution: {integrity: sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==} engines: {node: '>= 0.4'} @@ -8870,6 +8877,8 @@ snapshots: cac@6.7.14: {} + caip@1.1.1: {} + call-bind-apply-helpers@1.0.1: dependencies: es-errors: 1.3.0 @@ -9410,7 +9419,7 @@ snapshots: '@typescript-eslint/parser': 8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3) eslint: 9.20.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.20.1(jiti@2.4.2)) eslint-plugin-react: 7.37.4(eslint@9.20.1(jiti@2.4.2)) @@ -9430,7 +9439,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)): + eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 @@ -9445,14 +9454,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3) eslint: 9.20.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)) transitivePeerDependencies: - supports-color @@ -9467,7 +9476,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.20.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 From b02d5a3a1a43be1103f643099bbd0e1b270d73f2 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 11 Mar 2025 12:49:13 -0500 Subject: [PATCH 04/15] feat: improve types, fix cyclical bug --- .../ens-v2/handlers/RegistryDatastore.ts | 54 +++++++++--- .../ens-v2/handlers/shared/Registry.ts | 52 +++++++---- apps/ensindexer/src/plugins/ens-v2/v2-lib.ts | 42 ++++++++- .../ensnode/reference/ensnode-v2-notes.mdx | 4 + packages/ensnode-utils/src/types.ts | 7 ++ packages/ponder-schema/package.json | 1 + packages/ponder-schema/src/ponder.schema.ts | 87 +++++++++++-------- pnpm-lock.yaml | 14 +-- 8 files changed, 187 insertions(+), 74 deletions(-) diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts index d9c235568..ccea8cf8f 100644 --- a/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts +++ b/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts @@ -7,20 +7,46 @@ import { makeContractId, makeLabelId, maskTokenId } from "../v2-lib"; export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { // NOTE: can arrive in any order, must upsert all relevant entities ponder.on(namespace("RegistryDatastore:SubregistryUpdate"), async ({ context, event }) => { - console.table({ on: "RegistryDatastore:SubregistryUpdate", ...event.args }); - - const { registry, labelHash, subregistry, flags } = event.args; + const { + registry: registryAddress, + labelHash, + subregistry: subregistryAddress, + flags, + } = event.args; + const registryId = makeContractId(context.network.chainId, registryAddress); const tokenId = maskTokenId(labelHash); // NOTE: ensure tokenId is masked correctly - const registryId = makeContractId(context.network.chainId, registry); const labelId = makeLabelId(registryId, tokenId); - const subregistryId = makeContractId(context.network.chainId, subregistry); + const subregistryId = makeContractId(context.network.chainId, subregistryAddress); + + console.table({ + on: "RegistryDatastore:SubregistryUpdate", + registryId, + tokenId, + labelId, + subregistryId, + hash: event.transaction.hash, + }); // ensure registry entity await context.db.insert(schema.v2_registry).values({ id: registryId }).onConflictDoNothing(); + // NOTE(registry-label-uniq): if subregistry is already linked to a label, must ignore this update + // NOTE: implements first-write-wins for registry-label relations + const existingSubregistry = await context.db.find(schema.v2_registry, { id: subregistryId }); + if (existingSubregistry?.labelId) { + console.log( + `tx ${event.transaction.hash} wanted to set the subregistry for ${labelId} to ${subregistryId} but that registry is already linked to another label (${existingSubregistry.labelId}) — ignoring.`, + ); + return; + } + // ensure subregistry entity - await context.db.insert(schema.v2_registry).values({ id: subregistryId }).onConflictDoNothing(); + // TODO(registry-label-uniq): also update the reverse-mapping on the subregistry to point to this label + await context.db + .insert(schema.v2_registry) + .values({ id: subregistryId, labelId }) + .onConflictDoUpdate({ labelId }); await context.db .insert(schema.v2_label) @@ -32,14 +58,20 @@ export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { // NOTE: can arrive in any order, must upsert all relevant entities ponder.on(namespace("RegistryDatastore:ResolverUpdate"), async ({ context, event }) => { - console.table({ on: "RegistryDatastore:ResolverUpdate", ...event.args }); - - const { registry, labelHash, resolver, flags } = event.args; + const { registry: registryAddress, labelHash, resolver: resolverAddress, flags } = event.args; + const registryId = makeContractId(context.network.chainId, registryAddress); const tokenId = maskTokenId(labelHash); // NOTE: ensure tokenId is masked correctly - const registryId = makeContractId(context.network.chainId, registry); const labelId = makeLabelId(registryId, tokenId); - const resolverId = makeContractId(context.network.chainId, resolver); + const resolverId = makeContractId(context.network.chainId, resolverAddress); + + console.table({ + on: "RegistryDatastore:ResolverUpdate", + registryId, + tokenId, + labelId, + resolverId, + }); // ensure registry entity await context.db.insert(schema.v2_registry).values({ id: registryId }).onConflictDoNothing(); diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts index be48fa432..2f3bfa3d7 100644 --- a/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts +++ b/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts @@ -5,7 +5,7 @@ import { Context } from "ponder:registry"; import schema from "ponder:schema"; import { isLabelIndexable } from "@ensnode/utils/subname-helpers"; -import { Address, getAddress, labelhash } from "viem"; +import { Address, getAddress, labelhash, zeroAddress } from "viem"; import { EventWithArgs } from "../../../../lib/ponder-helpers"; import { labelHashToTokenId, @@ -23,13 +23,14 @@ export async function handleNewSubname({ context: Context; event: EventWithArgs<{ label: string }>; }) { - console.table({ on: "NewSubname", ...event.args }); const { label } = event.args; const registryId = makeContractId(context.network.chainId, event.log.address); const tokenId = labelHashToTokenId(labelhash(label)); const labelId = makeLabelId(registryId, tokenId); + console.table({ on: "NewSubname", registryId, tokenId, labelId }); + // ensure that this registry exists await context.db.insert(schema.v2_registry).values({ id: registryId }).onConflictDoNothing(); @@ -61,14 +62,14 @@ export async function handleURI({ value: string; }>; }) { - console.table({ on: "URI", ...event.args }); - const { id, value: uri } = event.args; const registryId = makeContractId(context.network.chainId, event.log.address); const tokenId = maskTokenId(id); // NOTE: ensure token id is masked const labelId = makeLabelId(registryId, tokenId); + console.table({ on: "URI", registryId, tokenId, labelId, uri }); + await context.db .insert(schema.v2_label) // insert new Label with uri @@ -83,13 +84,10 @@ export async function handleURI({ } // ERC1155 Transfer events may arrive in any order -// TODO: correctly burn tokens async function handleTransfer({ context, event, }: { context: Context; event: EventWithArgs<{ id: bigint; to: Address }> }) { - console.table({ on: "handleTransfer", ...event.args }); - const { id, to } = event.args; const registryId = makeContractId(context.network.chainId, event.log.address); @@ -97,17 +95,35 @@ async function handleTransfer({ const labelId = makeLabelId(registryId, tokenId); const owner = getAddress(to); // NOTE: ensures that owner is checksummed - await context.db - .insert(schema.v2_label) - // insert new Label with owner - .values({ - id: labelId, - registryId, - tokenId, - owner, - }) - // or update owner of existing Label - .onConflictDoUpdate({ owner }); + console.table({ on: "handleTransfer", registryId, tokenId, labelId, owner }); + + const isBurn = owner === zeroAddress; + if (isBurn) { + const label = await context.db.find(schema.v2_label, { id: labelId }); + + // NOTE(registry-label-uniq): we must also remove the reverse relationship on its subregistry + if (label?.subregistryId) { + await context.db + .update(schema.v2_registry, { id: label.subregistryId }) + .set({ labelId: null }); + } + + // to delete a token, we need only delete the label + await context.db.delete(schema.v2_label, { id: labelId }); + } else { + // mint or update + await context.db + .insert(schema.v2_label) + // insert new Label with owner + .values({ + id: labelId, + registryId, + tokenId, + owner, + }) + // or update owner of existing Label + .onConflictDoUpdate({ owner }); + } } export async function handleTransferSingle({ diff --git a/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts b/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts index bc92c2ba2..779b7529d 100644 --- a/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts +++ b/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts @@ -4,9 +4,10 @@ import { Context } from "ponder:registry"; import schema from "ponder:schema"; +import { encodeLabelhash } from "@ensdomains/ensjs/utils"; import { LabelHash, Node } from "@ensnode/utils/types"; import { AccountId } from "caip"; -import { Address, getAddress, hexToBigInt, namehash } from "viem"; +import { Address, getAddress, hexToBigInt, namehash, toHex } from "viem"; const LABEL_HASH_MASK = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000n; @@ -88,10 +89,47 @@ export const maskTokenId = (tokenId: bigint) => tokenId & LABEL_HASH_MASK; export const labelHashToTokenId = (labelHash: LabelHash) => maskTokenId(hexToBigInt(labelHash, { size: 32 })); +/** + * decodes a bigint tokenId into a hex labelHash + */ +export const tokenIdToLabelHash = (tokenId: bigint): LabelHash => toHex(tokenId, { size: 32 }); + /// /// Helpers /// +/** + * constructs a label's name by traversing the hierarchical namespace upwards. + * NOTE: likely more efficient than custom sql due to in-memory cache-ability + */ +async function constructLabelName(context: Context, labelId: string): Promise { + const label = await context.db.find(schema.v2_label, { id: labelId }); + if (!label) { + throw new Error(`constructLabelName expected labelId "${labelId}" to exist, it does not`); + } + + const parentRegistry = await context.db.find(schema.v2_registry, { id: label.registryId }); + if (!parentRegistry) { + throw new Error( + `constructLabelName expected registryId "${label.registryId}" to exist, it does not`, + ); + } + + // human-readable label or encoded labelHash + const segment = label.label ?? encodeLabelhash(tokenIdToLabelHash(label.tokenId)); + + console.log("constructLabelName", { segment, parentId: parentRegistry.labelId }); + + // this is the root Registry + if (!parentRegistry.labelId) return segment; + + // otherwise, recurse + return [segment, await constructLabelName(context, parentRegistry.labelId)].join("."); +} + export async function materializeLabelName(context: Context, labelId: string) { - // TODO: implement Label name materialization by traversing tree + const name = await constructLabelName(context, labelId); + const node = namehash(name); + + await context.db.update(schema.v2_label, { id: labelId }).set({ name, node }); } diff --git a/docs/ensnode.io/src/content/docs/ensnode/reference/ensnode-v2-notes.mdx b/docs/ensnode.io/src/content/docs/ensnode/reference/ensnode-v2-notes.mdx index 92b6aad09..b27d56bac 100644 --- a/docs/ensnode.io/src/content/docs/ensnode/reference/ensnode-v2-notes.mdx +++ b/docs/ensnode.io/src/content/docs/ensnode/reference/ensnode-v2-notes.mdx @@ -103,3 +103,7 @@ In theory (and likely in practice) any normalized name will never become invalid - removes centralized dependency on the CCIP Gateway - flaky test experience with .cb.id name gateway - also helps indexer discovery + +# Notes Scratchpad + +- multichain primary names diff --git a/packages/ensnode-utils/src/types.ts b/packages/ensnode-utils/src/types.ts index 4adf5218d..41c566d0a 100644 --- a/packages/ensnode-utils/src/types.ts +++ b/packages/ensnode-utils/src/types.ts @@ -19,3 +19,10 @@ export type Node = Hex; * @link https://docs.ens.domains/ensip/1#labelhash-algorithm */ export type LabelHash = Hex; + +/** + * Represents a CAIP-10 AccountId + * + * @link https://chainagnostic.org/CAIPs/caip-10 + */ +export type CAIP10AccountId = string; diff --git a/packages/ponder-schema/package.json b/packages/ponder-schema/package.json index 128c64804..07e45321e 100644 --- a/packages/ponder-schema/package.json +++ b/packages/ponder-schema/package.json @@ -35,6 +35,7 @@ "viem": "catalog:" }, "devDependencies": { + "@ensnode/utils": "workspace:", "@biomejs/biome": "catalog:", "@ensnode/shared-configs": "workspace:", "tsup": "catalog:", diff --git a/packages/ponder-schema/src/ponder.schema.ts b/packages/ponder-schema/src/ponder.schema.ts index 60a018791..30583a1b6 100644 --- a/packages/ponder-schema/src/ponder.schema.ts +++ b/packages/ponder-schema/src/ponder.schema.ts @@ -1,3 +1,4 @@ +import { CAIP10AccountId } from "@ensnode/utils/types"; import { index, onchainTable, relations, uniqueIndex } from "ponder"; import { type Address, zeroAddress } from "viem"; import { monkeypatchCollate } from "./collate"; @@ -686,52 +687,63 @@ export const versionChangedRelations = relations(versionChanged, ({ one }) => ({ * https://www.ensnode.io/ensnode/reference/ensnode-v2-notes/ * * The core design principle here is that a Registry references many Labels which each reference a - * (sub)Registry which references many Labels... etc. This correctly represents the nature of the + * (sub)Registry which references many Labels... etc. This accurately represents the nature of the * on-chain contracts and supports the dynamic re-arrangement of the hierarchical namespace as * proposed by ENSv2. * + * For example, when a subregistry is updated for a given Label, the tree now represent's that + * subregistry's Labels, without and bulk creation/deletion being necessary. + * * open questions: * - how do v1 subregistries other than .eth handle the migration? - * - what does v2.eth registry `reliquishing` accomplish, from an indexing perspective? + * - what does v2's .eth registry `reliquishing` accomplish, from an indexing perspective? * - a 'registry' is... any contract tht implements ERC1155 and NewSubname()? may mean that we need to * index every instance of those events... not very good at all... would be nice if a Registry contract * emitted an event like NewRegistry() in constructor that indicated whether it was an ENS registry. - * otherwise we'll need to track all of these. ponder doesn't handle dynamic address indexing for - * efficiency reasons. + * or if it were required to announce itself against some singleton address and we could use the + * factory pattern to index those addresses. otherwise we'll need to track every ERC1155 contract. + * (ponder doesn't handle dynamic address indexing for efficiency reasons) * - could multiple tokenIds in the ENSv2 system have the same subregistry address? seems yes because * datastore does not enforce uniqueness, but this results in a many-many mapping between labels - * and subregistries which seems very annoying to work with, in particular for traversals. the - * indexer could enforce uniqueness and if a name sets a registry address that's already assigned + * and subregistries which seems very annoying to work with, in particular for upward traversals. + * the indexer could enforce uniqueness and if a name sets a registry address that's already assigned * we could ignore that subtree? not ideal. since ENSv2 is on L2 we can likely include uniqueness * check without too much of a penalty? + * search TODO(registry-label-uniq): in codebase to see locations where this is noted * - event order guarantees would be really nice as part of the v2 spec * - i.e. registries must emit NewSubname before any ERC1155 events, must emit NewSubname before - * calling datastore, etc. + * calling datastore, etc. indexers love an event that's guaranteed to be first in order to setup entity * - should token ids within registry contracts not be masked? what happens if registry mints - * multiple tokens with conflicting tokenIds? + * multiple tokens with conflicting tokenIds? they'll have the same state in datastore but there will + * be multiple tokens in the 1155 contract with different owners, etc. how should the indexer represent this? * - we _need_ to be able to configure ponder's handling of null values in order to correctly index ENSv2 * because ENSv2 doesn't emit the namehash/labelhash, only human-readable args, which may contain null bytes * https://github.com/ponder-sh/ponder/issues/1456 * * todo in schema: * - events & event relations - * - CAIP typing, @ensnode/utils/types typing * - store node on label in order to set up resolver record references? * - createdAt and updatedAt values across the board * - could use composite schemas more frequently instead of concatenated ids */ +/** + * A Registry represents a Registry _contract_ on-chain, and is keyed by its chain-specific address. + */ export const v2_registry = onchainTable( "v2_registries", (t) => ({ /** * Registry is keyed by [CAIP-10](https://chainagnostic.org/CAIPs/caip-10) - * i.e. chainId:address - * - * TODO: introducing CAIP-10 for chain-scoping, may or may not be strictly necessary - * TODO: type as CAIP-10 */ - id: t.text().primaryKey(), + id: t.text().primaryKey().$type(), + + /** + * A Registry can be the subregistry of exactly one Label. + * NOTE: we duplicate this reference in order to make cachable traversals trivial. + * TODO(registry-label-uniq): see above + */ + labelId: t.text(), // TODO: reference registry-specific logic entities here (i.e. .eth registry expiries) }), @@ -742,15 +754,18 @@ export const v2_registryRelations = relations(v2_registry, ({ one, many }) => ({ /** * a registry has one label (i.e is that label's subregistry) * - * TODO: this invariant seems incorrect — see above open questions + * TODO(registry-label-uniq): see above */ label: one(v2_label, { - fields: [v2_registry.id], - references: [v2_label.subregistryId], + fields: [v2_registry.labelId], + references: [v2_label.id], + relationName: "isSubregistryOf", }), // a registry has many labels by label.registryId - labels: many(v2_label), + labels: many(v2_label, { + relationName: "managedLabels", + }), })); /** @@ -769,16 +784,18 @@ export const v2_label = onchainTable( id: t.text().primaryKey(), /** - * A Label belogs to a Registry. + * A Label belongs to a Registry. */ registryId: t.text().notNull(), /** * A Label entity represents a given labelHash value i.e. the result of `labelhash()`, encoded as a bigint 'tokenId'. * - * This is _not_ a UUID value, and collisions are expected (i.e. there will be a Label entity representing - * the `hello` in `hello.example.eth` and a Label representing the `hello` in `hello.eth` that - * have identical labelHash values). + * tokenId alone is _not_ a UUID value, and collisions are expected (i.e. there will be a Label + * entity representing the `hello` in `hello.example.eth` and a Label representing the `hello` + * in `hello.eth` that have identical tokenId values). + * + * Label entities are unique by (registryId, tokenId), enforced by ERC1155. * * Note that in ENSv2, labelHashes (and tokenIds) have the lower 32 bits masked. */ @@ -786,17 +803,16 @@ export const v2_label = onchainTable( /** * The human-readable representation of a given Label. In ENSv2, this `label` is always known, - * but in ENSv1, labels may or may not be known, hence this field is optional. When rendering - * and unknown label, an 'encoded' labelHash is used. + * but in ENSv1, labels may or may not be known, hence this field is optional. */ label: t.text(), /** * A Label stores a materialized `name`, representing its place in the label hierarchy. In the - * event that a Label within the hierarchy is unknown, this name will contain encoded labelHash + * event that a Label within the hierarchy is unknown, this name will contain 'encoded' labelHash * segments. * - * NOTE: in the future, name construction will be done dynamically instead of materialized at + * NOTE: in the future, name construction will be done a request-time instead of materialized at * index-time. * * ex. sub.example.eth @@ -809,7 +825,7 @@ export const v2_label = onchainTable( * A Label stores a materialized `node`, representing the result of `namehash()`. This is useful * for relating to ResolverRecords */ - // node: t.hex(), + node: t.hex(), /** * A Label may have an URI. @@ -823,6 +839,7 @@ export const v2_label = onchainTable( /** * A Label can be assigned a (sub)Registry with flags. + * NOTE: we duplicate this reference in order to make cachable traversals trivial. */ subregistryId: t.text(), subregistryFlags: t.bigint().notNull().default(0n), @@ -859,10 +876,10 @@ export const v2_labelRelations = relations(v2_label, ({ one, many }) => ({ }), // label references one records by pairwise composite key - // records: one(v2_resolverRecords, { - // fields: [v2_label.resolverId, v2_label.node], - // references: [v2_resolverRecords.resolverId, v2_resolverRecords.node], - // }), + records: one(v2_resolverRecords, { + fields: [v2_label.resolverId, v2_label.node], + references: [v2_resolverRecords.resolverId, v2_resolverRecords.node], + }), })); /** @@ -872,13 +889,9 @@ export const v2_resolver = onchainTable( "v2_resolvers", (t) => ({ /** - * Resolver are keyed by [CAIP-10](https://chainagnostic.org/CAIPs/caip-10) - * i.e. chainId:address - * - * TODO: introducing CAIP-10 for chain-scoping, may or may not be strictly necessary - * TODO: type as CAIP-10 + * Resolver are keyed by [CAIP-10](https://chainagnostic.org/CAIPs/caip-10). */ - id: t.text().primaryKey(), + id: t.text().primaryKey().$type(), }), (t) => ({}), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43fccb907..5d88da094 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -555,6 +555,9 @@ importers: '@ensnode/shared-configs': specifier: 'workspace:' version: link:../shared-configs + '@ensnode/utils': + specifier: 'workspace:' + version: link:../ensnode-utils tsup: specifier: 'catalog:' version: 8.3.6(jiti@2.4.2)(postcss@8.5.1)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0) @@ -2726,7 +2729,6 @@ packages: bun@1.2.2: resolution: {integrity: sha512-RUc8uVVTw8WoASUzXaEQJR1s7mnwoHm3P871qBUIqSaoOpuwcU+bSVX151/xoqDwnyv38SjOX7yQ3oO0IeT73g==} - cpu: [arm64, x64, aarch64] os: [darwin, linux, win32] hasBin: true @@ -9419,7 +9421,7 @@ snapshots: '@typescript-eslint/parser': 8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3) eslint: 9.20.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.20.1(jiti@2.4.2)) eslint-plugin-react: 7.37.4(eslint@9.20.1(jiti@2.4.2)) @@ -9439,7 +9441,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)): + eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 @@ -9454,14 +9456,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3) eslint: 9.20.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) transitivePeerDependencies: - supports-color @@ -9476,7 +9478,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.20.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 From c1dba60167357612f742f88e388d26f7e587bd90 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 11 Mar 2025 14:21:47 -0500 Subject: [PATCH 05/15] feat: confirm records by node relationship works --- .../src/plugins/ens-v2/handlers/Resolver.ts | 2 +- packages/ponder-schema/src/ponder.schema.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/Resolver.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/Resolver.ts index a28253d4e..d207de31e 100644 --- a/apps/ensindexer/src/plugins/ens-v2/handlers/Resolver.ts +++ b/apps/ensindexer/src/plugins/ens-v2/handlers/Resolver.ts @@ -63,7 +63,7 @@ export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { args: { node: event.args.node, newAddress: event.args.a, - coinType: 60n, // TODO: make sure this is correct ETH cointype + coinType: 60n, // TODO: move to utils/constants }, }, }); diff --git a/packages/ponder-schema/src/ponder.schema.ts b/packages/ponder-schema/src/ponder.schema.ts index 30583a1b6..221419973 100644 --- a/packages/ponder-schema/src/ponder.schema.ts +++ b/packages/ponder-schema/src/ponder.schema.ts @@ -710,7 +710,7 @@ export const versionChangedRelations = relations(versionChanged, ({ one }) => ({ * we could ignore that subtree? not ideal. since ENSv2 is on L2 we can likely include uniqueness * check without too much of a penalty? * search TODO(registry-label-uniq): in codebase to see locations where this is noted - * - event order guarantees would be really nice as part of the v2 spec + * - event order guarantees would be really nice as part of the v2 spec, but technically not needed * - i.e. registries must emit NewSubname before any ERC1155 events, must emit NewSubname before * calling datastore, etc. indexers love an event that's guaranteed to be first in order to setup entity * - should token ids within registry contracts not be masked? what happens if registry mints @@ -772,6 +772,7 @@ export const v2_registryRelations = relations(v2_registry, ({ one, many }) => ({ * A Label entity represents a label in the hierarchical namespace. * * TODO: pehaps should rename to `Name`/`Domain` or `Token`? + * TODO: perhaps key by node instead of (registryId, tokenId)? * * In ENSv2 this maps 1:1 with a Registry contract's tokenId */ @@ -822,11 +823,14 @@ export const v2_label = onchainTable( name: t.text(), /** - * A Label stores a materialized `node`, representing the result of `namehash()`. This is useful - * for relating to ResolverRecords + * A Label stores a materialized `node`, the result of `namehash(name)`, helpful for + * - referencing labels by `node` + * - referencing this label's resolver records, if any */ node: t.hex(), + /** */ + /** * A Label may have an URI. */ @@ -853,6 +857,8 @@ export const v2_label = onchainTable( (t) => ({ // a Label is unique by (registryId, tokenId) registryLabelHashIndex: uniqueIndex().on(t.registryId, t.tokenId), + // a Label is unique by node + idxNode: uniqueIndex().on(t.node), }), ); @@ -875,7 +881,7 @@ export const v2_labelRelations = relations(v2_label, ({ one, many }) => ({ references: [v2_resolver.id], }), - // label references one records by pairwise composite key + // label references one records by materialized id records: one(v2_resolverRecords, { fields: [v2_label.resolverId, v2_label.node], references: [v2_resolverRecords.resolverId, v2_resolverRecords.node], From d48b9f8533d61a2ec829704a1ce59ae02a7d3887 Mon Sep 17 00:00:00 2001 From: shrugs Date: Tue, 11 Mar 2025 15:06:47 -0500 Subject: [PATCH 06/15] feat: demonstrate ensnode-api service with custom endpoints/sql --- apps/ensnode-api/.gitignore | 2 + apps/ensnode-api/LICENSE | 21 +++++ apps/ensnode-api/README.md | 13 +++ apps/ensnode-api/package.json | 21 +++++ apps/ensnode-api/src/index.ts | 21 +++++ apps/ensnode-api/src/lib/db.ts | 20 +++++ apps/ensnode-api/src/v1.ts | 25 ++++++ apps/ensnode-api/tsconfig.json | 10 +++ packages/ens-deployments/package.json | 2 +- pnpm-lock.yaml | 122 +++++++++++++++++++------- pnpm-workspace.yaml | 2 +- 11 files changed, 223 insertions(+), 36 deletions(-) create mode 100644 apps/ensnode-api/.gitignore create mode 100644 apps/ensnode-api/LICENSE create mode 100644 apps/ensnode-api/README.md create mode 100644 apps/ensnode-api/package.json create mode 100644 apps/ensnode-api/src/index.ts create mode 100644 apps/ensnode-api/src/lib/db.ts create mode 100644 apps/ensnode-api/src/v1.ts create mode 100644 apps/ensnode-api/tsconfig.json diff --git a/apps/ensnode-api/.gitignore b/apps/ensnode-api/.gitignore new file mode 100644 index 000000000..506e4c37e --- /dev/null +++ b/apps/ensnode-api/.gitignore @@ -0,0 +1,2 @@ +# deps +node_modules/ diff --git a/apps/ensnode-api/LICENSE b/apps/ensnode-api/LICENSE new file mode 100644 index 000000000..24d66814d --- /dev/null +++ b/apps/ensnode-api/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 NameHash + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/ensnode-api/README.md b/apps/ensnode-api/README.md new file mode 100644 index 000000000..ebb5b228f --- /dev/null +++ b/apps/ensnode-api/README.md @@ -0,0 +1,13 @@ +# ENSNode API + +API Server for ENSNode + +## Documentation + +For detailed documentation and guides, see the [ENSNode Documentation](https://ensnode.io/ensnode). + +## License + +Licensed under the MIT License, Copyright © 2025-present [NameHash Labs](https://namehashlabs.org). + +See [LICENSE](./LICENSE) for more information. diff --git a/apps/ensnode-api/package.json b/apps/ensnode-api/package.json new file mode 100644 index 000000000..d8b2bcf29 --- /dev/null +++ b/apps/ensnode-api/package.json @@ -0,0 +1,21 @@ +{ + "name": "@ensnode/api", + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "dev": "bun run --hot src/index.ts" + }, + "dependencies": { + "@ensnode/ponder-schema": "workspace:*", + "@ensnode/utils": "workspace:*", + "@ponder/client": "catalog:", + "@ponder/utils": "catalog:", + "drizzle-orm": "catalog:", + "hono": "catalog:", + "viem": "catalog:" + }, + "devDependencies": { + "@ensnode/shared-configs": "workspace:*", + "@types/bun": "latest" + } +} diff --git a/apps/ensnode-api/src/index.ts b/apps/ensnode-api/src/index.ts new file mode 100644 index 000000000..1672e36d6 --- /dev/null +++ b/apps/ensnode-api/src/index.ts @@ -0,0 +1,21 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { proxy } from "hono/proxy"; + +import v1 from "./v1"; + +const app = new Hono(); + +// use cors +app.use(cors({ origin: "*" })); + +// TODO: ENSNode-api should be the exclusive api entrypoint for ENSNode +// https://hono.dev/examples/proxy +// - proxy /ponder, /subgraph, /sql/* endpoints to ensindexer + +app.route("/api/v1", v1); + +export default { + port: 3289, + fetch: app.fetch, +}; diff --git a/apps/ensnode-api/src/lib/db.ts b/apps/ensnode-api/src/lib/db.ts new file mode 100644 index 000000000..81511e66a --- /dev/null +++ b/apps/ensnode-api/src/lib/db.ts @@ -0,0 +1,20 @@ +import * as _schema from "@ensnode/ponder-schema"; +import { Table, is } from "drizzle-orm"; +// import { setDatabaseSchema } from "@ponder/client"; +import { drizzle } from "drizzle-orm/node-postgres"; + +const setDatabaseSchema = ( + schema: T, + schemaName: string, +): T => { + for (const table of Object.values(schema)) { + if (is(table, Table)) { + // Use type assertion to fix the TypeScript error + (table as any)[Symbol.for("drizzle:Schema")] = schemaName; + } + } + return schema; +}; + +export const schema = setDatabaseSchema(_schema, Bun.env.DATABASE_SCHEMA || 'public'); +export const db = drizzle(Bun.env.DATABASE_URL, { schema, casing: "snake_case" }); diff --git a/apps/ensnode-api/src/v1.ts b/apps/ensnode-api/src/v1.ts new file mode 100644 index 000000000..6b6edd103 --- /dev/null +++ b/apps/ensnode-api/src/v1.ts @@ -0,0 +1,25 @@ +import { Node } from "@ensnode/utils/types"; +import { eq } from "@ponder/client"; +import { replaceBigInts } from "@ponder/utils"; +import { Hono } from "hono"; + +import { db, schema } from "./lib/db.js"; + +const app = new Hono(); + +app.get("/node/:node", async (c) => { + const node = c.req.param("node") as Node | undefined; + if (!node) throw new Error("param expected"); // TODO: correct error handling + + const labels = await db + .select() + .from(schema.v2_label) + .where(eq(schema.v2_label.node, node)) + .limit(1); + + if (labels.length === 0) return c.notFound(); + + return c.json(replaceBigInts(labels[0], (v) => String(v))); +}); + +export default app; diff --git a/apps/ensnode-api/tsconfig.json b/apps/ensnode-api/tsconfig.json new file mode 100644 index 000000000..21fd3b621 --- /dev/null +++ b/apps/ensnode-api/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@ensnode/shared-configs/tsconfig.ponder.json", + "include": ["./**/*.ts"], + "exclude": ["node_modules"], + "compilerOptions": { + "strict": true, + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx" + } +} diff --git a/packages/ens-deployments/package.json b/packages/ens-deployments/package.json index 54c660717..027bc03a5 100644 --- a/packages/ens-deployments/package.json +++ b/packages/ens-deployments/package.json @@ -39,6 +39,6 @@ "viem": "catalog:" }, "dependencies": { - "@ponder/utils": "^0.2.3" + "@ponder/utils": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d88da094..5e9a48baf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ catalogs: specifier: ^0.39.3 version: 0.39.3 hono: - specifier: ^4.6.14 - version: 4.6.17 + specifier: ^4.7.4 + version: 4.7.4 ponder: specifier: ^0.9.27 version: 0.9.27 @@ -199,10 +199,10 @@ importers: version: 0.11.1(@types/node@22.13.5)(graphql-ws@5.16.2(graphql@16.10.0))(graphql@16.10.0) '@ponder/client': specifier: 'catalog:' - version: 0.9.27(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(kysely@0.26.3)(pg@8.11.3)(typescript@5.7.3) + version: 0.9.27(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3)(typescript@5.7.3) '@ponder/react': specifier: 'catalog:' - version: 0.9.27(@ponder/client@0.9.27(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(kysely@0.26.3)(pg@8.11.3)(typescript@5.7.3))(@tanstack/react-query@5.66.9(react@18.3.1))(react@18.3.1)(typescript@5.7.3) + version: 0.9.27(@ponder/client@0.9.27(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3)(typescript@5.7.3))(@tanstack/react-query@5.66.9(react@18.3.1))(react@18.3.1)(typescript@5.7.3) '@ponder/utils': specifier: 'catalog:' version: 0.2.3(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2)) @@ -305,10 +305,10 @@ importers: version: 1.1.1 hono: specifier: 'catalog:' - version: 4.6.17 + version: 4.7.4 ponder: specifier: 'catalog:' - version: 0.9.27(@opentelemetry/api@1.7.0)(@types/node@20.17.14)(hono@4.6.17)(lightningcss@1.29.1)(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2))(zod@3.24.2) + version: 0.9.27(@opentelemetry/api@1.7.0)(@types/node@20.17.14)(bun-types@1.2.5)(hono@4.7.4)(lightningcss@1.29.1)(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2))(zod@3.24.2) ts-deepmerge: specifier: ^7.0.2 version: 7.0.2 @@ -332,6 +332,37 @@ importers: specifier: 'catalog:' version: 3.0.5(@types/debug@4.1.12)(@types/node@20.17.14)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2)(yaml@2.7.0) + apps/ensnode-api: + dependencies: + '@ensnode/ponder-schema': + specifier: workspace:* + version: link:../../packages/ponder-schema + '@ensnode/utils': + specifier: workspace:* + version: link:../../packages/ensnode-utils + '@ponder/client': + specifier: 'catalog:' + version: 0.9.27(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3)(typescript@5.7.3) + '@ponder/utils': + specifier: 'catalog:' + version: 0.2.3(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2)) + drizzle-orm: + specifier: 'catalog:' + version: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3) + hono: + specifier: 'catalog:' + version: 4.7.4 + viem: + specifier: 'catalog:' + version: 2.22.13(typescript@5.7.3)(zod@3.24.2) + devDependencies: + '@ensnode/shared-configs': + specifier: workspace:* + version: link:../../packages/shared-configs + '@types/bun': + specifier: latest + version: 1.2.5 + apps/ensrainbow: dependencies: '@ensnode/ensrainbow-sdk': @@ -342,7 +373,7 @@ importers: version: link:../../packages/ensnode-utils '@hono/node-server': specifier: ^1.4.1 - version: 1.13.3(hono@4.6.17) + version: 1.13.3(hono@4.7.4) bun: specifier: ^1.2.2 version: 1.2.2 @@ -351,7 +382,7 @@ importers: version: 1.4.1 hono: specifier: 'catalog:' - version: 4.6.17 + version: 4.7.4 pino: specifier: ^8.19.0 version: 8.21.0 @@ -432,7 +463,7 @@ importers: packages/ens-deployments: dependencies: '@ponder/utils': - specifier: ^0.2.3 + specifier: 'catalog:' version: 0.2.3(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2)) devDependencies: '@biomejs/biome': @@ -443,7 +474,7 @@ importers: version: link:../shared-configs ponder: specifier: 'catalog:' - version: 0.9.27(@opentelemetry/api@1.7.0)(@types/node@22.13.5)(hono@4.6.17)(lightningcss@1.29.1)(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2))(zod@3.24.2) + version: 0.9.27(@opentelemetry/api@1.7.0)(@types/node@22.13.5)(bun-types@1.2.5)(hono@4.7.4)(lightningcss@1.29.1)(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2))(zod@3.24.2) tsup: specifier: 'catalog:' version: 8.3.6(jiti@2.4.2)(postcss@8.5.1)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0) @@ -507,7 +538,7 @@ importers: dependencies: drizzle-orm: specifier: 'catalog:' - version: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(kysely@0.26.3)(pg@8.11.3) + version: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3) parse-prometheus-text-format: specifier: ^1.1.1 version: 1.1.1 @@ -526,10 +557,10 @@ importers: version: 20.17.14 hono: specifier: 'catalog:' - version: 4.6.17 + version: 4.7.4 ponder: specifier: 'catalog:' - version: 0.9.27(@opentelemetry/api@1.7.0)(@types/node@20.17.14)(hono@4.6.17)(lightningcss@1.29.1)(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2))(zod@3.24.2) + version: 0.9.27(@opentelemetry/api@1.7.0)(@types/node@20.17.14)(bun-types@1.2.5)(hono@4.7.4)(lightningcss@1.29.1)(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2))(zod@3.24.2) tsup: specifier: 'catalog:' version: 8.3.6(jiti@2.4.2)(postcss@8.5.1)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0) @@ -544,7 +575,7 @@ importers: dependencies: ponder: specifier: 'catalog:' - version: 0.9.27(@opentelemetry/api@1.7.0)(@types/node@22.13.5)(hono@4.6.17)(lightningcss@1.29.1)(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2))(zod@3.24.2) + version: 0.9.27(@opentelemetry/api@1.7.0)(@types/node@22.13.5)(bun-types@1.2.5)(hono@4.7.4)(lightningcss@1.29.1)(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2))(zod@3.24.2) viem: specifier: 'catalog:' version: 2.22.13(typescript@5.7.3)(zod@3.24.2) @@ -581,7 +612,7 @@ importers: version: 2.2.3 drizzle-orm: specifier: 'catalog:' - version: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(kysely@0.26.3)(pg@8.11.3) + version: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3) graphql: specifier: ^16.10.0 version: 16.10.0 @@ -603,7 +634,7 @@ importers: version: 20.17.14 hono: specifier: 'catalog:' - version: 4.6.17 + version: 4.7.4 tsup: specifier: 'catalog:' version: 8.3.6(jiti@2.4.2)(postcss@8.5.1)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0) @@ -2256,6 +2287,9 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/bun@1.2.5': + resolution: {integrity: sha512-w2OZTzrZTVtbnJew1pdFmgV99H0/L+Pvw+z1P67HaR18MHOzYnTYOi6qzErhK8HyT+DB782ADVPPE92Xu2/Opg==} + '@types/codemirror@0.0.90': resolution: {integrity: sha512-8Z9+tSg27NPRGubbUPUCrt5DDG/OWzLph5BvcDykwR5D7RyZh5mhHG0uS1ePKV1YFCA+/cwc4Ey2AJAEFfV3IA==} @@ -2348,6 +2382,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.5.14': + resolution: {integrity: sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -2727,6 +2764,9 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bun-types@1.2.5: + resolution: {integrity: sha512-3oO6LVGGRRKI4kHINx5PIdIgnLRb7l/SprhzqXapmoYkFl5m4j6EvALvbDVuuBFaamB46Ap6HCUxIXNLCGy+tg==} + bun@1.2.2: resolution: {integrity: sha512-RUc8uVVTw8WoASUzXaEQJR1s7mnwoHm3P871qBUIqSaoOpuwcU+bSVX151/xoqDwnyv38SjOX7yQ3oO0IeT73g==} os: [darwin, linux, win32] @@ -3867,8 +3907,8 @@ packages: hey-listen@1.0.8: resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} - hono@4.6.17: - resolution: {integrity: sha512-Kbh4M0so2RzLiIg6iP33DoTU68TdvP2O/kb1Hhhdwa37fazuf402ig8ZRfjkz2dqXwiWl2dAgh0f++TuKAdOtQ==} + hono@4.7.4: + resolution: {integrity: sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -7258,9 +7298,9 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@hono/node-server@1.13.3(hono@4.6.17)': + '@hono/node-server@1.13.3(hono@4.7.4)': dependencies: - hono: 4.6.17 + hono: 4.7.4 '@humanfs/core@0.19.1': {} @@ -7582,9 +7622,9 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@ponder/client@0.9.27(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(kysely@0.26.3)(pg@8.11.3)(typescript@5.7.3)': + '@ponder/client@0.9.27(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3)(typescript@5.7.3)': dependencies: - drizzle-orm: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(kysely@0.26.3)(pg@8.11.3) + drizzle-orm: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3) eventsource: 3.0.5 superjson: 2.2.2 optionalDependencies: @@ -7618,9 +7658,9 @@ snapshots: - sql.js - sqlite3 - '@ponder/react@0.9.27(@ponder/client@0.9.27(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(kysely@0.26.3)(pg@8.11.3)(typescript@5.7.3))(@tanstack/react-query@5.66.9(react@18.3.1))(react@18.3.1)(typescript@5.7.3)': + '@ponder/react@0.9.27(@ponder/client@0.9.27(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3)(typescript@5.7.3))(@tanstack/react-query@5.66.9(react@18.3.1))(react@18.3.1)(typescript@5.7.3)': dependencies: - '@ponder/client': 0.9.27(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(kysely@0.26.3)(pg@8.11.3)(typescript@5.7.3) + '@ponder/client': 0.9.27(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3)(typescript@5.7.3) '@tanstack/react-query': 5.66.9(react@18.3.1) react: 18.3.1 superjson: 2.2.2 @@ -8210,6 +8250,10 @@ snapshots: dependencies: '@babel/types': 7.26.9 + '@types/bun@1.2.5': + dependencies: + bun-types: 1.2.5 + '@types/codemirror@0.0.90': dependencies: '@types/tern': 0.23.9 @@ -8307,6 +8351,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.5.14': + dependencies: + '@types/node': 22.13.5 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -8854,6 +8902,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bun-types@1.2.5: + dependencies: + '@types/node': 22.13.5 + '@types/ws': 8.5.14 + bun@1.2.2: optionalDependencies: '@oven/bun-darwin-aarch64': 1.2.2 @@ -9214,10 +9267,11 @@ snapshots: dotenv@8.6.0: {} - drizzle-orm@0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(kysely@0.26.3)(pg@8.11.3): + drizzle-orm@0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3): optionalDependencies: '@electric-sql/pglite': 0.2.13 '@opentelemetry/api': 1.7.0 + bun-types: 1.2.5 kysely: 0.26.3 pg: 8.11.3 @@ -10223,7 +10277,7 @@ snapshots: hey-listen@1.0.8: {} - hono@4.6.17: {} + hono@4.7.4: {} html-escaper@2.0.2: {} @@ -11576,7 +11630,7 @@ snapshots: dependencies: find-up: 4.1.0 - ponder@0.9.27(@opentelemetry/api@1.7.0)(@types/node@20.17.14)(hono@4.6.17)(lightningcss@1.29.1)(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2))(zod@3.24.2): + ponder@0.9.27(@opentelemetry/api@1.7.0)(@types/node@20.17.14)(bun-types@1.2.5)(hono@4.7.4)(lightningcss@1.29.1)(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2))(zod@3.24.2): dependencies: '@babel/code-frame': 7.26.2 '@commander-js/extra-typings': 12.1.0(commander@12.1.0) @@ -11584,7 +11638,7 @@ snapshots: '@escape.tech/graphql-armor-max-aliases': 2.6.0 '@escape.tech/graphql-armor-max-depth': 2.4.0 '@escape.tech/graphql-armor-max-tokens': 2.5.0 - '@hono/node-server': 1.13.3(hono@4.6.17) + '@hono/node-server': 1.13.3(hono@4.7.4) '@ponder/utils': 0.2.3(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2)) abitype: 0.10.3(typescript@5.7.3)(zod@3.24.2) ansi-escapes: 7.0.0 @@ -11593,11 +11647,11 @@ snapshots: dataloader: 2.2.3 detect-package-manager: 3.0.2 dotenv: 16.4.7 - drizzle-orm: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(kysely@0.26.3)(pg@8.11.3) + drizzle-orm: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3) glob: 10.4.5 graphql: 16.10.0 graphql-yoga: 5.10.10(graphql@16.10.0) - hono: 4.6.17 + hono: 4.7.4 http-terminator: 3.2.0 kysely: 0.26.3 pg: 8.11.3 @@ -11654,7 +11708,7 @@ snapshots: - terser - zod - ponder@0.9.27(@opentelemetry/api@1.7.0)(@types/node@22.13.5)(hono@4.6.17)(lightningcss@1.29.1)(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2))(zod@3.24.2): + ponder@0.9.27(@opentelemetry/api@1.7.0)(@types/node@22.13.5)(bun-types@1.2.5)(hono@4.7.4)(lightningcss@1.29.1)(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2))(zod@3.24.2): dependencies: '@babel/code-frame': 7.26.2 '@commander-js/extra-typings': 12.1.0(commander@12.1.0) @@ -11662,7 +11716,7 @@ snapshots: '@escape.tech/graphql-armor-max-aliases': 2.6.0 '@escape.tech/graphql-armor-max-depth': 2.4.0 '@escape.tech/graphql-armor-max-tokens': 2.5.0 - '@hono/node-server': 1.13.3(hono@4.6.17) + '@hono/node-server': 1.13.3(hono@4.7.4) '@ponder/utils': 0.2.3(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2)) abitype: 0.10.3(typescript@5.7.3)(zod@3.24.2) ansi-escapes: 7.0.0 @@ -11671,11 +11725,11 @@ snapshots: dataloader: 2.2.3 detect-package-manager: 3.0.2 dotenv: 16.4.7 - drizzle-orm: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(kysely@0.26.3)(pg@8.11.3) + drizzle-orm: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3) glob: 10.4.5 graphql: 16.10.0 graphql-yoga: 5.10.10(graphql@16.10.0) - hono: 4.6.17 + hono: 4.7.4 http-terminator: 3.2.0 kysely: 0.26.3 pg: 8.11.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9cf91135b..9527ed2ae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,7 +11,7 @@ catalog: "@types/node": ^20.10.0 "@vitest/coverage-v8": ^3.0.5 drizzle-orm: ^0.39.3 - hono: ^4.6.14 + hono: ^4.7.4 typescript: ^5.7.3 viem: ^2.22.13 vitest: ^3.0.5 From d4fe25d1a5d62140288b97f2945847c5bf894e6e Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 12 Mar 2025 12:31:18 -0500 Subject: [PATCH 07/15] docs: subgraph-compat spec --- .../content/docs/ensnode/reference/subgraph-compatibility.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ensnode.io/src/content/docs/ensnode/reference/subgraph-compatibility.mdx b/docs/ensnode.io/src/content/docs/ensnode/reference/subgraph-compatibility.mdx index 104e31e51..c56b8f6af 100644 --- a/docs/ensnode.io/src/content/docs/ensnode/reference/subgraph-compatibility.mdx +++ b/docs/ensnode.io/src/content/docs/ensnode/reference/subgraph-compatibility.mdx @@ -56,6 +56,7 @@ If you'd like to contribute to these features, please [open a Pull Request on Gi The following features of the subgraph graphql api are explicitly unsupported and are not planned. - [1-level-nested Entity `_orderBy` param](https://thegraph.com/docs/en/subgraphs/querying/graphql-api/#example-for-nested-entity-sorting) +- nested Entity filter params (i.e. `entity(where: { children_: { ... } }) { ... }`) (thegraph docs not available) - [time travel queries](https://thegraph.com/docs/en/subgraphs/querying/graphql-api/#time-travel-queries) - [_change_block filtering](https://thegraph.com/docs/en/subgraphs/querying/graphql-api/#example-for-block-filtering) From 01c3a0e953e19320c10b48fe52799d7d992030a7 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 12 Mar 2025 14:19:56 -0500 Subject: [PATCH 08/15] feat: rename Label to Domain --- .../ens-v2/handlers/RegistryDatastore.ts | 51 ++++---- .../ens-v2/handlers/shared/Registry.ts | 61 ++++----- apps/ensindexer/src/plugins/ens-v2/v2-lib.ts | 34 ++--- apps/ensnode-api/src/v1.ts | 4 +- packages/ponder-schema/src/ponder.schema.ts | 119 +++++++++--------- 5 files changed, 138 insertions(+), 131 deletions(-) diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts index ccea8cf8f..60cc4a4ee 100644 --- a/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts +++ b/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts @@ -2,28 +2,27 @@ import { ponder } from "ponder:registry"; import schema from "ponder:schema"; import { PonderENSPluginHandlerArgs } from "../../../lib/plugin-helpers"; -import { makeContractId, makeLabelId, maskTokenId } from "../v2-lib"; +import { makeContractId, makeDomainId } from "../v2-lib"; export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { // NOTE: can arrive in any order, must upsert all relevant entities ponder.on(namespace("RegistryDatastore:SubregistryUpdate"), async ({ context, event }) => { const { registry: registryAddress, - labelHash, + labelHash: tokenId, // NOTE: this variable is called labelHash but is actually masked tokenId subregistry: subregistryAddress, flags, } = event.args; const registryId = makeContractId(context.network.chainId, registryAddress); - const tokenId = maskTokenId(labelHash); // NOTE: ensure tokenId is masked correctly - const labelId = makeLabelId(registryId, tokenId); + const domainId = makeDomainId(registryId, tokenId); const subregistryId = makeContractId(context.network.chainId, subregistryAddress); console.table({ on: "RegistryDatastore:SubregistryUpdate", registryId, tokenId, - labelId, + domainId, subregistryId, hash: event.transaction.hash, }); @@ -31,45 +30,49 @@ export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { // ensure registry entity await context.db.insert(schema.v2_registry).values({ id: registryId }).onConflictDoNothing(); - // NOTE(registry-label-uniq): if subregistry is already linked to a label, must ignore this update - // NOTE: implements first-write-wins for registry-label relations + // NOTE(registry-domain-uniq): if subregistry is already linked to a domain, must ignore this update + // NOTE: implements first-write-wins for registry-domain relations const existingSubregistry = await context.db.find(schema.v2_registry, { id: subregistryId }); - if (existingSubregistry?.labelId) { + if (existingSubregistry?.domainId) { console.log( - `tx ${event.transaction.hash} wanted to set the subregistry for ${labelId} to ${subregistryId} but that registry is already linked to another label (${existingSubregistry.labelId}) — ignoring.`, + `tx ${event.transaction.hash} wanted to set the subregistry for ${domainId} to ${subregistryId} but that registry is already linked to another domain (${existingSubregistry.domainId}) — ignoring.`, ); return; } // ensure subregistry entity - // TODO(registry-label-uniq): also update the reverse-mapping on the subregistry to point to this label + // TODO(registry-domain-uniq): also update the reverse-mapping on the subregistry to point to this domain await context.db .insert(schema.v2_registry) - .values({ id: subregistryId, labelId }) - .onConflictDoUpdate({ labelId }); + .values({ id: subregistryId, domainId }) + .onConflictDoUpdate({ domainId }); await context.db - .insert(schema.v2_label) - // insert label with subregistry info - .values({ id: labelId, registryId, tokenId, subregistryId, subregistryFlags: flags }) - // or update existing label with subregistry info + .insert(schema.v2_domain) + // insert domain with subregistry info + .values({ id: domainId, registryId, tokenId, subregistryId, subregistryFlags: flags }) + // or update existing domain with subregistry info .onConflictDoUpdate({ subregistryId, subregistryFlags: flags }); }); // NOTE: can arrive in any order, must upsert all relevant entities ponder.on(namespace("RegistryDatastore:ResolverUpdate"), async ({ context, event }) => { - const { registry: registryAddress, labelHash, resolver: resolverAddress, flags } = event.args; + const { + registry: registryAddress, + labelHash: tokenId, // NOTE: this variable is called labelHash but is actually masked tokenId + resolver: resolverAddress, + flags, + } = event.args; const registryId = makeContractId(context.network.chainId, registryAddress); - const tokenId = maskTokenId(labelHash); // NOTE: ensure tokenId is masked correctly - const labelId = makeLabelId(registryId, tokenId); + const domainId = makeDomainId(registryId, tokenId); const resolverId = makeContractId(context.network.chainId, resolverAddress); console.table({ on: "RegistryDatastore:ResolverUpdate", registryId, tokenId, - labelId, + domainId, resolverId, }); @@ -80,10 +83,10 @@ export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { await context.db.insert(schema.v2_resolver).values({ id: resolverId }).onConflictDoNothing(); await context.db - .insert(schema.v2_label) - // insert label with resolver info - .values({ id: labelId, registryId, tokenId, resolverId, resolverFlags: flags }) - // or update existing label with resolver info + .insert(schema.v2_domain) + // insert domain with resolver info + .values({ id: domainId, registryId, tokenId, resolverId, resolverFlags: flags }) + // or update existing domain with resolver info .onConflictDoUpdate({ resolverId, resolverFlags: flags }); }); } diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts index 2f3bfa3d7..01b762042 100644 --- a/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts +++ b/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts @@ -10,9 +10,9 @@ import { EventWithArgs } from "../../../../lib/ponder-helpers"; import { labelHashToTokenId, makeContractId, - makeLabelId, + makeDomainId, maskTokenId, - materializeLabelName, + materializeDomainName, } from "../../v2-lib"; // NewSubname and DataStore events may arrive in any order @@ -27,9 +27,9 @@ export async function handleNewSubname({ const registryId = makeContractId(context.network.chainId, event.log.address); const tokenId = labelHashToTokenId(labelhash(label)); - const labelId = makeLabelId(registryId, tokenId); + const domainId = makeDomainId(registryId, tokenId); - console.table({ on: "NewSubname", registryId, tokenId, labelId }); + console.table({ on: "NewSubname", registryId, tokenId, domainId }); // ensure that this registry exists await context.db.insert(schema.v2_registry).values({ id: registryId }).onConflictDoNothing(); @@ -37,19 +37,19 @@ export async function handleNewSubname({ const indexableLabel = isLabelIndexable(label) ? label : null; await context.db - .insert(schema.v2_label) - // insert new Label with `label` value + .insert(schema.v2_domain) + // insert new Domain with `label` value .values({ - id: labelId, + id: domainId, registryId, tokenId, label: indexableLabel, }) - // or upsert existing Label's `label` value + // or upsert existing Domain's `label` value .onConflictDoUpdate({ label: indexableLabel }); // materialize name field - await materializeLabelName(context, labelId); + await materializeDomainName(context, domainId); } export async function handleURI({ @@ -66,20 +66,20 @@ export async function handleURI({ const registryId = makeContractId(context.network.chainId, event.log.address); const tokenId = maskTokenId(id); // NOTE: ensure token id is masked - const labelId = makeLabelId(registryId, tokenId); + const domainId = makeDomainId(registryId, tokenId); - console.table({ on: "URI", registryId, tokenId, labelId, uri }); + console.table({ on: "URI", registryId, tokenId, domainId, uri }); await context.db - .insert(schema.v2_label) - // insert new Label with uri + .insert(schema.v2_domain) + // insert new Domain with uri .values({ - id: labelId, + id: domainId, registryId, tokenId, uri, }) - // or update uri of existing Label + // or update uri of existing Domain .onConflictDoUpdate({ uri }); } @@ -92,36 +92,37 @@ async function handleTransfer({ const registryId = makeContractId(context.network.chainId, event.log.address); const tokenId = maskTokenId(id); // NOTE: ensures that the tokenId emitted is masked - const labelId = makeLabelId(registryId, tokenId); + const domainId = makeDomainId(registryId, tokenId); const owner = getAddress(to); // NOTE: ensures that owner is checksummed - console.table({ on: "handleTransfer", registryId, tokenId, labelId, owner }); + console.table({ on: "handleTransfer", registryId, tokenId, domainId, owner }); const isBurn = owner === zeroAddress; if (isBurn) { - const label = await context.db.find(schema.v2_label, { id: labelId }); - - // NOTE(registry-label-uniq): we must also remove the reverse relationship on its subregistry - if (label?.subregistryId) { + // to remove a Domain from the tree, we need only delete the Domain entity + // NOTE(registry-domain-uniq): we must also remove the reverse relationship on its subregistry + // because we store that information bi-directionally + const domain = await context.db.find(schema.v2_domain, { id: domainId }); + if (domain?.subregistryId) { await context.db - .update(schema.v2_registry, { id: label.subregistryId }) - .set({ labelId: null }); + .update(schema.v2_registry, { id: domain.subregistryId }) + .set({ domainId: null }); } - // to delete a token, we need only delete the label - await context.db.delete(schema.v2_label, { id: labelId }); + // delete the relevant Domain entity, removing it and its subtree from the namespace + await context.db.delete(schema.v2_domain, { id: domainId }); } else { - // mint or update + // this is a mint or update event await context.db - .insert(schema.v2_label) - // insert new Label with owner + .insert(schema.v2_domain) + // insert new Domain with owner .values({ - id: labelId, + id: domainId, registryId, tokenId, owner, }) - // or update owner of existing Label + // or update owner of existing Domain .onConflictDoUpdate({ owner }); } } diff --git a/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts b/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts index 779b7529d..24fb031ed 100644 --- a/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts +++ b/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts @@ -68,7 +68,8 @@ export const makeContractId = (chainId: number, address: Address) => { export const makeResolverRecordsId = (resolverId: string, node: Node) => [resolverId, node].join("-"); -export const makeLabelId = (registryId: string, tokenId: bigint) => [registryId, tokenId].join("-"); +export const makeDomainId = (registryId: string, tokenId: bigint) => + [registryId, tokenId].join("-"); export const makeResolverRecordsAddressId = (resolverRecordsId: string, coinType: bigint) => [resolverRecordsId, coinType].join("-"); @@ -99,37 +100,38 @@ export const tokenIdToLabelHash = (tokenId: bigint): LabelHash => toHex(tokenId, /// /** - * constructs a label's name by traversing the hierarchical namespace upwards. + * constructs a Domain's name by traversing the hierarchical namespace upwards. * NOTE: likely more efficient than custom sql due to in-memory cache-ability */ -async function constructLabelName(context: Context, labelId: string): Promise { - const label = await context.db.find(schema.v2_label, { id: labelId }); - if (!label) { - throw new Error(`constructLabelName expected labelId "${labelId}" to exist, it does not`); +async function constructDomainName(context: Context, domainId: string): Promise { + const domain = await context.db.find(schema.v2_domain, { id: domainId }); + if (!domain) { + throw new Error(`constructDomainName: expected domainId "${domainId}" to exist, it does not`); } - const parentRegistry = await context.db.find(schema.v2_registry, { id: label.registryId }); + const parentRegistry = await context.db.find(schema.v2_registry, { id: domain.registryId }); if (!parentRegistry) { throw new Error( - `constructLabelName expected registryId "${label.registryId}" to exist, it does not`, + `constructDomainName: expected registryId "${domain.registryId}" to exist, it does not`, ); } // human-readable label or encoded labelHash - const segment = label.label ?? encodeLabelhash(tokenIdToLabelHash(label.tokenId)); + const label = domain.label ?? encodeLabelhash(tokenIdToLabelHash(domain.tokenId)); - console.log("constructLabelName", { segment, parentId: parentRegistry.labelId }); + console.log("constructDomainName", { segment: label, parentId: parentRegistry.domainId }); - // this is the root Registry - if (!parentRegistry.labelId) return segment; + // this is the root Registry, which does not have an associated Domain + if (!parentRegistry.domainId) return label; // otherwise, recurse - return [segment, await constructLabelName(context, parentRegistry.labelId)].join("."); + return [label, await constructDomainName(context, parentRegistry.domainId)].join("."); } -export async function materializeLabelName(context: Context, labelId: string) { - const name = await constructLabelName(context, labelId); +// updates a Domain's `name` and `node` by materializing its location in the hierarchical name tree +export async function materializeDomainName(context: Context, domainId: string) { + const name = await constructDomainName(context, domainId); const node = namehash(name); - await context.db.update(schema.v2_label, { id: labelId }).set({ name, node }); + await context.db.update(schema.v2_domain, { id: domainId }).set({ name, node }); } diff --git a/apps/ensnode-api/src/v1.ts b/apps/ensnode-api/src/v1.ts index 6b6edd103..4e83ec30a 100644 --- a/apps/ensnode-api/src/v1.ts +++ b/apps/ensnode-api/src/v1.ts @@ -13,8 +13,8 @@ app.get("/node/:node", async (c) => { const labels = await db .select() - .from(schema.v2_label) - .where(eq(schema.v2_label.node, node)) + .from(schema.v2_domain) + .where(eq(schema.v2_domain.node, node)) .limit(1); if (labels.length === 0) return c.notFound(); diff --git a/packages/ponder-schema/src/ponder.schema.ts b/packages/ponder-schema/src/ponder.schema.ts index 221419973..68dc1e337 100644 --- a/packages/ponder-schema/src/ponder.schema.ts +++ b/packages/ponder-schema/src/ponder.schema.ts @@ -686,13 +686,13 @@ export const versionChangedRelations = relations(versionChanged, ({ one }) => ({ * NOTE: These entities kept namespaced for rapid prototyping—see v2 plans for additional context. * https://www.ensnode.io/ensnode/reference/ensnode-v2-notes/ * - * The core design principle here is that a Registry references many Labels which each reference a - * (sub)Registry which references many Labels... etc. This accurately represents the nature of the + * The core design principle here is that a Registry references many Domains which each reference a + * (sub)Registry which references many Domains... etc. This accurately represents the nature of the * on-chain contracts and supports the dynamic re-arrangement of the hierarchical namespace as * proposed by ENSv2. * - * For example, when a subregistry is updated for a given Label, the tree now represent's that - * subregistry's Labels, without and bulk creation/deletion being necessary. + * For example, when a subregistry is updated for a given Domain, the tree now represent's that + * subregistry's Domains, without and bulk creation/deletion being necessary. * * open questions: * - how do v1 subregistries other than .eth handle the migration? @@ -709,7 +709,7 @@ export const versionChangedRelations = relations(versionChanged, ({ one }) => ({ * the indexer could enforce uniqueness and if a name sets a registry address that's already assigned * we could ignore that subtree? not ideal. since ENSv2 is on L2 we can likely include uniqueness * check without too much of a penalty? - * search TODO(registry-label-uniq): in codebase to see locations where this is noted + * search TODO(registry-domain-uniq): in codebase to see locations where this is noted * - event order guarantees would be really nice as part of the v2 spec, but technically not needed * - i.e. registries must emit NewSubname before any ERC1155 events, must emit NewSubname before * calling datastore, etc. indexers love an event that's guaranteed to be first in order to setup entity @@ -739,11 +739,11 @@ export const v2_registry = onchainTable( id: t.text().primaryKey().$type(), /** - * A Registry can be the subregistry of exactly one Label. + * A Registry can be the subregistry of exactly one Domain. * NOTE: we duplicate this reference in order to make cachable traversals trivial. - * TODO(registry-label-uniq): see above + * TODO(registry-domain-uniq): see above */ - labelId: t.text(), + domainId: t.text(), // TODO: reference registry-specific logic entities here (i.e. .eth registry expiries) }), @@ -752,144 +752,145 @@ export const v2_registry = onchainTable( export const v2_registryRelations = relations(v2_registry, ({ one, many }) => ({ /** - * a registry has one label (i.e is that label's subregistry) + * a registry has one domain (i.e is that domain's subregistry) * - * TODO(registry-label-uniq): see above + * TODO(registry-domain-uniq): see above */ - label: one(v2_label, { - fields: [v2_registry.labelId], - references: [v2_label.id], - relationName: "isSubregistryOf", + domain: one(v2_domain, { + fields: [v2_registry.domainId], + references: [v2_domain.id], + relationName: "isSubregistryOfDomain", }), // a registry has many labels by label.registryId - labels: many(v2_label, { - relationName: "managedLabels", + domains: many(v2_domain, { + relationName: "managedDomains", }), })); /** - * A Label entity represents a label in the hierarchical namespace. + * A Domain entity represents a subname in the hierarchical namespace. * - * TODO: pehaps should rename to `Name`/`Domain` or `Token`? * TODO: perhaps key by node instead of (registryId, tokenId)? * - * In ENSv2 this maps 1:1 with a Registry contract's tokenId + * In ENSv2 this maps 1:1 with a Registry contract's tokens. */ -export const v2_label = onchainTable( - "v2_labels", +export const v2_domain = onchainTable( + "v2_domains", (t) => ({ /** - * Labels are unique by (registryId, tokenId), encoded as `${registryId}-${tokenId}` + * Domains are unique by (registryId, tokenId), encoded as `${registryId}-${tokenId}` */ id: t.text().primaryKey(), /** - * A Label belongs to a Registry. + * A Domain belongs to a Registry. */ registryId: t.text().notNull(), /** - * A Label entity represents a given labelHash value i.e. the result of `labelhash()`, encoded as a bigint 'tokenId'. + * A Domain entity represents a given labelHash value i.e. the result of `labelhash()`, encoded as a bigint 'tokenId'. * - * tokenId alone is _not_ a UUID value, and collisions are expected (i.e. there will be a Label - * entity representing the `hello` in `hello.example.eth` and a Label representing the `hello` + * tokenId alone is _not_ a UUID value, and collisions are expected (i.e. there will be a Domain + * entity representing the `hello` in `hello.example.eth` and a Domain representing the `hello` * in `hello.eth` that have identical tokenId values). * - * Label entities are unique by (registryId, tokenId), enforced by ERC1155. + * Domain entities are unique by (registryId, tokenId), enforced by ERC1155. * * Note that in ENSv2, labelHashes (and tokenIds) have the lower 32 bits masked. */ tokenId: t.bigint().notNull(), /** - * The human-readable representation of a given Label. In ENSv2, this `label` is always known, - * but in ENSv1, labels may or may not be known, hence this field is optional. + * The human-readable representation of a given name segment. + * + * In ENSv1, labels may or may not be known, hence this field is optional. + * In ENSv2, this `label` is always known. */ label: t.text(), /** - * A Label stores a materialized `name`, representing its place in the label hierarchy. In the - * event that a Label within the hierarchy is unknown, this name will contain 'encoded' labelHash + * A Domain stores a materialized `name`, representing its place in the label hierarchy. In the + * event that a label within the hierarchy is unknown, this name will contain 'encoded' labelHash * segments. * * NOTE: in the future, name construction will be done a request-time instead of materialized at * index-time. * * ex. sub.example.eth - * ex. [0xabcd].example.eth - * ex. known.[0xabcd].example.eth + * ex. [abcd].example.eth + * ex. known.[abcd].example.eth */ name: t.text(), /** - * A Label stores a materialized `node`, the result of `namehash(name)`, helpful for - * - referencing labels by `node` - * - referencing this label's resolver records, if any + * A Domain stores a materialized `node`, the result of `namehash(name)`, helpful for + * - referencing Domains by `node` + * - referencing this Domain's resolver records, if any */ node: t.hex(), /** */ /** - * A Label may have an URI. + * A Domain may have an URI. */ uri: t.text(), /** - * A Label has an `owner` address, potentially zeroAddress. + * A Domain has an `owner` address, potentially zeroAddress. */ owner: t.hex().notNull().default(zeroAddress), /** - * A Label can be assigned a (sub)Registry with flags. - * NOTE: we duplicate this reference in order to make cachable traversals trivial. + * A Domain can be assigned a (sub)Registry with flags. + * NOTE: we include bi-directonal references in order to make cachable traversals trivial. */ subregistryId: t.text(), subregistryFlags: t.bigint().notNull().default(0n), /** - * A Label can be configured with a given Resolver with flags. + * A Domain can be configured with a given Resolver with flags. */ resolverId: t.text(), resolverFlags: t.bigint().notNull().default(0n), }), (t) => ({ - // a Label is unique by (registryId, tokenId) - registryLabelHashIndex: uniqueIndex().on(t.registryId, t.tokenId), - // a Label is unique by node + // a Domain is unique by (registryId, tokenId) + registryDomainHashIndex: uniqueIndex().on(t.registryId, t.tokenId), + // a Domain is unique by node idxNode: uniqueIndex().on(t.node), }), ); -export const v2_labelRelations = relations(v2_label, ({ one, many }) => ({ - // label belongs to one (parent)registry +export const v2_domainRelations = relations(v2_domain, ({ one, many }) => ({ + // domain belongs to one (parent)registry registry: one(v2_registry, { - fields: [v2_label.registryId], + fields: [v2_domain.registryId], references: [v2_registry.id], }), - // label references one (sub)registry + // domain references one (sub)registry subregistry: one(v2_registry, { - fields: [v2_label.subregistryId], + fields: [v2_domain.subregistryId], references: [v2_registry.id], }), - // label references one resolver + // domain references one resolver resolver: one(v2_resolver, { - fields: [v2_label.resolverId], + fields: [v2_domain.resolverId], references: [v2_resolver.id], }), - // label references one records by materialized id + // domain references one ResolverRecords by (resolverId, node) records: one(v2_resolverRecords, { - fields: [v2_label.resolverId, v2_label.node], + fields: [v2_domain.resolverId, v2_domain.node], references: [v2_resolverRecords.resolverId, v2_resolverRecords.node], }), })); /** - * A Resolver represents a given Resolver _contract_ on-chain, keyed by CAIP-10 address. + * A Resolver represents a given Resolver _contract_ on-chain. */ export const v2_resolver = onchainTable( "v2_resolvers", @@ -903,10 +904,10 @@ export const v2_resolver = onchainTable( ); export const v2_resolverRelations = relations(v2_resolver, ({ one, many }) => ({ - // any number of labels can reference a given Resolver - label: many(v2_label), + // any number of domains can reference a given Resolver + domains: many(v2_domain), - // resolver has many records + // resolver has many ResolverRecords records: many(v2_resolverRecords), })); @@ -918,12 +919,12 @@ export const v2_resolverRecords = onchainTable( "v2_resolver_records", (t) => ({ /** - * A ResolverRecords is keyed as `${resolverId}-${node}`. + * A ResolverRecords is keyed by (resolverId, node), encoded as `${resolverId}-${node}`. */ id: t.text().primaryKey(), /** - * A ResolverRecords maintains references to the Resolver contract and which label it stores + * A ResolverRecords maintains references to the Resolver contract and which node it stores * records for. */ resolverId: t.text().notNull(), From 75ba92182deb7b7963a9f553a43f3df02724081f Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 12 Mar 2025 14:33:18 -0500 Subject: [PATCH 09/15] chore: rename ensnode-api folder to api --- apps/{ensnode-api => api}/.gitignore | 0 apps/{ensnode-api => api}/LICENSE | 0 apps/{ensnode-api => api}/README.md | 0 apps/{ensnode-api => api}/package.json | 0 apps/{ensnode-api => api}/src/index.ts | 0 apps/{ensnode-api => api}/src/lib/db.ts | 0 apps/{ensnode-api => api}/src/v1.ts | 0 apps/{ensnode-api => api}/tsconfig.json | 0 8 files changed, 0 insertions(+), 0 deletions(-) rename apps/{ensnode-api => api}/.gitignore (100%) rename apps/{ensnode-api => api}/LICENSE (100%) rename apps/{ensnode-api => api}/README.md (100%) rename apps/{ensnode-api => api}/package.json (100%) rename apps/{ensnode-api => api}/src/index.ts (100%) rename apps/{ensnode-api => api}/src/lib/db.ts (100%) rename apps/{ensnode-api => api}/src/v1.ts (100%) rename apps/{ensnode-api => api}/tsconfig.json (100%) diff --git a/apps/ensnode-api/.gitignore b/apps/api/.gitignore similarity index 100% rename from apps/ensnode-api/.gitignore rename to apps/api/.gitignore diff --git a/apps/ensnode-api/LICENSE b/apps/api/LICENSE similarity index 100% rename from apps/ensnode-api/LICENSE rename to apps/api/LICENSE diff --git a/apps/ensnode-api/README.md b/apps/api/README.md similarity index 100% rename from apps/ensnode-api/README.md rename to apps/api/README.md diff --git a/apps/ensnode-api/package.json b/apps/api/package.json similarity index 100% rename from apps/ensnode-api/package.json rename to apps/api/package.json diff --git a/apps/ensnode-api/src/index.ts b/apps/api/src/index.ts similarity index 100% rename from apps/ensnode-api/src/index.ts rename to apps/api/src/index.ts diff --git a/apps/ensnode-api/src/lib/db.ts b/apps/api/src/lib/db.ts similarity index 100% rename from apps/ensnode-api/src/lib/db.ts rename to apps/api/src/lib/db.ts diff --git a/apps/ensnode-api/src/v1.ts b/apps/api/src/v1.ts similarity index 100% rename from apps/ensnode-api/src/v1.ts rename to apps/api/src/v1.ts diff --git a/apps/ensnode-api/tsconfig.json b/apps/api/tsconfig.json similarity index 100% rename from apps/ensnode-api/tsconfig.json rename to apps/api/tsconfig.json From 1dea38c9aa9078bbbe210f461f8f2067554ab66a Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 12 Mar 2025 14:42:34 -0500 Subject: [PATCH 10/15] fix: lockfile --- pnpm-lock.yaml | 62 +++++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e9a48baf..49b1c644c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,37 @@ importers: specifier: 'catalog:' version: 5.7.3 + apps/api: + dependencies: + '@ensnode/ponder-schema': + specifier: workspace:* + version: link:../../packages/ponder-schema + '@ensnode/utils': + specifier: workspace:* + version: link:../../packages/ensnode-utils + '@ponder/client': + specifier: 'catalog:' + version: 0.9.27(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3)(typescript@5.7.3) + '@ponder/utils': + specifier: 'catalog:' + version: 0.2.3(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2)) + drizzle-orm: + specifier: 'catalog:' + version: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3) + hono: + specifier: 'catalog:' + version: 4.7.4 + viem: + specifier: 'catalog:' + version: 2.22.13(typescript@5.7.3)(zod@3.24.2) + devDependencies: + '@ensnode/shared-configs': + specifier: workspace:* + version: link:../../packages/shared-configs + '@types/bun': + specifier: latest + version: 1.2.5 + apps/ensadmin: dependencies: '@graphiql/react': @@ -332,37 +363,6 @@ importers: specifier: 'catalog:' version: 3.0.5(@types/debug@4.1.12)(@types/node@20.17.14)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.2)(yaml@2.7.0) - apps/ensnode-api: - dependencies: - '@ensnode/ponder-schema': - specifier: workspace:* - version: link:../../packages/ponder-schema - '@ensnode/utils': - specifier: workspace:* - version: link:../../packages/ensnode-utils - '@ponder/client': - specifier: 'catalog:' - version: 0.9.27(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3)(typescript@5.7.3) - '@ponder/utils': - specifier: 'catalog:' - version: 0.2.3(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2)) - drizzle-orm: - specifier: 'catalog:' - version: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3) - hono: - specifier: 'catalog:' - version: 4.7.4 - viem: - specifier: 'catalog:' - version: 2.22.13(typescript@5.7.3)(zod@3.24.2) - devDependencies: - '@ensnode/shared-configs': - specifier: workspace:* - version: link:../../packages/shared-configs - '@types/bun': - specifier: latest - version: 1.2.5 - apps/ensrainbow: dependencies: '@ensnode/ensrainbow-sdk': From f0afac29cdd33c8d560a9176356ba04b6af91a1a Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 12 Mar 2025 14:50:19 -0500 Subject: [PATCH 11/15] fix: add bun --- apps/api/package.json | 1 + pnpm-lock.yaml | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index d8b2bcf29..54534354a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,6 +10,7 @@ "@ensnode/utils": "workspace:*", "@ponder/client": "catalog:", "@ponder/utils": "catalog:", + "bun": "^1.2.2", "drizzle-orm": "catalog:", "hono": "catalog:", "viem": "catalog:" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49b1c644c..c8416bd8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: '@ponder/utils': specifier: 'catalog:' version: 0.2.3(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2)) + bun: + specifier: ^1.2.2 + version: 1.2.2 drizzle-orm: specifier: 'catalog:' version: 0.39.3(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.7.0)(bun-types@1.2.5)(kysely@0.26.3)(pg@8.11.3) @@ -2769,6 +2772,7 @@ packages: bun@1.2.2: resolution: {integrity: sha512-RUc8uVVTw8WoASUzXaEQJR1s7mnwoHm3P871qBUIqSaoOpuwcU+bSVX151/xoqDwnyv38SjOX7yQ3oO0IeT73g==} + cpu: [arm64, x64, aarch64] os: [darwin, linux, win32] hasBin: true @@ -9475,7 +9479,7 @@ snapshots: '@typescript-eslint/parser': 8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3) eslint: 9.20.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.20.1(jiti@2.4.2)) eslint-plugin-react: 7.37.4(eslint@9.20.1(jiti@2.4.2)) @@ -9495,7 +9499,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)): + eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 @@ -9510,14 +9514,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3) eslint: 9.20.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)) transitivePeerDependencies: - supports-color @@ -9532,7 +9536,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.20.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 From a1955bb3c6ba01e36468b5deced6acbf41d3c0e1 Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 12 Mar 2025 17:52:53 -0500 Subject: [PATCH 12/15] feat: move name construction to api layer, implement domain finding --- apps/api/src/lib/construct-name.ts | 36 ++++++++ apps/api/src/lib/db.ts | 4 +- apps/api/src/lib/get-domain.ts | 85 +++++++++++++++++++ apps/api/src/lib/get-records.ts | 10 +++ apps/api/src/lib/parse-name.ts | 34 ++++++++ apps/api/src/v1.ts | 22 ++--- .../ens-v2/handlers/shared/Registry.ts | 11 +-- apps/ensindexer/src/plugins/ens-v2/v2-lib.ts | 44 +--------- packages/ponder-schema/src/ponder.schema.ts | 31 ------- 9 files changed, 177 insertions(+), 100 deletions(-) create mode 100644 apps/api/src/lib/construct-name.ts create mode 100644 apps/api/src/lib/get-domain.ts create mode 100644 apps/api/src/lib/get-records.ts create mode 100644 apps/api/src/lib/parse-name.ts diff --git a/apps/api/src/lib/construct-name.ts b/apps/api/src/lib/construct-name.ts new file mode 100644 index 000000000..17977986c --- /dev/null +++ b/apps/api/src/lib/construct-name.ts @@ -0,0 +1,36 @@ +/** + * constructs a Domain's name by traversing the hierarchical namespace upwards. + * NOTE: likely more efficient than custom sql due to in-memory cache-ability + */ +async function constructDomainName(context: Context, domainId: string): Promise { + const domain = await context.db.find(schema.v2_domain, { id: domainId }); + if (!domain) { + throw new Error(`constructDomainName: expected domainId "${domainId}" to exist, it does not`); + } + + const parentRegistry = await context.db.find(schema.v2_registry, { id: domain.registryId }); + if (!parentRegistry) { + throw new Error( + `constructDomainName: expected registryId "${domain.registryId}" to exist, it does not`, + ); + } + + // human-readable label or encoded labelHash + const label = domain.label ?? encodeLabelhash(tokenIdToLabelHash(domain.tokenId)); + + console.log("constructDomainName", { segment: label, parentId: parentRegistry.domainId }); + + // this is the root Registry, which does not have an associated Domain + if (!parentRegistry.domainId) return label; + + // otherwise, recurse + return [label, await constructDomainName(context, parentRegistry.domainId)].join("."); +} + +// updates a Domain's `name` and `node` by materializing its location in the hierarchical name tree +export async function materializeDomainName(context: Context, domainId: string) { + const name = await constructDomainName(context, domainId); + const node = namehash(name); + + await context.db.update(schema.v2_domain, { id: domainId }).set({ name, node }); +} diff --git a/apps/api/src/lib/db.ts b/apps/api/src/lib/db.ts index 81511e66a..d16411694 100644 --- a/apps/api/src/lib/db.ts +++ b/apps/api/src/lib/db.ts @@ -16,5 +16,5 @@ const setDatabaseSchema = ( return schema; }; -export const schema = setDatabaseSchema(_schema, Bun.env.DATABASE_SCHEMA || 'public'); -export const db = drizzle(Bun.env.DATABASE_URL, { schema, casing: "snake_case" }); +export const schema = setDatabaseSchema(_schema, Bun.env.DATABASE_SCHEMA || "public"); +export const db = drizzle(Bun.env.DATABASE_URL, { schema, casing: "snake_case", logger: true }); diff --git a/apps/api/src/lib/get-domain.ts b/apps/api/src/lib/get-domain.ts new file mode 100644 index 000000000..12769e66a --- /dev/null +++ b/apps/api/src/lib/get-domain.ts @@ -0,0 +1,85 @@ +import { sql } from "drizzle-orm"; + +import { LabelHash } from "@ensnode/utils/types"; +import { HTTPException } from "hono/http-exception"; +import { hexToBigInt } from "viem"; +import { db, schema } from "./db"; +import { parseName } from "./parse-name"; + +// TODO: configure this correctly, likely constructing the root registry id from the relevant ens deployment +const ROOT_REGISTRY = "eip155:11155111:0xc44D7201065190B290Aaaf6efaDFD49d530547A3"; + +// TODO: de-duplicate these helpers with @ensnode/utils +const LABEL_HASH_MASK = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000n; +const maskTokenId = (tokenId: bigint) => tokenId & LABEL_HASH_MASK; +const labelHashToTokenId = (labelHash: LabelHash) => + maskTokenId(hexToBigInt(labelHash, { size: 32 })); + +/** + * gets a Domain from the tree if it exists using recursive CTE, traversing from RootRegistry + */ +export async function getDomain(name: string) { + const tokenIds = parseName(name) // given a set of labelhashes + .reverse() // reverse for path + .map((labelHash) => labelHashToTokenId(labelHash)); // convert to masked bigint tokenId + + console.log({ + name, + tokenIdsReversed: tokenIds, + }); + + // https://github.com/drizzle-team/drizzle-orm/issues/1289 + // https://github.com/drizzle-team/drizzle-orm/issues/1589 + const rawTokenIdsArray = sql.raw(`ARRAY[${tokenIds.join(", ")}]::numeric[]`); + + const result = await db.execute(sql` + WITH RECURSIVE path_traversal AS ( + -- Base case: Start with RootRegistry + SELECT + r.id AS "registry_id", + NULL::text AS "domain_id", + NULL::numeric(78,0) AS "token_id", + 0 AS depth + -- ARRAY[]::numeric[] AS traversed_path + FROM + ${schema.v2_registry} r + WHERE + r.id = ${ROOT_REGISTRY} + + UNION ALL + + -- Recursive case: Find matching domain + SELECT + d."subregistry_id" AS "registry_id", + d.id AS "domain_id", + d."token_id", + pt.depth + 1 AS depth + -- pt.traversed_path || d."token_id": :numeric AS traversed_path + FROM + path_traversal pt + JOIN + ${schema.v2_domain} d ON d."registry_id" = pt."registry_id" + WHERE + d."token_id" = (${rawTokenIdsArray})[pt.depth + 1] + AND pt.depth < array_length(${rawTokenIdsArray}, 1) + ) + + SELECT * FROM path_traversal + ORDER BY depth + `); + + const rows = result.rows; + + // the domain in question was found iff the path has exactly the correct number of nodes + // NOTE: +1 includes the RootRegistry response + const exists = result.rows.length === tokenIds.length + 1; + if (!exists) throw new HTTPException(404, { message: "Domain not found." }); + + const lastRow = rows[rows.length - 1]; + if (lastRow.domain_id === null) throw new Error(`Expected domain_id`); + + // the last element is the node and it exists in the tree + return await db.query.v2_domain.findFirst({ + where: (t, { eq }) => eq(t.id, lastRow.domain_id), + }); +} diff --git a/apps/api/src/lib/get-records.ts b/apps/api/src/lib/get-records.ts new file mode 100644 index 000000000..4fc1284c3 --- /dev/null +++ b/apps/api/src/lib/get-records.ts @@ -0,0 +1,10 @@ +import { Node } from "@ensnode/utils/types"; +import { db } from "./db"; + +export async function getResolverRecords(resolverId: string, node: Node) { + return await db.query.v2_resolverRecords.findFirst({ + // TODO: put id generation into @ensnode/utils and re-use it here for faster lookups + where: (t, { eq, and }) => and(eq(t.resolverId, resolverId), eq(t.node, node)), + with: { addresses: true }, + }); +} diff --git a/apps/api/src/lib/parse-name.ts b/apps/api/src/lib/parse-name.ts new file mode 100644 index 000000000..d237559b0 --- /dev/null +++ b/apps/api/src/lib/parse-name.ts @@ -0,0 +1,34 @@ +import { LabelHash } from "@ensnode/utils/types"; +import { Hex, isHex } from "viem"; +import { labelhash, normalize } from "viem/ens"; + +// https://github.com/wevm/viem/blob/main/src/utils/ens/encodedLabelToLabelhash.ts +export function encodedLabelToLabelhash(label: string): Hex | null { + if (label.length !== 66) return null; + if (label.indexOf("[") !== 0) return null; + if (label.indexOf("]") !== 65) return null; + const hash = `0x${label.slice(1, 65)}`; + if (!isHex(hash)) return null; + return hash; +} + +/** + * parses a name into labelHash segments. name may contain encoded labelHashes + */ +export function parseName(name: string): LabelHash[] { + if (name !== normalize(name)) throw new Error(`parseName: "${name}" is not normalized.`); + + return name.split(".").map((segment) => { + const labelHash = segment.startsWith("[") + ? encodedLabelToLabelhash(segment) + : labelhash(segment); + + if (!labelHash) { + throw new Error( + `parseName: name "${name}" segment "${segment}" is not a valid encoded labelHash`, + ); + } + + return labelHash; + }); +} diff --git a/apps/api/src/v1.ts b/apps/api/src/v1.ts index 4e83ec30a..f66df03ba 100644 --- a/apps/api/src/v1.ts +++ b/apps/api/src/v1.ts @@ -1,25 +1,19 @@ -import { Node } from "@ensnode/utils/types"; -import { eq } from "@ponder/client"; import { replaceBigInts } from "@ponder/utils"; import { Hono } from "hono"; -import { db, schema } from "./lib/db.js"; +import { getDomain } from "./lib/get-domain.js"; const app = new Hono(); -app.get("/node/:node", async (c) => { - const node = c.req.param("node") as Node | undefined; - if (!node) throw new Error("param expected"); // TODO: correct error handling +/** + * Finds a Domain by its `name` in the nametree. + */ +app.get("/domain/:name", async (c) => { + const name = c.req.param("name"); - const labels = await db - .select() - .from(schema.v2_domain) - .where(eq(schema.v2_domain.node, node)) - .limit(1); + const domainTree = await getDomain(name); - if (labels.length === 0) return c.notFound(); - - return c.json(replaceBigInts(labels[0], (v) => String(v))); + return c.json(replaceBigInts(domainTree, (v) => String(v))); }); export default app; diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts index 01b762042..f58522492 100644 --- a/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts +++ b/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts @@ -7,13 +7,7 @@ import schema from "ponder:schema"; import { isLabelIndexable } from "@ensnode/utils/subname-helpers"; import { Address, getAddress, labelhash, zeroAddress } from "viem"; import { EventWithArgs } from "../../../../lib/ponder-helpers"; -import { - labelHashToTokenId, - makeContractId, - makeDomainId, - maskTokenId, - materializeDomainName, -} from "../../v2-lib"; +import { labelHashToTokenId, makeContractId, makeDomainId, maskTokenId } from "../../v2-lib"; // NewSubname and DataStore events may arrive in any order export async function handleNewSubname({ @@ -47,9 +41,6 @@ export async function handleNewSubname({ }) // or upsert existing Domain's `label` value .onConflictDoUpdate({ label: indexableLabel }); - - // materialize name field - await materializeDomainName(context, domainId); } export async function handleURI({ diff --git a/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts b/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts index 24fb031ed..7ec6883e5 100644 --- a/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts +++ b/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts @@ -4,10 +4,9 @@ import { Context } from "ponder:registry"; import schema from "ponder:schema"; -import { encodeLabelhash } from "@ensdomains/ensjs/utils"; import { LabelHash, Node } from "@ensnode/utils/types"; import { AccountId } from "caip"; -import { Address, getAddress, hexToBigInt, namehash, toHex } from "viem"; +import { Address, getAddress, hexToBigInt, toHex } from "viem"; const LABEL_HASH_MASK = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000n; @@ -94,44 +93,3 @@ export const labelHashToTokenId = (labelHash: LabelHash) => * decodes a bigint tokenId into a hex labelHash */ export const tokenIdToLabelHash = (tokenId: bigint): LabelHash => toHex(tokenId, { size: 32 }); - -/// -/// Helpers -/// - -/** - * constructs a Domain's name by traversing the hierarchical namespace upwards. - * NOTE: likely more efficient than custom sql due to in-memory cache-ability - */ -async function constructDomainName(context: Context, domainId: string): Promise { - const domain = await context.db.find(schema.v2_domain, { id: domainId }); - if (!domain) { - throw new Error(`constructDomainName: expected domainId "${domainId}" to exist, it does not`); - } - - const parentRegistry = await context.db.find(schema.v2_registry, { id: domain.registryId }); - if (!parentRegistry) { - throw new Error( - `constructDomainName: expected registryId "${domain.registryId}" to exist, it does not`, - ); - } - - // human-readable label or encoded labelHash - const label = domain.label ?? encodeLabelhash(tokenIdToLabelHash(domain.tokenId)); - - console.log("constructDomainName", { segment: label, parentId: parentRegistry.domainId }); - - // this is the root Registry, which does not have an associated Domain - if (!parentRegistry.domainId) return label; - - // otherwise, recurse - return [label, await constructDomainName(context, parentRegistry.domainId)].join("."); -} - -// updates a Domain's `name` and `node` by materializing its location in the hierarchical name tree -export async function materializeDomainName(context: Context, domainId: string) { - const name = await constructDomainName(context, domainId); - const node = namehash(name); - - await context.db.update(schema.v2_domain, { id: domainId }).set({ name, node }); -} diff --git a/packages/ponder-schema/src/ponder.schema.ts b/packages/ponder-schema/src/ponder.schema.ts index 68dc1e337..bac4e8091 100644 --- a/packages/ponder-schema/src/ponder.schema.ts +++ b/packages/ponder-schema/src/ponder.schema.ts @@ -809,29 +809,6 @@ export const v2_domain = onchainTable( */ label: t.text(), - /** - * A Domain stores a materialized `name`, representing its place in the label hierarchy. In the - * event that a label within the hierarchy is unknown, this name will contain 'encoded' labelHash - * segments. - * - * NOTE: in the future, name construction will be done a request-time instead of materialized at - * index-time. - * - * ex. sub.example.eth - * ex. [abcd].example.eth - * ex. known.[abcd].example.eth - */ - name: t.text(), - - /** - * A Domain stores a materialized `node`, the result of `namehash(name)`, helpful for - * - referencing Domains by `node` - * - referencing this Domain's resolver records, if any - */ - node: t.hex(), - - /** */ - /** * A Domain may have an URI. */ @@ -858,8 +835,6 @@ export const v2_domain = onchainTable( (t) => ({ // a Domain is unique by (registryId, tokenId) registryDomainHashIndex: uniqueIndex().on(t.registryId, t.tokenId), - // a Domain is unique by node - idxNode: uniqueIndex().on(t.node), }), ); @@ -881,12 +856,6 @@ export const v2_domainRelations = relations(v2_domain, ({ one, many }) => ({ fields: [v2_domain.resolverId], references: [v2_resolver.id], }), - - // domain references one ResolverRecords by (resolverId, node) - records: one(v2_resolverRecords, { - fields: [v2_domain.resolverId, v2_domain.node], - references: [v2_resolverRecords.resolverId, v2_resolverRecords.node], - }), })); /** From d9fca7386de714c6ef7815835637b4141ca7b3a2 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 13 Mar 2025 12:51:02 -0500 Subject: [PATCH 13/15] fix: key by maskedTokenId but store full tokenId --- apps/api/src/lib/get-domain.ts | 19 ++++--- apps/api/src/v1.ts | 4 +- .../ens-v2/handlers/RegistryDatastore.ts | 56 ++++++++++++++----- .../ens-v2/handlers/shared/Registry.ts | 36 ++++++++---- apps/ensindexer/src/plugins/ens-v2/v2-lib.ts | 7 +-- packages/ponder-schema/src/ponder.schema.ts | 24 +++++--- 6 files changed, 99 insertions(+), 47 deletions(-) diff --git a/apps/api/src/lib/get-domain.ts b/apps/api/src/lib/get-domain.ts index 12769e66a..a549bbd17 100644 --- a/apps/api/src/lib/get-domain.ts +++ b/apps/api/src/lib/get-domain.ts @@ -12,8 +12,7 @@ const ROOT_REGISTRY = "eip155:11155111:0xc44D7201065190B290Aaaf6efaDFD49d530547A // TODO: de-duplicate these helpers with @ensnode/utils const LABEL_HASH_MASK = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000n; const maskTokenId = (tokenId: bigint) => tokenId & LABEL_HASH_MASK; -const labelHashToTokenId = (labelHash: LabelHash) => - maskTokenId(hexToBigInt(labelHash, { size: 32 })); +const labelHashToTokenId = (labelHash: LabelHash) => hexToBigInt(labelHash, { size: 32 }); /** * gets a Domain from the tree if it exists using recursive CTE, traversing from RootRegistry @@ -21,7 +20,7 @@ const labelHashToTokenId = (labelHash: LabelHash) => export async function getDomain(name: string) { const tokenIds = parseName(name) // given a set of labelhashes .reverse() // reverse for path - .map((labelHash) => labelHashToTokenId(labelHash)); // convert to masked bigint tokenId + .map((labelHash) => maskTokenId(labelHashToTokenId(labelHash))); // convert to masked bigint tokenId console.log({ name, @@ -38,7 +37,7 @@ export async function getDomain(name: string) { SELECT r.id AS "registry_id", NULL::text AS "domain_id", - NULL::numeric(78,0) AS "token_id", + NULL::numeric(78,0) AS "masked_token_id", 0 AS depth -- ARRAY[]::numeric[] AS traversed_path FROM @@ -52,27 +51,29 @@ export async function getDomain(name: string) { SELECT d."subregistry_id" AS "registry_id", d.id AS "domain_id", - d."token_id", + d."masked_token_id", pt.depth + 1 AS depth - -- pt.traversed_path || d."token_id": :numeric AS traversed_path + -- pt.traversed_path || d."masked_token_id": :numeric AS traversed_path FROM path_traversal pt JOIN ${schema.v2_domain} d ON d."registry_id" = pt."registry_id" WHERE - d."token_id" = (${rawTokenIdsArray})[pt.depth + 1] + d."masked_token_id" = (${rawTokenIdsArray})[pt.depth + 1] AND pt.depth < array_length(${rawTokenIdsArray}, 1) ) SELECT * FROM path_traversal + WHERE domain_id IS NOT NULL -- only return domains, not root registry ORDER BY depth `); const rows = result.rows; + console.log(rows); + // the domain in question was found iff the path has exactly the correct number of nodes - // NOTE: +1 includes the RootRegistry response - const exists = result.rows.length === tokenIds.length + 1; + const exists = result.rows.length === tokenIds.length; if (!exists) throw new HTTPException(404, { message: "Domain not found." }); const lastRow = rows[rows.length - 1]; diff --git a/apps/api/src/v1.ts b/apps/api/src/v1.ts index f66df03ba..89ecca8f8 100644 --- a/apps/api/src/v1.ts +++ b/apps/api/src/v1.ts @@ -11,9 +11,9 @@ const app = new Hono(); app.get("/domain/:name", async (c) => { const name = c.req.param("name"); - const domainTree = await getDomain(name); + const domain = await getDomain(name); - return c.json(replaceBigInts(domainTree, (v) => String(v))); + return c.json(replaceBigInts(domain, (v) => String(v))); }); export default app; diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts index 60cc4a4ee..8f3daec24 100644 --- a/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts +++ b/apps/ensindexer/src/plugins/ens-v2/handlers/RegistryDatastore.ts @@ -9,29 +9,26 @@ export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { ponder.on(namespace("RegistryDatastore:SubregistryUpdate"), async ({ context, event }) => { const { registry: registryAddress, - labelHash: tokenId, // NOTE: this variable is called labelHash but is actually masked tokenId + labelHash: maskedTokenId, // NOTE: this variable is called labelHash but is actually masked tokenId subregistry: subregistryAddress, flags, } = event.args; const registryId = makeContractId(context.network.chainId, registryAddress); - const domainId = makeDomainId(registryId, tokenId); + const domainId = makeDomainId(registryId, maskedTokenId); const subregistryId = makeContractId(context.network.chainId, subregistryAddress); console.table({ on: "RegistryDatastore:SubregistryUpdate", registryId, - tokenId, + tokenId: maskedTokenId, domainId, subregistryId, hash: event.transaction.hash, }); - // ensure registry entity - await context.db.insert(schema.v2_registry).values({ id: registryId }).onConflictDoNothing(); - - // NOTE(registry-domain-uniq): if subregistry is already linked to a domain, must ignore this update - // NOTE: implements first-write-wins for registry-domain relations + // NOTE(registry-domain-uniq): if subregistry is already linked to a domain, ignore this update + // (implements first-write-wins for registry-domain relations) const existingSubregistry = await context.db.find(schema.v2_registry, { id: subregistryId }); if (existingSubregistry?.domainId) { console.log( @@ -40,6 +37,9 @@ export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { return; } + // ensure registry entity + await context.db.insert(schema.v2_registry).values({ id: registryId }).onConflictDoNothing(); + // ensure subregistry entity // TODO(registry-domain-uniq): also update the reverse-mapping on the subregistry to point to this domain await context.db @@ -50,7 +50,22 @@ export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { await context.db .insert(schema.v2_domain) // insert domain with subregistry info - .values({ id: domainId, registryId, tokenId, subregistryId, subregistryFlags: flags }) + .values({ + id: domainId, + registryId, + maskedTokenId, + // NOTE: this is technically incorrect but this event does not have access to the token's unmasked + // tokenId. in the event that the Domain already exists, this create will not occur so this isn't an issue. + // in the event that the Domain does not exist, it is created with an incorrect tokenId BUT + // according to IRegistry, NewSubname will follow, which ensures that the correct tokenId + // is always set. solutions to this issue include: + // 1. event-order guarantees (NewSubname then RegistryDatastore events) + // 2. RegistryDatastore events emitting the un-masked tokenId and expecting clients to do the + // subsequent masking + tokenId: maskedTokenId, + subregistryId, + subregistryFlags: flags, + }) // or update existing domain with subregistry info .onConflictDoUpdate({ subregistryId, subregistryFlags: flags }); }); @@ -59,19 +74,19 @@ export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { ponder.on(namespace("RegistryDatastore:ResolverUpdate"), async ({ context, event }) => { const { registry: registryAddress, - labelHash: tokenId, // NOTE: this variable is called labelHash but is actually masked tokenId + labelHash: maskedTokenId, // NOTE: this variable is called labelHash but is actually masked tokenId resolver: resolverAddress, flags, } = event.args; const registryId = makeContractId(context.network.chainId, registryAddress); - const domainId = makeDomainId(registryId, tokenId); + const domainId = makeDomainId(registryId, maskedTokenId); const resolverId = makeContractId(context.network.chainId, resolverAddress); console.table({ on: "RegistryDatastore:ResolverUpdate", registryId, - tokenId, + tokenId: maskedTokenId, domainId, resolverId, }); @@ -85,7 +100,22 @@ export default function ({ namespace }: PonderENSPluginHandlerArgs<"ens-v2">) { await context.db .insert(schema.v2_domain) // insert domain with resolver info - .values({ id: domainId, registryId, tokenId, resolverId, resolverFlags: flags }) + .values({ + id: domainId, + registryId, + maskedTokenId, + // NOTE: this is technically incorrect but this event does not have access to the token's unmasked + // tokenId. in the event that the Domain already exists, this create will not occur so this isn't an issue. + // in the event that the Domain does not exist, it is created with an incorrect tokenId BUT + // according to IRegistry, NewSubname will follow, which ensures that the correct tokenId + // is always set. solutions to this issue include: + // 1. event-order guarantees (NewSubname then RegistryDatastore events) + // 2. RegistryDatastore events emitting the un-masked tokenId and expecting clients to do the + // subsequent masking + tokenId: maskedTokenId, + resolverId, + resolverFlags: flags, + }) // or update existing domain with resolver info .onConflictDoUpdate({ resolverId, resolverFlags: flags }); }); diff --git a/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts b/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts index f58522492..20baafdbf 100644 --- a/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts +++ b/apps/ensindexer/src/plugins/ens-v2/handlers/shared/Registry.ts @@ -21,7 +21,8 @@ export async function handleNewSubname({ const registryId = makeContractId(context.network.chainId, event.log.address); const tokenId = labelHashToTokenId(labelhash(label)); - const domainId = makeDomainId(registryId, tokenId); + const maskedTokenId = maskTokenId(tokenId); + const domainId = makeDomainId(registryId, maskedTokenId); console.table({ on: "NewSubname", registryId, tokenId, domainId }); @@ -37,10 +38,15 @@ export async function handleNewSubname({ id: domainId, registryId, tokenId, + maskedTokenId, label: indexableLabel, }) // or upsert existing Domain's `label` value - .onConflictDoUpdate({ label: indexableLabel }); + .onConflictDoUpdate({ + label: indexableLabel, + // NOTE: we enforce that tokenId is set here to avoid event-order issues specified in RegistryDatastore + tokenId, + }); } export async function handleURI({ @@ -53,11 +59,11 @@ export async function handleURI({ value: string; }>; }) { - const { id, value: uri } = event.args; + const { id: tokenId, value: uri } = event.args; const registryId = makeContractId(context.network.chainId, event.log.address); - const tokenId = maskTokenId(id); // NOTE: ensure token id is masked - const domainId = makeDomainId(registryId, tokenId); + const maskedTokenId = maskTokenId(tokenId); + const domainId = makeDomainId(registryId, maskedTokenId); console.table({ on: "URI", registryId, tokenId, domainId, uri }); @@ -68,10 +74,15 @@ export async function handleURI({ id: domainId, registryId, tokenId, + maskedTokenId, uri, }) // or update uri of existing Domain - .onConflictDoUpdate({ uri }); + .onConflictDoUpdate({ + uri, + // NOTE: we enforce that tokenId is set here to avoid event-order issues specified in RegistryDatastore + tokenId, + }); } // ERC1155 Transfer events may arrive in any order @@ -79,11 +90,11 @@ async function handleTransfer({ context, event, }: { context: Context; event: EventWithArgs<{ id: bigint; to: Address }> }) { - const { id, to } = event.args; + const { id: tokenId, to } = event.args; const registryId = makeContractId(context.network.chainId, event.log.address); - const tokenId = maskTokenId(id); // NOTE: ensures that the tokenId emitted is masked - const domainId = makeDomainId(registryId, tokenId); + const maskedTokenId = maskTokenId(tokenId); + const domainId = makeDomainId(registryId, maskedTokenId); const owner = getAddress(to); // NOTE: ensures that owner is checksummed console.table({ on: "handleTransfer", registryId, tokenId, domainId, owner }); @@ -111,10 +122,15 @@ async function handleTransfer({ id: domainId, registryId, tokenId, + maskedTokenId, owner, }) // or update owner of existing Domain - .onConflictDoUpdate({ owner }); + .onConflictDoUpdate({ + owner, + // NOTE: we enforce that tokenId is set here to avoid event-order issues specified in RegistryDatastore + tokenId, + }); } } diff --git a/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts b/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts index 7ec6883e5..f2ec62a91 100644 --- a/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts +++ b/apps/ensindexer/src/plugins/ens-v2/v2-lib.ts @@ -67,8 +67,8 @@ export const makeContractId = (chainId: number, address: Address) => { export const makeResolverRecordsId = (resolverId: string, node: Node) => [resolverId, node].join("-"); -export const makeDomainId = (registryId: string, tokenId: bigint) => - [registryId, tokenId].join("-"); +export const makeDomainId = (registryId: string, maskedTokenId: bigint) => + [registryId, maskedTokenId].join("-"); export const makeResolverRecordsAddressId = (resolverRecordsId: string, coinType: bigint) => [resolverRecordsId, coinType].join("-"); @@ -86,8 +86,7 @@ export const maskTokenId = (tokenId: bigint) => tokenId & LABEL_HASH_MASK; /** * encodes a hex labelHash as bigint, masking the lower 32 bits */ -export const labelHashToTokenId = (labelHash: LabelHash) => - maskTokenId(hexToBigInt(labelHash, { size: 32 })); +export const labelHashToTokenId = (labelHash: LabelHash) => hexToBigInt(labelHash, { size: 32 }); /** * decodes a bigint tokenId into a hex labelHash diff --git a/packages/ponder-schema/src/ponder.schema.ts b/packages/ponder-schema/src/ponder.schema.ts index bac4e8091..8c533dec7 100644 --- a/packages/ponder-schema/src/ponder.schema.ts +++ b/packages/ponder-schema/src/ponder.schema.ts @@ -771,15 +771,14 @@ export const v2_registryRelations = relations(v2_registry, ({ one, many }) => ({ /** * A Domain entity represents a subname in the hierarchical namespace. * - * TODO: perhaps key by node instead of (registryId, tokenId)? - * * In ENSv2 this maps 1:1 with a Registry contract's tokens. + * NOTE: we key Domain by maskedTokenId to avoid collisions. */ export const v2_domain = onchainTable( "v2_domains", (t) => ({ /** - * Domains are unique by (registryId, tokenId), encoded as `${registryId}-${tokenId}` + * Domains are unique by (registryId, maskedTokenId), encoded as `${registryId}-${maskedTokenId}` */ id: t.text().primaryKey(), @@ -789,15 +788,20 @@ export const v2_domain = onchainTable( registryId: t.text().notNull(), /** - * A Domain entity represents a given labelHash value i.e. the result of `labelhash()`, encoded as a bigint 'tokenId'. + * A Domain entity represents a given labelHash value i.e. the result of `labelhash()`, encoded + * as a bigint 'tokenId', within a given Registry, with the lower 32 bits masked. + */ + maskedTokenId: t.bigint().notNull(), + + /** + * A Domain stores its un-masked tokenId for calculating `node`. * * tokenId alone is _not_ a UUID value, and collisions are expected (i.e. there will be a Domain * entity representing the `hello` in `hello.example.eth` and a Domain representing the `hello` * in `hello.eth` that have identical tokenId values). * - * Domain entities are unique by (registryId, tokenId), enforced by ERC1155. - * - * Note that in ENSv2, labelHashes (and tokenIds) have the lower 32 bits masked. + * Domain entities are unique by (registryId, tokenId), enforced by ERC1155. Note that this tokenId + * value is _not_ masked. */ tokenId: t.bigint().notNull(), @@ -833,8 +837,10 @@ export const v2_domain = onchainTable( resolverFlags: t.bigint().notNull().default(0n), }), (t) => ({ - // a Domain is unique by (registryId, tokenId) - registryDomainHashIndex: uniqueIndex().on(t.registryId, t.tokenId), + // a Domain is unique by (registryId, maskedTokenId) (indexer-invariant) + registryTokenIdIndex: uniqueIndex().on(t.registryId, t.maskedTokenId), + // a Domain is unique by (registryId, tokenId) (ERC1155-invariant) + registryMaskedTokenIdIndex: uniqueIndex().on(t.registryId, t.tokenId), }), ); From 9001d38716562d5a9774ada7dcfa82a6c50fc673 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 13 Mar 2025 12:55:36 -0500 Subject: [PATCH 14/15] fix: store checksummed addresses --- apps/api/src/lib/get-domain.ts | 6 ++++++ packages/ponder-schema/src/ponder.schema.ts | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/api/src/lib/get-domain.ts b/apps/api/src/lib/get-domain.ts index a549bbd17..3d5f04642 100644 --- a/apps/api/src/lib/get-domain.ts +++ b/apps/api/src/lib/get-domain.ts @@ -38,6 +38,8 @@ export async function getDomain(name: string) { r.id AS "registry_id", NULL::text AS "domain_id", NULL::numeric(78,0) AS "masked_token_id", + NULL::numeric(78,0) AS "token_id", + NULL::text AS "label", 0 AS depth -- ARRAY[]::numeric[] AS traversed_path FROM @@ -52,6 +54,8 @@ export async function getDomain(name: string) { d."subregistry_id" AS "registry_id", d.id AS "domain_id", d."masked_token_id", + d."token_id", + d.label, pt.depth + 1 AS depth -- pt.traversed_path || d."masked_token_id": :numeric AS traversed_path FROM @@ -79,6 +83,8 @@ export async function getDomain(name: string) { const lastRow = rows[rows.length - 1]; if (lastRow.domain_id === null) throw new Error(`Expected domain_id`); + // construct the domain's name and node + // the last element is the node and it exists in the tree return await db.query.v2_domain.findFirst({ where: (t, { eq }) => eq(t.id, lastRow.domain_id), diff --git a/packages/ponder-schema/src/ponder.schema.ts b/packages/ponder-schema/src/ponder.schema.ts index 8c533dec7..1114255e7 100644 --- a/packages/ponder-schema/src/ponder.schema.ts +++ b/packages/ponder-schema/src/ponder.schema.ts @@ -820,8 +820,10 @@ export const v2_domain = onchainTable( /** * A Domain has an `owner` address, potentially zeroAddress. + * + * NOTE: stored as text with type Address to ensure checksumming is persisted */ - owner: t.hex().notNull().default(zeroAddress), + owner: t.text().notNull().default(zeroAddress).$type
(), /** * A Domain can be assigned a (sub)Registry with flags. From e34e2799cd7f8a1b837d4afe60105f86f14d07e5 Mon Sep 17 00:00:00 2001 From: shrugs Date: Thu, 13 Mar 2025 13:17:16 -0500 Subject: [PATCH 15/15] feat: reconstruct name and node for domain --- apps/api/package.json | 1 + apps/api/src/lib/construct-name.ts | 36 ------------------------ apps/api/src/lib/get-domain.ts | 37 +++++++++++++++--------- apps/api/src/lib/parse-name.ts | 4 +-- apps/api/src/v1.ts | 45 +++++++++++++++++++++++++++--- pnpm-lock.yaml | 3 ++ 6 files changed, 70 insertions(+), 56 deletions(-) delete mode 100644 apps/api/src/lib/construct-name.ts diff --git a/apps/api/package.json b/apps/api/package.json index 54534354a..c1a10cc6b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,6 +6,7 @@ "dev": "bun run --hot src/index.ts" }, "dependencies": { + "@ensdomains/ensjs": "^4.0.2", "@ensnode/ponder-schema": "workspace:*", "@ensnode/utils": "workspace:*", "@ponder/client": "catalog:", diff --git a/apps/api/src/lib/construct-name.ts b/apps/api/src/lib/construct-name.ts deleted file mode 100644 index 17977986c..000000000 --- a/apps/api/src/lib/construct-name.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * constructs a Domain's name by traversing the hierarchical namespace upwards. - * NOTE: likely more efficient than custom sql due to in-memory cache-ability - */ -async function constructDomainName(context: Context, domainId: string): Promise { - const domain = await context.db.find(schema.v2_domain, { id: domainId }); - if (!domain) { - throw new Error(`constructDomainName: expected domainId "${domainId}" to exist, it does not`); - } - - const parentRegistry = await context.db.find(schema.v2_registry, { id: domain.registryId }); - if (!parentRegistry) { - throw new Error( - `constructDomainName: expected registryId "${domain.registryId}" to exist, it does not`, - ); - } - - // human-readable label or encoded labelHash - const label = domain.label ?? encodeLabelhash(tokenIdToLabelHash(domain.tokenId)); - - console.log("constructDomainName", { segment: label, parentId: parentRegistry.domainId }); - - // this is the root Registry, which does not have an associated Domain - if (!parentRegistry.domainId) return label; - - // otherwise, recurse - return [label, await constructDomainName(context, parentRegistry.domainId)].join("."); -} - -// updates a Domain's `name` and `node` by materializing its location in the hierarchical name tree -export async function materializeDomainName(context: Context, domainId: string) { - const name = await constructDomainName(context, domainId); - const node = namehash(name); - - await context.db.update(schema.v2_domain, { id: domainId }).set({ name, node }); -} diff --git a/apps/api/src/lib/get-domain.ts b/apps/api/src/lib/get-domain.ts index 3d5f04642..50e8acbea 100644 --- a/apps/api/src/lib/get-domain.ts +++ b/apps/api/src/lib/get-domain.ts @@ -1,6 +1,6 @@ import { sql } from "drizzle-orm"; -import { LabelHash } from "@ensnode/utils/types"; +import { CAIP10AccountId, LabelHash } from "@ensnode/utils/types"; import { HTTPException } from "hono/http-exception"; import { hexToBigInt } from "viem"; import { db, schema } from "./db"; @@ -17,11 +17,15 @@ const labelHashToTokenId = (labelHash: LabelHash) => hexToBigInt(labelHash, { si /** * gets a Domain from the tree if it exists using recursive CTE, traversing from RootRegistry */ -export async function getDomain(name: string) { +export async function getDomainAndPath(name: string) { const tokenIds = parseName(name) // given a set of labelhashes - .reverse() // reverse for path + .toReversed() // reverse for path .map((labelHash) => maskTokenId(labelHashToTokenId(labelHash))); // convert to masked bigint tokenId + if (tokenIds.length === 0) { + throw new Error(`getDomainAndPath: name "${name}" did not contain any segments?`); + } + console.log({ name, tokenIdsReversed: tokenIds, @@ -72,21 +76,28 @@ export async function getDomain(name: string) { ORDER BY depth `); - const rows = result.rows; - - console.log(rows); + // TODO: idk type this correctly + const rows = result.rows as { + registry_id: CAIP10AccountId; + domain_id: string; + masked_token_id: string; + token_id: string; + label: string; + depth: number; + }[]; // the domain in question was found iff the path has exactly the correct number of nodes - const exists = result.rows.length === tokenIds.length; + const exists = rows.length > 0 && rows.length === tokenIds.length; if (!exists) throw new HTTPException(404, { message: "Domain not found." }); - const lastRow = rows[rows.length - 1]; + const lastRow = rows[rows.length - 1]!; // NOTE: must exist given length check above if (lastRow.domain_id === null) throw new Error(`Expected domain_id`); - // construct the domain's name and node - // the last element is the node and it exists in the tree - return await db.query.v2_domain.findFirst({ - where: (t, { eq }) => eq(t.id, lastRow.domain_id), - }); + return { + path: rows, + domain: await db.query.v2_domain.findFirst({ + where: (t, { eq }) => eq(t.id, lastRow.domain_id), + }), + }; } diff --git a/apps/api/src/lib/parse-name.ts b/apps/api/src/lib/parse-name.ts index d237559b0..7733e609c 100644 --- a/apps/api/src/lib/parse-name.ts +++ b/apps/api/src/lib/parse-name.ts @@ -1,6 +1,6 @@ import { LabelHash } from "@ensnode/utils/types"; import { Hex, isHex } from "viem"; -import { labelhash, normalize } from "viem/ens"; +import { labelhash } from "viem/ens"; // https://github.com/wevm/viem/blob/main/src/utils/ens/encodedLabelToLabelhash.ts export function encodedLabelToLabelhash(label: string): Hex | null { @@ -16,8 +16,6 @@ export function encodedLabelToLabelhash(label: string): Hex | null { * parses a name into labelHash segments. name may contain encoded labelHashes */ export function parseName(name: string): LabelHash[] { - if (name !== normalize(name)) throw new Error(`parseName: "${name}" is not normalized.`); - return name.split(".").map((segment) => { const labelHash = segment.startsWith("[") ? encodedLabelToLabelhash(segment) diff --git a/apps/api/src/v1.ts b/apps/api/src/v1.ts index 89ecca8f8..8fe9ca7a1 100644 --- a/apps/api/src/v1.ts +++ b/apps/api/src/v1.ts @@ -1,7 +1,10 @@ +import { encodeLabelhash } from "@ensdomains/ensjs/utils"; +import { uint256ToHex32 } from "@ensnode/utils/subname-helpers"; import { replaceBigInts } from "@ponder/utils"; import { Hono } from "hono"; +import { namehash } from "viem"; -import { getDomain } from "./lib/get-domain.js"; +import { getDomainAndPath } from "./lib/get-domain.js"; const app = new Hono(); @@ -9,11 +12,45 @@ const app = new Hono(); * Finds a Domain by its `name` in the nametree. */ app.get("/domain/:name", async (c) => { - const name = c.req.param("name"); + const nameParam = c.req.param("name"); - const domain = await getDomain(name); + // fetches a domain by name and the concrete path in the nametree + const { domain, path } = await getDomainAndPath(nameParam); - return c.json(replaceBigInts(domain, (v) => String(v))); + // identify any unknown labels in the name + const unknownSegments = path.filter((segment) => segment.label === undefined); + + // TODO: attempt heal with ENSRainbow batch + const knownOrEncodedSegments = (await Promise.all(unknownSegments)).reduce< + Record + >((memo, segment) => { + memo[segment.token_id] === encodeLabelhash(uint256ToHex32(BigInt(segment.token_id))); + return memo; + }, {}); + + // construct the domain's name to the best of our abilities + const name = path + // reverse to name-order + .toReversed() + // return known label or ens rainbow result + .map((segment) => segment.label ?? knownOrEncodedSegments[segment.token_id]) + // join into name + .join("."); + + const node = namehash(name); + + // TODO: type this when we're more confident in what we want + const result = { + domain: { + ...domain, + // add constructed name and node to domain response + name, + node, + }, + path, + }; + + return c.json(replaceBigInts(result, (v) => String(v))); }); export default app; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8416bd8e..056247b38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ importers: apps/api: dependencies: + '@ensdomains/ensjs': + specifier: ^4.0.2 + version: 4.0.2(typescript@5.7.3)(viem@2.22.13(typescript@5.7.3)(zod@3.24.2))(zod@3.24.2) '@ensnode/ponder-schema': specifier: workspace:* version: link:../../packages/ponder-schema