diff --git a/.env.local.example b/.env.local.example index 6252eb7..bb97466 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,19 +1,40 @@ # RPC configuration -# Follow the format: RPC_URL_{chainId}={rpcUrl} - -RPC_URL_1=https://ethereum-rpc.publicnode.com -RPC_URL_8453=https://base-rpc.publicnode.com -RPC_URL_59144=https://linea-rpc.publicnode.com - -# Identify which indexer plugin to activate (see `src/plugins` for available plugins) - -ACTIVE_PLUGIN=base.eth +# For the RPC URL of each chain, follow the format: RPC_URL_{chainId}={rpcUrl} +RPC_URL_1=https://eth.drpc.org +RPC_URL_8453=https://base.drpc.org +RPC_URL_59144=https://linea.drpc.org +# For the RPC rate limits of each chain, follow the format: +# RPC_REQUEST_RATE_LIMIT_{chainId}={rateLimitInRequestsPerSecond} +# The rate limit is the maximum number of requests per second that can be made +# to the RPC endpoint. For public RPC endpoints, it is recommended to set +# a rate limit to low values (i.e. below 30 rps) to avoid being rate limited. +# For private RPC endpoints, the rate limit can be set to higher values, +# depending on the capacity of the endpoint. For example, 500 rps. +# If no rate limit is set for a given chainId, the DEFAULT_RPC_RATE_LIMIT value +# will be applied. +RPC_REQUEST_RATE_LIMIT_1=50 +RPC_REQUEST_RATE_LIMIT_8453=20 +RPC_REQUEST_RATE_LIMIT_59144=20 # Database configuration - -# This is where the indexer will create the tables defined in ponder.schema.ts -# No two indexer instances can use the same database schema at the same time. This prevents data corruption. -# @link https://ponder.sh/docs/api-reference/database#database-schema-rules -DATABASE_SCHEMA=subname_index_base.eth -# The indexer will use Postgres with that as the connection string. If not defined, the indexer will use PSlite. +# This is a namespace for the tables that the indexer will create to store indexed data. +# It should be a string that is unique to the running indexer instance. +# +# Keeping the database schema unique to the indexer instance is important to +# 1) speed up indexing after a restart +# 2) prevent data corruption from multiple indexer app instances writing state +# concurrently to the same db schema +# +# No two indexer instances can use the same database schema at the same time. +# +# Read more about database schema rules here: +# https://ponder.sh/docs/api-reference/database#database-schema-rules +DATABASE_SCHEMA=ens +# This is the connection string for the database that the indexer will use to store data. +# It should be in the format of `postgresql://:@:/` DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database + +# Plugin configuration +# Identify which indexer plugins to activate (see `src/plugins` for available plugins) +# This is a comma separated list of one or more available plugin names (case-sensitive). +ACTIVE_PLUGINS=eth,base.eth,linea.eth \ No newline at end of file diff --git a/README.md b/README.md index 2364136..797d9d4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ens-multichain indexer +# ENSNode > powered by ponder diff --git a/package.json b/package.json index b0ddcdf..2cda7f4 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "dependencies": { "@ensdomains/ensjs": "^4.0.2", "hono": "^4.6.14", - "ponder": "^0.8.17", + "ponder": "^0.8.26", + "ts-deepmerge": "^7.0.2", "viem": "^2.21.57" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d291439..62c531e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,11 @@ importers: specifier: ^4.6.14 version: 4.6.14 ponder: - specifier: ^0.8.17 - version: 0.8.17(@opentelemetry/api@1.9.0)(@types/node@20.17.10)(@types/pg@8.11.10)(hono@4.6.14)(typescript@5.7.2)(viem@2.21.57(typescript@5.7.2)) + specifier: ^0.8.26 + version: 0.8.26(@opentelemetry/api@1.9.0)(@types/node@20.17.10)(@types/pg@8.11.10)(hono@4.6.14)(typescript@5.7.2)(viem@2.21.57(typescript@5.7.2)) + ts-deepmerge: + specifier: ^7.0.2 + version: 7.0.2 viem: specifier: ^2.21.57 version: 2.21.57(typescript@5.7.2) @@ -1452,8 +1455,8 @@ packages: resolution: {integrity: sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==} hasBin: true - ponder@0.8.17: - resolution: {integrity: sha512-p0gvs0CJpdJ6sf5OOQYXaIfmIeUVoTMkCbPVAJ1jK1O2m62ZnTlxpnGrPp5ZAWYxdlCSQQCpZpNhdsYGejGK+g==} + ponder@0.8.26: + resolution: {integrity: sha512-K1fAaJK8eGdAnur+X6TGW/3y7hJc+3HSB5KJMFYiUNReExgFG9kxDT6+La/myv8IHm3SE2BB7BAWAXmaUYy2Cg==} engines: {node: '>=18.14'} hasBin: true peerDependencies: @@ -1734,6 +1737,10 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + ts-deepmerge@7.0.2: + resolution: {integrity: sha512-akcpDTPuez4xzULo5NwuoKwYRtjQJ9eoNfBACiBMaXwNAx7B1PKfe5wqUFJuW5uKzQ68YjDFwPaWHDG1KnFGsA==} + engines: {node: '>=14.13.1'} + ts-pattern@5.6.0: resolution: {integrity: sha512-SL8u60X5+LoEy9tmQHWCdPc2hhb2pKI6I1tU5Jue3v8+iRqZdcT3mWPwKKJy1fMfky6uha82c8ByHAE8PMhKHw==} @@ -3157,7 +3164,7 @@ snapshots: sonic-boom: 3.8.1 thread-stream: 2.7.0 - ponder@0.8.17(@opentelemetry/api@1.9.0)(@types/node@20.17.10)(@types/pg@8.11.10)(hono@4.6.14)(typescript@5.7.2)(viem@2.21.57(typescript@5.7.2)): + ponder@0.8.26(@opentelemetry/api@1.9.0)(@types/node@20.17.10)(@types/pg@8.11.10)(hono@4.6.14)(typescript@5.7.2)(viem@2.21.57(typescript@5.7.2)): dependencies: '@babel/code-frame': 7.26.2 '@commander-js/extra-typings': 12.1.0(commander@12.1.0) @@ -3505,6 +3512,8 @@ snapshots: tr46@0.0.3: {} + ts-deepmerge@7.0.2: {} + ts-pattern@5.6.0: {} tsconfck@3.1.4(typescript@5.7.2): diff --git a/ponder.config.ts b/ponder.config.ts index a0e2880..8a96830 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -1,29 +1,33 @@ -import { ACTIVE_PLUGIN } from "./src/lib/plugin-helpers"; +import { deepMergeRecursive } from "./src/lib/helpers"; +import { type MergedTypes, getActivePlugins } from "./src/lib/plugin-helpers"; import * as baseEthPlugin from "./src/plugins/base.eth/ponder.config"; import * as ethPlugin from "./src/plugins/eth/ponder.config"; import * as lineaEthPlugin from "./src/plugins/linea.eth/ponder.config"; -const plugins = [baseEthPlugin, ethPlugin, lineaEthPlugin] as const; +// list of all available plugins +// any available plugin can be activated at runtime +const availablePlugins = [baseEthPlugin, ethPlugin, lineaEthPlugin] as const; -// here we export only a single 'plugin's config, by type it as every config -// this makes all of the mapping types happy at typecheck-time, but only the -// relevant config is run at runtime -export default ((): AllConfigs => { - const pluginToActivate = plugins.find((p) => p.ownedName === ACTIVE_PLUGIN); +// merge of all available plugin configs to support correct typechecking +// of the indexing handlers +type AllPluginConfigs = MergedTypes<(typeof availablePlugins)[number]["config"]>; - if (!pluginToActivate) { - throw new Error(`Unsupported ACTIVE_PLUGIN: ${ACTIVE_PLUGIN}`); - } +// Activates the indexing handlers of activated plugins. +// Statically typed as the merge of all available plugin configs. However at +// runtime returns the merge of all activated plugin configs. +function activatePluginsAndGetConfig(): AllPluginConfigs { + const activePlugins = getActivePlugins(availablePlugins); - pluginToActivate.activate(); + // load indexing handlers from the active plugins into the runtime + activePlugins.forEach((plugin) => plugin.activate()); - return pluginToActivate.config as AllConfigs; -})(); + const activePluginsConfig = activePlugins + .map((plugin) => plugin.config) + .reduce((acc, val) => deepMergeRecursive(acc, val), {} as AllPluginConfigs); -// Helper type to get the intersection of all config types -type IntersectionOf = (T extends any ? (x: T) => void : never) extends (x: infer R) => void - ? R - : never; -// The type of the exported default is the intersection of all plugin configs to -// each plugin can be correctly typechecked -type AllConfigs = IntersectionOf<(typeof plugins)[number]["config"]>; + return activePluginsConfig as AllPluginConfigs; +} + +// 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 activatePluginsAndGetConfig(); diff --git a/src/handlers/NameWrapper.ts b/src/handlers/NameWrapper.ts index 5330c49..bd1191a 100644 --- a/src/handlers/NameWrapper.ts +++ b/src/handlers/NameWrapper.ts @@ -2,10 +2,10 @@ import { type Context, type Event, type EventNames } from "ponder:registry"; import schema from "ponder:schema"; import { checkPccBurned } from "@ensdomains/ensjs/utils"; import { type Address, type Hex, hexToBytes, namehash } from "viem"; +import { upsertAccount } from "../lib/db-helpers"; import { bigintMax } from "../lib/helpers"; import { makeEventId } from "../lib/ids"; import { decodeDNSPacketBytes, tokenIdToLabel } from "../lib/subname-helpers"; -import { upsertAccount } from "../lib/upserts"; // if the wrappedDomain in question has pcc burned (?) and a higher (?) expiry date, update the domain's expiryDate async function materializeDomainExpiryDate(context: Context, node: Hex) { diff --git a/src/handlers/Registrar.ts b/src/handlers/Registrar.ts index 90c6057..eb3057b 100644 --- a/src/handlers/Registrar.ts +++ b/src/handlers/Registrar.ts @@ -2,8 +2,8 @@ import { type Context } from "ponder:registry"; import schema from "ponder:schema"; import { Block } from "ponder"; import { type Hex, namehash } from "viem"; +import { upsertAccount, upsertRegistration } from "../lib/db-helpers"; import { isLabelIndexable, makeSubnodeNamehash, tokenIdToLabel } from "../lib/subname-helpers"; -import { upsertAccount, upsertRegistration } from "../lib/upserts"; const GRACE_PERIOD_SECONDS = 7776000n; // 90 days in seconds @@ -23,7 +23,7 @@ export const makeRegistrarHandlers = (ownedName: `${string}eth`) => { if (domain.labelName !== name) { await context.db .update(schema.domain, { id: node }) - .set({ labelName: name, name: `${name}${ownedName}` }); + .set({ labelName: name, name: `${name}.${ownedName}` }); } await context.db.update(schema.registration, { id: label }).set({ labelName: name, cost }); @@ -79,7 +79,7 @@ export const makeRegistrarHandlers = (ownedName: `${string}eth`) => { context: Context; args: { name: string; label: Hex; cost: bigint }; }) { - return await setNamePreimage(context, name, label, cost); + await setNamePreimage(context, name, label, cost); }, async handleNameRenewedByController({ @@ -89,7 +89,7 @@ export const makeRegistrarHandlers = (ownedName: `${string}eth`) => { context: Context; args: { name: string; label: Hex; cost: bigint }; }) { - return await setNamePreimage(context, name, label, cost); + await setNamePreimage(context, name, label, cost); }, async handleNameRenewed({ @@ -117,7 +117,7 @@ export const makeRegistrarHandlers = (ownedName: `${string}eth`) => { async handleNameTransferred({ context, - args: { tokenId, from, to }, + args: { tokenId, to }, }: { context: Context; args: { diff --git a/src/handlers/Registry.ts b/src/handlers/Registry.ts index 0179b8d..c1dc146 100644 --- a/src/handlers/Registry.ts +++ b/src/handlers/Registry.ts @@ -3,24 +3,47 @@ import schema from "ponder:schema"; import { encodeLabelhash } from "@ensdomains/ensjs/utils"; import { Block } from "ponder"; import { type Hex, zeroAddress } from "viem"; +import { upsertAccount } from "../lib/db-helpers"; import { makeResolverId } from "../lib/ids"; import { ROOT_NODE, makeSubnodeNamehash } from "../lib/subname-helpers"; -import { upsertAccount } from "../lib/upserts"; /** * Initialize the ENS root node with the zeroAddress as the owner. + * Any permutation of plugins might be activated (except no plugins activated) + * and multiple plugins expect the ENS root to exist. This behavior is + * consistent with the ens-subgraph, which initializes the ENS root node in + * the same way. However, the ens-subgraph does not have independent plugins. + * In our case, we have multiple plugins that might be activated independently. + * Regardless of the permutation of active plugins, they all expect + * the ENS root to exist. + * + * This function should be used as the setup event handler for registry + * (or shadow registry) contracts. + * https://ponder.sh/docs/api-reference/indexing-functions#setup-event + * In case there are multiple plugins activated, `setupRootNode` will be + * executed multiple times. The order of execution of `setupRootNode` is + * deterministic based on the reverse order of the network names of the given + * contracts associated with the activated plugins. For example, + * if the network names were: `base`, `linea`, `mainnet`, the order of execution + * will be: `mainnet`, `linea`, `base`. + * And if the network names were: `base`, `ethereum`, `linea`, the order of + * execution will be: `linea`, `ethereum`, `base`. */ export async function setupRootNode({ context }: { context: Context }) { // ensure we have an account for the zeroAddress await upsertAccount(context, zeroAddress); // initialize the ENS root to be owned by the zeroAddress and not migrated - await context.db.insert(schema.domain).values({ - id: ROOT_NODE, - ownerId: zeroAddress, - createdAt: 0n, - isMigrated: false, - }); + await context.db + .insert(schema.domain) + .values({ + id: ROOT_NODE, + ownerId: zeroAddress, + createdAt: 0n, + isMigrated: false, + }) + // only insert the domain entity into the database if it doesn't already exist + .onConflictDoNothing(); } function isDomainEmpty(domain: typeof schema.domain.$inferSelect) { diff --git a/src/handlers/Resolver.ts b/src/handlers/Resolver.ts index a70605d..d77167a 100644 --- a/src/handlers/Resolver.ts +++ b/src/handlers/Resolver.ts @@ -2,9 +2,9 @@ import { type Context } from "ponder:registry"; import schema from "ponder:schema"; import { Log } from "ponder"; import { Hex } from "viem"; +import { upsertAccount, upsertResolver } from "../lib/db-helpers"; import { hasNullByte, uniq } from "../lib/helpers"; import { makeResolverId } from "../lib/ids"; -import { upsertAccount, upsertResolver } from "../lib/upserts"; export async function handleAddrChanged({ context, diff --git a/src/lib/upserts.ts b/src/lib/db-helpers.ts similarity index 60% rename from src/lib/upserts.ts rename to src/lib/db-helpers.ts index 467779a..aa12290 100644 --- a/src/lib/upserts.ts +++ b/src/lib/db-helpers.ts @@ -3,19 +3,19 @@ import schema from "ponder:schema"; import type { Address } from "viem"; export async function upsertAccount(context: Context, address: Address) { - return await context.db.insert(schema.account).values({ id: address }).onConflictDoNothing(); + return context.db.insert(schema.account).values({ id: address }).onConflictDoNothing(); } export async function upsertResolver( context: Context, values: typeof schema.resolver.$inferInsert, ) { - return await context.db.insert(schema.resolver).values(values).onConflictDoUpdate(values); + return context.db.insert(schema.resolver).values(values).onConflictDoUpdate(values); } export async function upsertRegistration( context: Context, values: typeof schema.registration.$inferInsert, ) { - return await context.db.insert(schema.registration).values(values).onConflictDoUpdate(values); + return context.db.insert(schema.registration).values(values).onConflictDoUpdate(values); } diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 3212e10..71bf25e 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -1,4 +1,5 @@ import type { ContractConfig } from "ponder"; +import { merge as tsDeepMerge } from "ts-deepmerge"; export const uniq = (arr: T[]): T[] => [...new Set(arr)]; @@ -16,3 +17,93 @@ export const blockConfig = ( startBlock: Math.min(Math.max(start || 0, startBlock), end || Number.MAX_SAFE_INTEGER), endBlock: end, }); + +/** + * Gets the RPC endpoint URL for a given chain ID. + * + * @param chainId the chain ID to get the RPC URL for + * @returns the URL of the RPC endpoint + */ +export const rpcEndpointUrl = (chainId: number): string => { + /** + * Reads the RPC URL for a given chain ID from the environment variable: + * RPC_URL_{chainId}. For example, for Ethereum mainnet the chainId is `1`, + * so the env variable can be set as `RPC_URL_1=https://eth.drpc.org`. + */ + const envVarName = `RPC_URL_${chainId}`; + const envVarValue = process.env[envVarName]; + + // no RPC URL provided in env var + if (!envVarValue) { + // throw an error, as the RPC URL is required and no defaults apply + throw new Error( + `Missing '${envVarName}' environment variable. The RPC URL for chainId ${chainId} is required.`, + ); + } + + try { + return new URL(envVarValue).toString(); + } catch (e) { + throw new Error( + `Invalid '${envVarName}' environment variable value: '${envVarValue}'. Please provide a valid RPC URL.`, + ); + } +}; + +// default request per second rate limit for RPC endpoints +const DEFAULT_RPC_RATE_LIMIT = 50; + +/** + * Gets the RPC request rate limit for a given chain ID. + * + * @param chainId the chain ID to get the rate limit for + * @returns the rate limit in requests per second (rps) + */ +export const rpcMaxRequestsPerSecond = (chainId: number): number => { + /** + * Reads the RPC request rate limit for a given chain ID from the environment + * variable: RPC_REQUEST_RATE_LIMIT_{chainId}. + * For example, for Ethereum mainnet the chainId is `1`, so the env variable + * can be set as `RPC_REQUEST_RATE_LIMIT_1=400`. This will set the rate limit + * for the mainnet (chainId=1) to 400 requests per second. + */ + const envVarName = `RPC_REQUEST_RATE_LIMIT_${chainId}`; + const envVarValue = process.env[envVarName]; + + // no rate limit provided in env var + if (!envVarValue) { + // apply default rate limit value + return DEFAULT_RPC_RATE_LIMIT; + } + + // otherwise + try { + // parse the rate limit value from the environment variable + const parsedEnvVarValue = parseInt(envVarValue, 10); + + if (Number.isNaN(parsedEnvVarValue) || parsedEnvVarValue <= 0) { + throw new Error(`Rate limit value must be an integer greater than 0.`); + } + + return parsedEnvVarValue; + } catch (e: any) { + throw new Error( + `Invalid '${envVarName}' environment variable value: '${envVarValue}'. ${e.message}`, + ); + } +}; + +type AnyObject = { [key: string]: any }; + +/** + * Deep merge two objects recursively. + * @param target The target object to merge into. + * @param source The source object to merge from. + * @returns The merged object. + */ +export function deepMergeRecursive( + target: T, + source: U, +): T & U { + return tsDeepMerge(target, source) as T & U; +} diff --git a/src/lib/plugin-helpers.ts b/src/lib/plugin-helpers.ts index e57005d..36063a7 100644 --- a/src/lib/plugin-helpers.ts +++ b/src/lib/plugin-helpers.ts @@ -79,5 +79,53 @@ type PluginNamespacePath = | `/${string}` | `/${string}${T}`; -/** @var the requested active plugin name (see `src/plugins` for available plugins) */ -export const ACTIVE_PLUGIN = process.env.ACTIVE_PLUGIN; +/** + * Returns a list of 1 or more distinct active plugins based on the `ACTIVE_PLUGINS` environment variable. + * + * The `ACTIVE_PLUGINS` environment variable is a comma-separated list of plugin + * names. The function returns the plugins that are included in the list. + * + * @param availablePlugins is a list of available plugins + * @returns the active plugins + */ +export function getActivePlugins( + availablePlugins: readonly T[], +): T[] { + /** @var comma separated list of the requested plugin names (see `src/plugins` for available plugins) */ + const requestedPluginsEnvVar = process.env.ACTIVE_PLUGINS; + const requestedPlugins = requestedPluginsEnvVar ? requestedPluginsEnvVar.split(",") : []; + + if (!requestedPlugins.length) { + throw new Error("Set the ACTIVE_PLUGINS environment variable to activate one or more plugins."); + } + + // Check if the requested plugins are valid and can become active + const invalidPlugins = requestedPlugins.filter( + (requestedPlugin) => + !availablePlugins.some((availablePlugin) => availablePlugin.ownedName === requestedPlugin), + ); + + if (invalidPlugins.length) { + // Throw an error if there are invalid plugins + throw new Error( + `Invalid plugin names found: ${invalidPlugins.join( + ", ", + )}. Please check the ACTIVE_PLUGINS environment variable.`, + ); + } + + const uniquePluginsToActivate = availablePlugins.reduce((acc, plugin) => { + // Only add the plugin if it's not already in the map + if (acc.has(plugin.ownedName) === false) { + acc.set(plugin.ownedName, plugin); + } + return acc; + }, new Map()); + + return Array.from(uniquePluginsToActivate.values()); +} + +// Helper type to merge multiple types into one +export type MergedTypes = (T extends any ? (x: T) => void : never) extends (x: infer R) => void + ? R + : never; diff --git a/src/plugins/README.md b/src/plugins/README.md index 991fc8f..c21aa33 100644 --- a/src/plugins/README.md +++ b/src/plugins/README.md @@ -1,8 +1,8 @@ # Indexer plugins -This directory contains plugins which allow defining subname-specific processing of blockchain events. -Only one plugin can be active at a time. Use the `ACTIVE_PLUGIN` env variable to select the active plugin, for example: +This directory contains plugins that define subname-specific processing of blockchain events. +One or more plugins are activated at a time. Use the `ACTIVE_PLUGINS` env variable to select the active plugins, for example: ``` -ACTIVE_PLUGIN=base.eth +ACTIVE_PLUGINS=eth,base.eth,linea.eth ``` \ No newline at end of file diff --git a/src/plugins/base.eth/handlers/Registrar.ts b/src/plugins/base.eth/handlers/Registrar.ts index 0173295..c26f7cc 100644 --- a/src/plugins/base.eth/handlers/Registrar.ts +++ b/src/plugins/base.eth/handlers/Registrar.ts @@ -1,8 +1,9 @@ import { ponder } from "ponder:registry"; import schema from "ponder:schema"; +import { zeroAddress } from "viem"; import { makeRegistrarHandlers } from "../../../handlers/Registrar"; +import { upsertAccount } from "../../../lib/db-helpers"; import { makeSubnodeNamehash, tokenIdToLabel } from "../../../lib/subname-helpers"; -import { upsertAccount } from "../../../lib/upserts"; import { ownedName, pluginNamespace } from "../ponder.config"; const { @@ -21,41 +22,52 @@ export default function () { ); ponder.on(pluginNamespace("BaseRegistrar:NameRegistered"), async ({ context, event }) => { - // base has 'preminted' names via Registrar#registerOnly, which explicitly does not update Registry. - // this breaks a subgraph assumption, as it expects a domain to exist (via Registry:NewOwner) before - // any Registrar:NameRegistered events. in the future we will likely happily upsert domains, but - // in order to avoid prematurely drifting from subgraph equivalancy, we upsert the domain here, + await upsertAccount(context, event.args.owner); + // Base has 'preminted' names via Registrar#registerOnly, which explicitly + // does not update the Registry. This breaks a subgraph assumption, as it + // expects a domain to exist (via Registry:NewOwner) before any + // Registrar:NameRegistered events. We insert the domain entity here, // allowing the base indexer to progress. - const { id, owner } = event.args; - const label = tokenIdToLabel(id); - const node = makeSubnodeNamehash(ownedSubnameNode, label); - await upsertAccount(context, owner); - await context.db - .insert(schema.domain) - .values({ - id: node, - ownerId: owner, - createdAt: event.block.timestamp, - }) - .onConflictDoNothing(); + await context.db.insert(schema.domain).values({ + id: makeSubnodeNamehash(ownedSubnameNode, tokenIdToLabel(event.args.id)), + ownerId: event.args.owner, + createdAt: event.block.timestamp, + }); // after ensuring the domain exists, continue with the standard handler return handleNameRegistered({ context, event }); }); ponder.on(pluginNamespace("BaseRegistrar:NameRenewed"), handleNameRenewed); - // Base's BaseRegistrar uses `id` instead of `tokenId` ponder.on(pluginNamespace("BaseRegistrar:Transfer"), async ({ context, event }) => { - return await handleNameTransferred({ + // base.eth's BaseRegistrar uses `id` instead of `tokenId` + const { id: tokenId, from, to } = event.args; + + if (event.args.from === zeroAddress) { + // The ens-subgraph `handleNameTransferred` handler implementation + // assumes an indexed record for the domain already exists. However, + // when an NFT token is minted (transferred from `0x0` address), + // there's no domain entity in the database yet. That very first transfer + // event has to ensure the domain entity for the requested token ID + // has been inserted into the database. This is a workaround to meet + // expectations of the `handleNameTransferred` subgraph implementation. + await context.db.insert(schema.domain).values({ + id: makeSubnodeNamehash(ownedSubnameNode, tokenIdToLabel(tokenId)), + ownerId: to, + createdAt: event.block.timestamp, + }); + } + + await handleNameTransferred({ context, - args: { ...event.args, tokenId: event.args.id }, + args: { from, to, tokenId }, }); }); ponder.on(pluginNamespace("EARegistrarController:NameRegistered"), async ({ context, event }) => { // TODO: registration expected here - return handleNameRegisteredByController({ + await handleNameRegisteredByController({ context, args: { ...event.args, cost: 0n }, }); @@ -64,14 +76,14 @@ export default function () { ponder.on(pluginNamespace("RegistrarController:NameRegistered"), async ({ context, event }) => { // TODO: registration expected here - return handleNameRegisteredByController({ + await handleNameRegisteredByController({ context, args: { ...event.args, cost: 0n }, }); }); ponder.on(pluginNamespace("RegistrarController:NameRenewed"), async ({ context, event }) => { - return handleNameRenewedByController({ + await handleNameRenewedByController({ context, args: { ...event.args, cost: 0n }, }); diff --git a/src/plugins/base.eth/ponder.config.ts b/src/plugins/base.eth/ponder.config.ts index 6340a2c..aadd96e 100644 --- a/src/plugins/base.eth/ponder.config.ts +++ b/src/plugins/base.eth/ponder.config.ts @@ -1,7 +1,8 @@ -import { createConfig, factory } from "ponder"; +import { type ContractConfig, createConfig, factory } from "ponder"; import { http, getAbiItem } from "viem"; import { base } from "viem/chains"; +import { blockConfig, rpcEndpointUrl, rpcMaxRequestsPerSecond } from "../../lib/helpers"; import { createPluginNamespace } from "../../lib/plugin-helpers"; import { BaseRegistrar } from "./abis/BaseRegistrar"; import { EarlyAccessRegistrarController } from "./abis/EARegistrarController"; @@ -13,11 +14,17 @@ export const ownedName = "base.eth" as const; export const pluginNamespace = createPluginNamespace(ownedName); +// constrain indexing between the following start/end blocks +// https://ponder.sh/0_6/docs/contracts-and-networks#block-range +const START_BLOCK: ContractConfig["startBlock"] = undefined; +const END_BLOCK: ContractConfig["endBlock"] = undefined; + export const config = createConfig({ networks: { base: { chainId: base.id, - transport: http(process.env[`RPC_URL_${base.id}`]), + transport: http(rpcEndpointUrl(base.id)), + maxRequestsPerSecond: rpcMaxRequestsPerSecond(base.id), }, }, contracts: { @@ -25,7 +32,7 @@ export const config = createConfig({ network: "base", abi: Registry, address: "0xb94704422c2a1e396835a571837aa5ae53285a95", - startBlock: 17571480, + ...blockConfig(START_BLOCK, 17571480, END_BLOCK), }, [pluginNamespace("Resolver")]: { network: "base", @@ -35,25 +42,25 @@ export const config = createConfig({ event: getAbiItem({ abi: Registry, name: "NewResolver" }), parameter: "resolver", }), - startBlock: 17575714, + ...blockConfig(START_BLOCK, 17575714, END_BLOCK), }, [pluginNamespace("BaseRegistrar")]: { network: "base", abi: BaseRegistrar, address: "0x03c4738Ee98aE44591e1A4A4F3CaB6641d95DD9a", - startBlock: 17571486, + ...blockConfig(START_BLOCK, 17571486, END_BLOCK), }, [pluginNamespace("EARegistrarController")]: { network: "base", abi: EarlyAccessRegistrarController, address: "0xd3e6775ed9b7dc12b205c8e608dc3767b9e5efda", - startBlock: 17575699, + ...blockConfig(START_BLOCK, 17575699, END_BLOCK), }, [pluginNamespace("RegistrarController")]: { network: "base", abi: RegistrarController, address: "0x4cCb0BB02FCABA27e82a56646E81d8c5bC4119a5", - startBlock: 18619035, + ...blockConfig(START_BLOCK, 18619035, END_BLOCK), }, }, }); diff --git a/src/plugins/eth/handlers/EthRegistrar.ts b/src/plugins/eth/handlers/EthRegistrar.ts index e864be6..58177f9 100644 --- a/src/plugins/eth/handlers/EthRegistrar.ts +++ b/src/plugins/eth/handlers/EthRegistrar.ts @@ -15,20 +15,20 @@ export default function () { ponder.on(pluginNamespace("BaseRegistrar:NameRenewed"), handleNameRenewed); ponder.on(pluginNamespace("BaseRegistrar:Transfer"), async ({ context, event }) => { - return await handleNameTransferred({ context, args: event.args }); + await handleNameTransferred({ context, args: event.args }); }); ponder.on( pluginNamespace("EthRegistrarControllerOld:NameRegistered"), async ({ context, event }) => { // the old registrar controller just had `cost` param - return await handleNameRegisteredByController({ context, args: event.args }); + await handleNameRegisteredByController({ context, args: event.args }); }, ); ponder.on( pluginNamespace("EthRegistrarControllerOld:NameRenewed"), async ({ context, event }) => { - return await handleNameRenewedByController({ context, args: event.args }); + await handleNameRenewedByController({ context, args: event.args }); }, ); @@ -36,7 +36,7 @@ export default function () { pluginNamespace("EthRegistrarController:NameRegistered"), async ({ context, event }) => { // the new registrar controller uses baseCost + premium to compute cost - return await handleNameRegisteredByController({ + await handleNameRegisteredByController({ context, args: { ...event.args, @@ -46,6 +46,6 @@ export default function () { }, ); ponder.on(pluginNamespace("EthRegistrarController:NameRenewed"), async ({ context, event }) => { - return await handleNameRenewedByController({ context, args: event.args }); + await handleNameRenewedByController({ context, args: event.args }); }); } diff --git a/src/plugins/eth/ponder.config.ts b/src/plugins/eth/ponder.config.ts index 0c0c490..98134ce 100644 --- a/src/plugins/eth/ponder.config.ts +++ b/src/plugins/eth/ponder.config.ts @@ -2,7 +2,7 @@ import { ContractConfig, createConfig, factory, mergeAbis } from "ponder"; import { http, getAbiItem } from "viem"; import { mainnet } from "viem/chains"; -import { blockConfig } from "../../lib/helpers"; +import { blockConfig, rpcEndpointUrl, rpcMaxRequestsPerSecond } from "../../lib/helpers"; import { createPluginNamespace } from "../../lib/plugin-helpers"; import { BaseRegistrar } from "./abis/BaseRegistrar"; import { EthRegistrarController } from "./abis/EthRegistrarController"; @@ -18,7 +18,7 @@ export const ownedName = "eth"; export const pluginNamespace = createPluginNamespace(ownedName); -// constrain the ponder indexing between the following start/end blocks +// constrain indexing between the following start/end blocks // https://ponder.sh/0_6/docs/contracts-and-networks#block-range const START_BLOCK: ContractConfig["startBlock"] = undefined; const END_BLOCK: ContractConfig["endBlock"] = undefined; @@ -27,7 +27,8 @@ export const config = createConfig({ networks: { mainnet: { chainId: mainnet.id, - transport: http(process.env[`RPC_URL_${mainnet.id}`]), + transport: http(rpcEndpointUrl(mainnet.id)), + maxRequestsPerSecond: rpcMaxRequestsPerSecond(mainnet.id), }, }, contracts: { diff --git a/src/plugins/linea.eth/handlers/EthRegistrar.ts b/src/plugins/linea.eth/handlers/EthRegistrar.ts index 33e3219..ff17d05 100644 --- a/src/plugins/linea.eth/handlers/EthRegistrar.ts +++ b/src/plugins/linea.eth/handlers/EthRegistrar.ts @@ -1,5 +1,8 @@ import { ponder } from "ponder:registry"; +import schema from "ponder:schema"; +import { zeroAddress } from "viem"; import { makeRegistrarHandlers } from "../../../handlers/Registrar"; +import { makeSubnodeNamehash, tokenIdToLabel } from "../../../lib/subname-helpers"; import { ownedName, pluginNamespace } from "../ponder.config"; const { @@ -8,6 +11,7 @@ const { handleNameRenewedByController, handleNameRenewed, handleNameTransferred, + ownedSubnameNode, } = makeRegistrarHandlers(ownedName); export default function () { @@ -15,14 +19,34 @@ export default function () { ponder.on(pluginNamespace("BaseRegistrar:NameRenewed"), handleNameRenewed); ponder.on(pluginNamespace("BaseRegistrar:Transfer"), async ({ context, event }) => { - return await handleNameTransferred({ context, args: event.args }); + const { tokenId, from, to } = event.args; + + if (event.args.from === zeroAddress) { + // The ens-subgraph `handleNameTransferred` handler implementation + // assumes an indexed record for the domain already exists. However, + // when an NFT token is minted (transferred from `0x0` address), + // there's no domain entity in the database yet. That very first transfer + // event has to ensure the domain entity for the requested token ID + // has been inserted into the database. This is a workaround to meet + // expectations of the `handleNameTransferred` subgraph implementation. + await context.db.insert(schema.domain).values({ + id: makeSubnodeNamehash(ownedSubnameNode, tokenIdToLabel(tokenId)), + ownerId: to, + createdAt: event.block.timestamp, + }); + } + + await handleNameTransferred({ + context, + args: { from, to, tokenId }, + }); }); // Linea allows the owner of the EthRegistrarController to register subnames for free ponder.on( pluginNamespace("EthRegistrarController:OwnerNameRegistered"), async ({ context, event }) => { - return handleNameRegisteredByController({ + await handleNameRegisteredByController({ context, args: { ...event.args, @@ -36,7 +60,7 @@ export default function () { ponder.on( pluginNamespace("EthRegistrarController:PohNameRegistered"), async ({ context, event }) => { - return handleNameRegisteredByController({ + await handleNameRegisteredByController({ context, args: { ...event.args, @@ -50,7 +74,7 @@ export default function () { pluginNamespace("EthRegistrarController:NameRegistered"), async ({ context, event }) => { // the new registrar controller uses baseCost + premium to compute cost - return await handleNameRegisteredByController({ + await handleNameRegisteredByController({ context, args: { ...event.args, @@ -60,6 +84,6 @@ export default function () { }, ); ponder.on(pluginNamespace("EthRegistrarController:NameRenewed"), async ({ context, event }) => { - return await handleNameRenewedByController({ context, args: event.args }); + await handleNameRenewedByController({ context, args: event.args }); }); } diff --git a/src/plugins/linea.eth/ponder.config.ts b/src/plugins/linea.eth/ponder.config.ts index 0dcaa8a..17fdb41 100644 --- a/src/plugins/linea.eth/ponder.config.ts +++ b/src/plugins/linea.eth/ponder.config.ts @@ -2,7 +2,7 @@ import { ContractConfig, createConfig, factory, mergeAbis } from "ponder"; import { http, getAbiItem } from "viem"; import { linea } from "viem/chains"; -import { blockConfig } from "../../lib/helpers"; +import { blockConfig, rpcEndpointUrl, rpcMaxRequestsPerSecond } from "../../lib/helpers"; import { createPluginNamespace } from "../../lib/plugin-helpers"; import { BaseRegistrar } from "./abis/BaseRegistrar"; import { EthRegistrarController } from "./abis/EthRegistrarController"; @@ -14,7 +14,7 @@ export const ownedName = "linea.eth"; export const pluginNamespace = createPluginNamespace(ownedName); -// constrain the ponder indexing between the following start/end blocks +// constrain indexing between the following start/end blocks // https://ponder.sh/0_6/docs/contracts-and-networks#block-range const START_BLOCK: ContractConfig["startBlock"] = undefined; const END_BLOCK: ContractConfig["endBlock"] = undefined; @@ -23,7 +23,8 @@ export const config = createConfig({ networks: { linea: { chainId: linea.id, - transport: http(process.env[`RPC_URL_${linea.id}`]), + transport: http(rpcEndpointUrl(linea.id)), + maxRequestsPerSecond: rpcMaxRequestsPerSecond(linea.id), }, }, contracts: {