diff --git a/.changeset/quiet-yaks-sleep.md b/.changeset/quiet-yaks-sleep.md new file mode 100644 index 000000000..9adcdd5f9 --- /dev/null +++ b/.changeset/quiet-yaks-sleep.md @@ -0,0 +1,7 @@ +--- +"@ensnode/ensnode-schema": minor +"@ensnode/ensnode-sdk": minor +"ensindexer": minor +--- + +Introduces a new `registrars` plugin for tracking all registrations and renewals for direct subnames of `eth`, `base.eth`, and `linea.eth`. diff --git a/apps/ensindexer/ponder/src/register-handlers.ts b/apps/ensindexer/ponder/src/register-handlers.ts index dea35e418..88f23449e 100644 --- a/apps/ensindexer/ponder/src/register-handlers.ts +++ b/apps/ensindexer/ponder/src/register-handlers.ts @@ -9,6 +9,7 @@ import { PluginName } from "@ensnode/ensnode-sdk"; import attach_protocolAccelerationHandlers from "@/plugins/protocol-acceleration/event-handlers"; import attach_ReferralHandlers from "@/plugins/referrals/event-handlers"; +import attach_RegistrarsHandlers from "@/plugins/registrars/event-handlers"; import attach_BasenamesHandlers from "@/plugins/subgraph/plugins/basenames/event-handlers"; import attach_LineanamesHandlers from "@/plugins/subgraph/plugins/lineanames/event-handlers"; import attach_SubgraphHandlers from "@/plugins/subgraph/plugins/subgraph/event-handlers"; @@ -45,6 +46,11 @@ if (config.plugins.includes(PluginName.Referrals)) { attach_ReferralHandlers(); } +// Registrars Plugin +if (config.plugins.includes(PluginName.Registrars)) { + attach_RegistrarsHandlers(); +} + // TokenScope Plugin if (config.plugins.includes(PluginName.TokenScope)) { attach_TokenscopeHandlers(); diff --git a/apps/ensindexer/src/plugins/index.ts b/apps/ensindexer/src/plugins/index.ts index 66dd86cdb..e948747c0 100644 --- a/apps/ensindexer/src/plugins/index.ts +++ b/apps/ensindexer/src/plugins/index.ts @@ -5,6 +5,7 @@ import type { MergedTypes } from "@/lib/lib-helpers"; // Core-Schema-Indepdendent Plugins import protocolAccelerationPlugin from "./protocol-acceleration/plugin"; import referralsPlugin from "./referrals/plugin"; +import registrarsPlugin from "./registrars/plugin"; // Subgraph-Schema Core Plugins import basenamesPlugin from "./subgraph/plugins/basenames/plugin"; import lineaNamesPlugin from "./subgraph/plugins/lineanames/plugin"; @@ -20,6 +21,7 @@ export const ALL_PLUGINS = [ tokenScopePlugin, protocolAccelerationPlugin, referralsPlugin, + registrarsPlugin, ] as const; /** diff --git a/apps/ensindexer/src/plugins/registrars/README.md b/apps/ensindexer/src/plugins/registrars/README.md new file mode 100644 index 000000000..6f2f8f60b --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/README.md @@ -0,0 +1,9 @@ +# `registrars` plugin for ENSIndexer + +This plugin enables tracking all registrations and renewals that ever happened for subregistries managing the following: +- direct subnames of the Ethnames registrar managed name (ex: `eth` for all namespaces). +- direct subnames of the Basenames registrar managed name (ex: for mainnet `base.eth` but varies for other namespaces). +- direct subnames of the Lineanames registrar managed name (ex: for mainnet `linea.eth` but varies for other namespaces). + +Additionally indexes: +- All ENS Referrals (for Registrar Controllers supporting ENS Referral Programs). diff --git a/apps/ensindexer/src/plugins/registrars/basenames/handlers/Basenames_Registrar.ts b/apps/ensindexer/src/plugins/registrars/basenames/handlers/Basenames_Registrar.ts new file mode 100644 index 000000000..74e6ec3ac --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/basenames/handlers/Basenames_Registrar.ts @@ -0,0 +1,124 @@ +import config from "@/config"; + +import { ponder } from "ponder:registry"; +import { namehash } from "viem/ens"; + +import { DatasourceNames } from "@ensnode/datasources"; +import { + type BlockRef, + bigIntToNumber, + makeSubdomainNode, + PluginName, + type Subregistry, +} from "@ensnode/ensnode-sdk"; + +import { getDatasourceContract } from "@/lib/datasource-helpers"; +import { namespaceContract } from "@/lib/plugin-helpers"; + +import { + handleRegistrarEventRegistration, + handleRegistrarEventRenewal, +} from "../../shared/lib/registrar-events"; +import { upsertSubregistry } from "../../shared/lib/subregistry"; +import { getRegistrarManagedName, tokenIdToLabelHash } from "../lib/registrar-helpers"; + +/** + * Registers event handlers with Ponder. + */ +export default function () { + const pluginName = PluginName.Registrars; + const parentNode = namehash(getRegistrarManagedName(config.namespace)); + + const subregistryId = getDatasourceContract( + config.namespace, + DatasourceNames.Basenames, + "BaseRegistrar", + ); + const subregistry = { + subregistryId, + node: parentNode, + } satisfies Subregistry; + + // support NameRegisteredWithRecord for BaseRegistrar as it used by Base's RegistrarControllers + ponder.on( + namespaceContract(pluginName, "Basenames_BaseRegistrar:NameRegisteredWithRecord"), + async ({ context, event }) => { + const id = event.id; + const labelHash = tokenIdToLabelHash(event.args.id); + const node = makeSubdomainNode(labelHash, parentNode); + const registrant = event.transaction.from; + const expiresAt = bigIntToNumber(event.args.expires); + const block = { + number: bigIntToNumber(event.block.number), + timestamp: bigIntToNumber(event.block.timestamp), + } satisfies BlockRef; + const transactionHash = event.transaction.hash; + + await upsertSubregistry(context, subregistry); + + await handleRegistrarEventRegistration(context, { + id, + subregistryId, + node, + registrant, + expiresAt, + block, + transactionHash, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "Basenames_BaseRegistrar:NameRegistered"), + async ({ context, event }) => { + const id = event.id; + const labelHash = tokenIdToLabelHash(event.args.id); + const node = makeSubdomainNode(labelHash, parentNode); + const registrant = event.transaction.from; + const expiresAt = bigIntToNumber(event.args.expires); + const block = { + number: bigIntToNumber(event.block.number), + timestamp: bigIntToNumber(event.block.timestamp), + } satisfies BlockRef; + const transactionHash = event.transaction.hash; + + await upsertSubregistry(context, subregistry); + + await handleRegistrarEventRegistration(context, { + id, + subregistryId, + node, + registrant, + expiresAt, + block, + transactionHash, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "Basenames_BaseRegistrar:NameRenewed"), + async ({ context, event }) => { + const id = event.id; + const labelHash = tokenIdToLabelHash(event.args.id); + const node = makeSubdomainNode(labelHash, parentNode); + const registrant = event.transaction.from; + const expiresAt = bigIntToNumber(event.args.expires); + const block = { + number: bigIntToNumber(event.block.number), + timestamp: bigIntToNumber(event.block.timestamp), + } satisfies BlockRef; + const transactionHash = event.transaction.hash; + + await handleRegistrarEventRenewal(context, { + id, + subregistryId, + node, + registrant, + expiresAt, + block, + transactionHash, + }); + }, + ); +} diff --git a/apps/ensindexer/src/plugins/registrars/basenames/handlers/Basenames_RegistrarController.ts b/apps/ensindexer/src/plugins/registrars/basenames/handlers/Basenames_RegistrarController.ts new file mode 100644 index 000000000..35a45b610 --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/basenames/handlers/Basenames_RegistrarController.ts @@ -0,0 +1,161 @@ +import config from "@/config"; + +import { ponder } from "ponder:registry"; +import { namehash } from "viem/ens"; + +import { DatasourceNames } from "@ensnode/datasources"; +import { + makeSubdomainNode, + PluginName, + type RegistrarActionPricingUnknown, + type RegistrarActionReferralNotApplicable, +} from "@ensnode/ensnode-sdk"; + +import { getDatasourceContract } from "@/lib/datasource-helpers"; +import { namespaceContract } from "@/lib/plugin-helpers"; + +import { handleRegistrarControllerEvent } from "../../shared/lib/registrar-controller-events"; +import { getRegistrarManagedName } from "../lib/registrar-helpers"; + +/** + * Registers event handlers with Ponder. + */ +export default function () { + const pluginName = PluginName.Registrars; + const parentNode = namehash(getRegistrarManagedName(config.namespace)); + + const subregistryId = getDatasourceContract( + config.namespace, + DatasourceNames.Basenames, + "BaseRegistrar", + ); + + /** + * No Registrar Controller for Basenames implements premiums or + * emits distinct baseCost or premium (as opposed to just a simple price) + * in events. + * + * TODO: [Index the pricing data for "logical registrar actions" for Basenames.](https://github.com/namehash/ensnode/issues/1256) + */ + const pricing = { + baseCost: null, + premium: null, + total: null, + } satisfies RegistrarActionPricingUnknown; + + /** + * No Registrar Controller for Basenames implements referrals or + * emits a referrer in events. + */ + const referral = { + encodedReferrer: null, + decodedReferrer: null, + } satisfies RegistrarActionReferralNotApplicable; + + /** + * Basenames_EARegistrarController Event Handlers + */ + + ponder.on( + namespaceContract(pluginName, "Basenames_EARegistrarController:NameRegistered"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.label; // this field is the labelhash, not the label + const node = makeSubdomainNode(labelHash, parentNode); + const transactionHash = event.transaction.hash; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); + + /** + * Basenames_RegistrarController Event Handlers + */ + + ponder.on( + namespaceContract(pluginName, "Basenames_RegistrarController:NameRegistered"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.label; // this field is the labelhash, not the label + const node = makeSubdomainNode(labelHash, parentNode); + const transactionHash = event.transaction.hash; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "Basenames_RegistrarController:NameRenewed"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.label; // this field is the labelhash, not the label + const node = makeSubdomainNode(labelHash, parentNode); + const transactionHash = event.transaction.hash; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); + + /** + * Basenames_UpgradeableRegistrarController Event Handlers + */ + + ponder.on( + namespaceContract(pluginName, "Basenames_UpgradeableRegistrarController:NameRegistered"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.label; // this field is the labelhash, not the label + const node = makeSubdomainNode(labelHash, parentNode); + const transactionHash = event.transaction.hash; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "Basenames_UpgradeableRegistrarController:NameRenewed"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.label; // this field is the labelhash, not the label + const node = makeSubdomainNode(labelHash, parentNode); + const transactionHash = event.transaction.hash; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); +} diff --git a/apps/ensindexer/src/plugins/registrars/basenames/lib/registrar-helpers.ts b/apps/ensindexer/src/plugins/registrars/basenames/lib/registrar-helpers.ts new file mode 100644 index 000000000..2345b4ff7 --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/basenames/lib/registrar-helpers.ts @@ -0,0 +1,38 @@ +import type { ENSNamespaceId } from "@ensnode/datasources"; +import { type LabelHash, uint256ToHex32 } from "@ensnode/ensnode-sdk"; + +import type { RegistrarManagedName } from "@/lib/types"; + +/** + * When direct subnames of Basenames are registered through + * the Basenames RegistrarController contract, + * an ERC721 NFT is minted that tokenizes ownership of the registration. + * The minted NFT will be assigned a unique tokenId represented as + * uint256(labelhash(label)) where label is the direct subname of + * the Basename that was registered. + * https://github.com/base/basenames/blob/1b5c1ad/src/L2/RegistrarController.sol#L488 + */ +export function tokenIdToLabelHash(tokenId: bigint): LabelHash { + return uint256ToHex32(tokenId); +} + +/** + * Get registrar managed name for `basenames` subregistry for selected ENS namespace. + * + * @param namespaceId + * @returns registrar managed name + * @throws an error when no registrar managed name could be returned + */ +export function getRegistrarManagedName(namespaceId: ENSNamespaceId): RegistrarManagedName { + switch (namespaceId) { + case "mainnet": + return "base.eth"; + case "sepolia": + return "basetest.eth"; + case "holesky": + case "ens-test-env": + throw new Error( + `No registrar managed name is known for the 'basenames' subregistry within the "${namespaceId}" namespace.`, + ); + } +} diff --git a/apps/ensindexer/src/plugins/registrars/ethnames/handlers/Ethnames_Registrar.ts b/apps/ensindexer/src/plugins/registrars/ethnames/handlers/Ethnames_Registrar.ts new file mode 100644 index 000000000..808690960 --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/ethnames/handlers/Ethnames_Registrar.ts @@ -0,0 +1,95 @@ +import config from "@/config"; + +import { ponder } from "ponder:registry"; +import { namehash } from "viem/ens"; + +import { DatasourceNames } from "@ensnode/datasources"; +import { + type BlockRef, + bigIntToNumber, + makeSubdomainNode, + PluginName, + type Subregistry, +} from "@ensnode/ensnode-sdk"; + +import { getDatasourceContract } from "@/lib/datasource-helpers"; +import { namespaceContract } from "@/lib/plugin-helpers"; + +import { + handleRegistrarEventRegistration, + handleRegistrarEventRenewal, +} from "../../shared/lib/registrar-events"; +import { upsertSubregistry } from "../../shared/lib/subregistry"; +import { getRegistrarManagedName, tokenIdToLabelHash } from "../lib/registrar-helpers"; + +/** + * Registers event handlers with Ponder. + */ +export default function () { + const pluginName = PluginName.Registrars; + const parentNode = namehash(getRegistrarManagedName(config.namespace)); + + const subregistryId = getDatasourceContract( + config.namespace, + DatasourceNames.ENSRoot, + "BaseRegistrar", + ); + const subregistry = { + subregistryId, + node: parentNode, + } satisfies Subregistry; + + ponder.on( + namespaceContract(pluginName, "Ethnames_BaseRegistrar:NameRegistered"), + async ({ context, event }) => { + const id = event.id; + const labelHash = tokenIdToLabelHash(event.args.id); + const node = makeSubdomainNode(labelHash, parentNode); + const registrant = event.transaction.from; + const expiresAt = bigIntToNumber(event.args.expires); + const block = { + number: bigIntToNumber(event.block.number), + timestamp: bigIntToNumber(event.block.timestamp), + } satisfies BlockRef; + const transactionHash = event.transaction.hash; + + await upsertSubregistry(context, subregistry); + + await handleRegistrarEventRegistration(context, { + id, + subregistryId, + node, + registrant, + expiresAt, + block, + transactionHash, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "Ethnames_BaseRegistrar:NameRenewed"), + async ({ context, event }) => { + const id = event.id; + const labelHash = tokenIdToLabelHash(event.args.id); + const node = makeSubdomainNode(labelHash, parentNode); + const registrant = event.transaction.from; + const expiresAt = bigIntToNumber(event.args.expires); + const block = { + number: bigIntToNumber(event.block.number), + timestamp: bigIntToNumber(event.block.timestamp), + } satisfies BlockRef; + const transactionHash = event.transaction.hash; + + await handleRegistrarEventRenewal(context, { + id, + subregistryId, + node, + registrant, + expiresAt, + block, + transactionHash, + }); + }, + ); +} diff --git a/apps/ensindexer/src/plugins/registrars/ethnames/handlers/Ethnames_RegistrarController.ts b/apps/ensindexer/src/plugins/registrars/ethnames/handlers/Ethnames_RegistrarController.ts new file mode 100644 index 000000000..5d58e5f13 --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/ethnames/handlers/Ethnames_RegistrarController.ts @@ -0,0 +1,304 @@ +import config from "@/config"; + +import { ponder } from "ponder:registry"; +import { namehash } from "viem"; + +import { DatasourceNames } from "@ensnode/datasources"; +import { + addPrices, + decodeEncodedReferrer, + makeSubdomainNode, + PluginName, + priceEth, + type RegistrarActionPricingAvailable, + type RegistrarActionReferralAvailable, + type RegistrarActionReferralNotApplicable, +} from "@ensnode/ensnode-sdk"; + +import { getDatasourceContract } from "@/lib/datasource-helpers"; +import { namespaceContract } from "@/lib/plugin-helpers"; + +import { handleRegistrarControllerEvent } from "../../shared/lib/registrar-controller-events"; +import { getRegistrarManagedName } from "../lib/registrar-helpers"; + +/** + * Registers event handlers with Ponder. + */ +export default function () { + const pluginName = PluginName.Registrars; + const parentNode = namehash(getRegistrarManagedName(config.namespace)); + + const subregistryId = getDatasourceContract( + config.namespace, + DatasourceNames.ENSRoot, + "BaseRegistrar", + ); + + /** + * Ethnames_LegacyEthRegistrarController Event Handlers + */ + + ponder.on( + namespaceContract(pluginName, "Ethnames_LegacyEthRegistrarController:NameRegistered"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.label; // this field is the labelhash, not the label + const node = makeSubdomainNode(labelHash, parentNode); + + /** + * Ethnames_LegacyEthRegistrarController does not implement premiums, + * however, it implements base cost. + */ + const baseCost = priceEth(event.args.cost); + const premium = priceEth(0n); + const total = baseCost; + const pricing = { + baseCost, + premium, + total, + } satisfies RegistrarActionPricingAvailable; + + /** + * Ethnames_LegacyEthRegistrarController does not implement referrals or + * emits a referrer in events. + */ + const referral = { + encodedReferrer: null, + decodedReferrer: null, + } satisfies RegistrarActionReferralNotApplicable; + + const transactionHash = event.transaction.hash; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "Ethnames_LegacyEthRegistrarController:NameRenewed"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.label; // this field is the labelhash, not the label + const node = makeSubdomainNode(labelHash, parentNode); + + /** + * Ethnames_LegacyEthRegistrarController does not implement premiums, + * however, it implements base cost. + * + * Premium for renewals is always 0 anyway. + */ + const baseCost = priceEth(event.args.cost); + const premium = priceEth(0n); + const total = baseCost; + const pricing = { + baseCost, + premium, + total, + } satisfies RegistrarActionPricingAvailable; + + /** + * Ethnames_LegacyEthRegistrarController does not implement referrals or + * emits a referrer in events. + */ + const referral = { + encodedReferrer: null, + decodedReferrer: null, + } satisfies RegistrarActionReferralNotApplicable; + + const transactionHash = event.transaction.hash; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); + + /** + * Ethnames_WrappedEthRegistrarController Event Handlers + */ + + ponder.on( + namespaceContract(pluginName, "Ethnames_WrappedEthRegistrarController:NameRegistered"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.label; // this field is the labelhash, not the label + const node = makeSubdomainNode(labelHash, parentNode); + const transactionHash = event.transaction.hash; + + /** + * Ethnames_WrappedEthRegistrarController implements premiums, and base cost. + */ + const baseCost = priceEth(event.args.baseCost); + const premium = priceEth(event.args.premium); + const total = addPrices(baseCost, premium); + const pricing = { + baseCost, + premium, + total, + } satisfies RegistrarActionPricingAvailable; + + /** + * Ethnames_WrappedEthRegistrarController does not implement referrals or + * emits a referrer in events. + */ + const referral = { + encodedReferrer: null, + decodedReferrer: null, + } satisfies RegistrarActionReferralNotApplicable; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "Ethnames_WrappedEthRegistrarController:NameRenewed"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.label; // this field is the labelhash, not the label + const node = makeSubdomainNode(labelHash, parentNode); + const transactionHash = event.transaction.hash; + + /** + * Ethnames_WrappedEthRegistrarController implements premiums, and base cost. + * + * Premium for renewals is always 0 anyway. + */ + const baseCost = priceEth(event.args.cost); + const premium = priceEth(0n); + const total = baseCost; + const pricing = { + baseCost, + premium, + total, + } satisfies RegistrarActionPricingAvailable; + + /** + * Ethnames_WrappedEthRegistrarController does not implement referrals or + * emits a referrer in events. + */ + const referral = { + encodedReferrer: null, + decodedReferrer: null, + } satisfies RegistrarActionReferralNotApplicable; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); + + /** + * Ethnames_UnwrappedEthRegistrarController Event Handlers + */ + + ponder.on( + namespaceContract(pluginName, "Ethnames_UnwrappedEthRegistrarController:NameRegistered"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.labelhash; + const node = makeSubdomainNode(labelHash, parentNode); + const transactionHash = event.transaction.hash; + + /** + * Ethnames_UnwrappedEthRegistrarController implements premiums, and base cost. + */ + const baseCost = priceEth(event.args.baseCost); + const premium = priceEth(event.args.premium); + const total = addPrices(baseCost, premium); + const pricing = { + baseCost, + premium, + total, + } satisfies RegistrarActionPricingAvailable; + + /** + * Ethnames_UnwrappedEthRegistrarController implements referrals and + * emits a referrer in events. + */ + const encodedReferrer = event.args.referrer; + const decodedReferrer = decodeEncodedReferrer(encodedReferrer); + + const referral = { + encodedReferrer, + decodedReferrer, + } satisfies RegistrarActionReferralAvailable; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "Ethnames_UnwrappedEthRegistrarController:NameRenewed"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.labelhash; + const node = makeSubdomainNode(labelHash, parentNode); + const transactionHash = event.transaction.hash; + + /** + * Ethnames_UnwrappedEthRegistrarController implements premiums, and base cost. + * + * Premium for renewals is always 0 anyway. + */ + const baseCost = priceEth(event.args.cost); + const premium = priceEth(0n); + const total = baseCost; + const pricing = { + baseCost, + premium, + total, + } satisfies RegistrarActionPricingAvailable; + + /** + * Ethnames_UnwrappedEthRegistrarController implements referrals and + * emits a referrer in events. + */ + const encodedReferrer = event.args.referrer; + const decodedReferrer = decodeEncodedReferrer(encodedReferrer); + + const referral = { + encodedReferrer, + decodedReferrer, + } satisfies RegistrarActionReferralAvailable; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); +} diff --git a/apps/ensindexer/src/plugins/registrars/ethnames/lib/registrar-helpers.ts b/apps/ensindexer/src/plugins/registrars/ethnames/lib/registrar-helpers.ts new file mode 100644 index 000000000..0ca10b56c --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/ethnames/lib/registrar-helpers.ts @@ -0,0 +1,33 @@ +import type { ENSNamespaceId } from "@ensnode/datasources"; +import { type LabelHash, uint256ToHex32 } from "@ensnode/ensnode-sdk"; + +import type { RegistrarManagedName } from "@/lib/types"; + +/** + * When direct subnames of Ethnames are registered through + * the Ethnames ETHRegistrarController contract, + * an ERC721 NFT is minted that tokenizes ownership of the registration. + * The minted NFT will be assigned a unique tokenId which is + * uint256(labelhash(label)) where label is the direct subname of + * the Ethname that was registered. + * https://github.com/ensdomains/ens-contracts/blob/db613bc/contracts/ethregistrar/ETHRegistrarController.sol#L215 + */ +export function tokenIdToLabelHash(tokenId: bigint): LabelHash { + return uint256ToHex32(tokenId); +} + +/** + * Get the registrar managed name for the Ethnames subregistry for the selected ENS namespace. + * + * @param namespaceId + * @returns registrar managed name + */ +export function getRegistrarManagedName(namespaceId: ENSNamespaceId): RegistrarManagedName { + switch (namespaceId) { + case "mainnet": + case "sepolia": + case "holesky": + case "ens-test-env": + return "eth"; + } +} diff --git a/apps/ensindexer/src/plugins/registrars/event-handlers.ts b/apps/ensindexer/src/plugins/registrars/event-handlers.ts new file mode 100644 index 000000000..d9b306224 --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/event-handlers.ts @@ -0,0 +1,17 @@ +import attach_Basenames_Registrars from "./basenames/handlers/Basenames_Registrar"; +import attach_Basenames_RegistrarControllers from "./basenames/handlers/Basenames_RegistrarController"; +import attach_Ethnames_Registrars from "./ethnames/handlers/Ethnames_Registrar"; +import attach_Ethnames_RegistrarControllers from "./ethnames/handlers/Ethnames_RegistrarController"; +import attach_Lineanames_Registrars from "./lineanames/handlers/Lineanames_Registrar"; +import attach_Lineanames_RegistrarControllers from "./lineanames/handlers/Lineanames_RegistrarController"; + +export default function () { + attach_Ethnames_Registrars(); + attach_Ethnames_RegistrarControllers(); + + attach_Basenames_Registrars(); + attach_Basenames_RegistrarControllers(); + + attach_Lineanames_Registrars(); + attach_Lineanames_RegistrarControllers(); +} diff --git a/apps/ensindexer/src/plugins/registrars/lineanames/handlers/Lineanames_Registrar.ts b/apps/ensindexer/src/plugins/registrars/lineanames/handlers/Lineanames_Registrar.ts new file mode 100644 index 000000000..75044a8b0 --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/lineanames/handlers/Lineanames_Registrar.ts @@ -0,0 +1,95 @@ +import config from "@/config"; + +import { ponder } from "ponder:registry"; +import { namehash } from "viem/ens"; + +import { DatasourceNames } from "@ensnode/datasources"; +import { + type BlockRef, + bigIntToNumber, + makeSubdomainNode, + PluginName, + type Subregistry, +} from "@ensnode/ensnode-sdk"; + +import { getDatasourceContract } from "@/lib/datasource-helpers"; +import { namespaceContract } from "@/lib/plugin-helpers"; + +import { + handleRegistrarEventRegistration, + handleRegistrarEventRenewal, +} from "../../shared/lib/registrar-events"; +import { upsertSubregistry } from "../../shared/lib/subregistry"; +import { getRegistrarManagedName, tokenIdToLabelHash } from "../lib/registrar-helpers"; + +/** + * Registers event handlers with Ponder. + */ +export default function () { + const pluginName = PluginName.Registrars; + const parentNode = namehash(getRegistrarManagedName(config.namespace)); + + const subregistryId = getDatasourceContract( + config.namespace, + DatasourceNames.Lineanames, + "BaseRegistrar", + ); + const subregistry = { + subregistryId, + node: parentNode, + } satisfies Subregistry; + + ponder.on( + namespaceContract(pluginName, "Lineanames_BaseRegistrar:NameRegistered"), + async ({ context, event }) => { + const id = event.id; + const labelHash = tokenIdToLabelHash(event.args.id); + const node = makeSubdomainNode(labelHash, parentNode); + const registrant = event.transaction.from; + const expiresAt = bigIntToNumber(event.args.expires); + const block = { + number: bigIntToNumber(event.block.number), + timestamp: bigIntToNumber(event.block.timestamp), + } satisfies BlockRef; + const transactionHash = event.transaction.hash; + + await upsertSubregistry(context, subregistry); + + await handleRegistrarEventRegistration(context, { + id, + subregistryId, + node, + registrant, + expiresAt, + block, + transactionHash, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "Lineanames_BaseRegistrar:NameRenewed"), + async ({ context, event }) => { + const id = event.id; + const labelHash = tokenIdToLabelHash(event.args.id); + const node = makeSubdomainNode(labelHash, parentNode); + const registrant = event.transaction.from; + const expiresAt = bigIntToNumber(event.args.expires); + const block = { + number: bigIntToNumber(event.block.number), + timestamp: bigIntToNumber(event.block.timestamp), + } satisfies BlockRef; + const transactionHash = event.transaction.hash; + + await handleRegistrarEventRenewal(context, { + id, + subregistryId, + node, + registrant, + expiresAt, + block, + transactionHash, + }); + }, + ); +} diff --git a/apps/ensindexer/src/plugins/registrars/lineanames/handlers/Lineanames_RegistrarController.ts b/apps/ensindexer/src/plugins/registrars/lineanames/handlers/Lineanames_RegistrarController.ts new file mode 100644 index 000000000..1ea18bb8c --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/lineanames/handlers/Lineanames_RegistrarController.ts @@ -0,0 +1,163 @@ +import config from "@/config"; + +import { ponder } from "ponder:registry"; +import { namehash } from "viem/ens"; + +import { DatasourceNames } from "@ensnode/datasources"; +import { + addPrices, + makeSubdomainNode, + PluginName, + priceEth, + type RegistrarActionPricingAvailable, + type RegistrarActionReferralNotApplicable, +} from "@ensnode/ensnode-sdk"; + +import { getDatasourceContract } from "@/lib/datasource-helpers"; +import { namespaceContract } from "@/lib/plugin-helpers"; + +import { getRegistrarManagedName } from "../../lineanames/lib/registrar-helpers"; +import { handleRegistrarControllerEvent } from "../../shared/lib/registrar-controller-events"; + +/** + * Registers event handlers with Ponder. + */ +export default function () { + const pluginName = PluginName.Registrars; + const parentNode = namehash(getRegistrarManagedName(config.namespace)); + + const subregistryId = getDatasourceContract( + config.namespace, + DatasourceNames.Lineanames, + "BaseRegistrar", + ); + + /** + * No Registrar Controller for Lineanames implements referrals or + * emits a referrer in events. + */ + const referral = { + encodedReferrer: null, + decodedReferrer: null, + } satisfies RegistrarActionReferralNotApplicable; + + /** + * Lineanames_EthRegistrarController Event Handlers + */ + + ponder.on( + namespaceContract(pluginName, "Lineanames_EthRegistrarController:OwnerNameRegistered"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.label; // this field is the labelhash, not the label + const node = makeSubdomainNode(labelHash, parentNode); + const transactionHash = event.transaction.hash; + + /** + * The `OwnerNameRegistered` event emitted by + * `Lineanames_EthRegistrarController` contract is akin to + * the `NameRegistered` event with `baseCost` of `0` and `premium` of `0`. + */ + const pricing = { + baseCost: priceEth(0n), + premium: priceEth(0n), + total: priceEth(0n), + } satisfies RegistrarActionPricingAvailable; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "Lineanames_EthRegistrarController:PohNameRegistered"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.label; // this field is the labelhash, not the label + const node = makeSubdomainNode(labelHash, parentNode); + const transactionHash = event.transaction.hash; + + /** + * The `PohNameRegistered` event emitted by + * `Lineanames_EthRegistrarController` contract is akin to + * the `NameRegistered` event with `baseCost` of `0` and `premium` of `0`. + */ + const pricing = { + baseCost: priceEth(0n), + premium: priceEth(0n), + total: priceEth(0n), + } satisfies RegistrarActionPricingAvailable; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "Lineanames_EthRegistrarController:NameRegistered"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.label; // this field is the labelhash, not the label + const node = makeSubdomainNode(labelHash, parentNode); + const transactionHash = event.transaction.hash; + + const baseCost = priceEth(event.args.baseCost); + const premium = priceEth(event.args.premium); + const total = addPrices(baseCost, premium); + const pricing = { + baseCost, + premium, + total, + } satisfies RegistrarActionPricingAvailable; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); + + ponder.on( + namespaceContract(pluginName, "Lineanames_EthRegistrarController:NameRenewed"), + async ({ context, event }) => { + const id = event.id; + const labelHash = event.args.label; // this field is the labelhash, not the label + const node = makeSubdomainNode(labelHash, parentNode); + const transactionHash = event.transaction.hash; + + const baseCost = priceEth(event.args.cost); + const premium = priceEth(0n); // premium for renewals is always 0 + const total = baseCost; + const pricing = { + baseCost, + premium, + total, + } satisfies RegistrarActionPricingAvailable; + + await handleRegistrarControllerEvent(context, { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }); + }, + ); +} diff --git a/apps/ensindexer/src/plugins/registrars/lineanames/lib/registrar-helpers.ts b/apps/ensindexer/src/plugins/registrars/lineanames/lib/registrar-helpers.ts new file mode 100644 index 000000000..13bba68f3 --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/lineanames/lib/registrar-helpers.ts @@ -0,0 +1,38 @@ +import type { ENSNamespaceId } from "@ensnode/datasources"; +import { type LabelHash, uint256ToHex32 } from "@ensnode/ensnode-sdk"; + +import type { RegistrarManagedName } from "@/lib/types"; + +/** + * When direct subnames of Lineanames are registered through + * the Lineanames ETHRegistrarController contract, + * an ERC721 NFT is minted that tokenizes ownership of the registration. + * The minted NFT will be assigned a unique tokenId represented as + * uint256(labelhash(label)) where label is the direct subname of + * Lineanames that was registered. + * https://github.com/Consensys/linea-ens/blob/3a4f02f/packages/linea-ens-contracts/contracts/ethregistrar/ETHRegistrarController.sol#L447 + */ +export function tokenIdToLabelHash(tokenId: bigint): LabelHash { + return uint256ToHex32(tokenId); +} + +/** + * Get registrar managed name for `lineanames` subregistry for selected ENS namespace. + * + * @param namespaceId + * @returns registrar managed name + * @throws an error when no registrar managed name could be returned + */ +export function getRegistrarManagedName(namespaceId: ENSNamespaceId): RegistrarManagedName { + switch (namespaceId) { + case "mainnet": + return "linea.eth"; + case "sepolia": + return "linea-sepolia.eth"; + case "holesky": + case "ens-test-env": + throw new Error( + `No registrar managed name is known for the 'lineanames' subregistry within the "${namespaceId}" namespace.`, + ); + } +} diff --git a/apps/ensindexer/src/plugins/registrars/plugin.ts b/apps/ensindexer/src/plugins/registrars/plugin.ts new file mode 100644 index 000000000..164b4fe44 --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/plugin.ts @@ -0,0 +1,164 @@ +/** + * The `registrars` plugin indexes data about ENS subregistries, specifically the + * registrar and registrar controller contracts that manage registrations and renewals + * for known subregistry base registrars for the following: + * - Ethnames + * - Basenames + * - Lineanames + */ + +import * as ponder from "ponder"; + +import { DatasourceNames } from "@ensnode/datasources"; +import { PluginName } from "@ensnode/ensnode-sdk"; + +import { + createPlugin, + getDatasourceAsFullyDefinedAtCompileTime, + namespaceContract, +} from "@/lib/plugin-helpers"; +import { chainConfigForContract, chainsConnectionConfig } from "@/lib/ponder-helpers"; + +const pluginName = PluginName.Registrars; + +export default createPlugin({ + name: pluginName, + requiredDatasourceNames: [ + DatasourceNames.ENSRoot, + DatasourceNames.Basenames, + DatasourceNames.Lineanames, + ], + createPonderConfig(config) { + // configure Ethnames dependencies + const ethnamesDatasource = getDatasourceAsFullyDefinedAtCompileTime( + config.namespace, + DatasourceNames.ENSRoot, + ); + + const ethnamesRegistrarContracts = { + [namespaceContract(pluginName, "Ethnames_BaseRegistrar")]: { + chain: chainConfigForContract( + config.globalBlockrange, + ethnamesDatasource.chain.id, + ethnamesDatasource.contracts.BaseRegistrar, + ), + abi: ethnamesDatasource.contracts.BaseRegistrar.abi, + }, + }; + + const ethnamesRegistrarControllerContracts = { + [namespaceContract(pluginName, "Ethnames_LegacyEthRegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + ethnamesDatasource.chain.id, + ethnamesDatasource.contracts.LegacyEthRegistrarController, + ), + abi: ethnamesDatasource.contracts.LegacyEthRegistrarController.abi, + }, + [namespaceContract(pluginName, "Ethnames_WrappedEthRegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + ethnamesDatasource.chain.id, + ethnamesDatasource.contracts.WrappedEthRegistrarController, + ), + abi: ethnamesDatasource.contracts.WrappedEthRegistrarController.abi, + }, + [namespaceContract(pluginName, "Ethnames_UnwrappedEthRegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + ethnamesDatasource.chain.id, + ethnamesDatasource.contracts.UnwrappedEthRegistrarController, + ), + abi: ethnamesDatasource.contracts.UnwrappedEthRegistrarController.abi, + }, + }; + + // configure Basenames dependencies + const basenamesDatasource = getDatasourceAsFullyDefinedAtCompileTime( + config.namespace, + DatasourceNames.Basenames, + ); + + const basenamesRegistrarContracts = { + [namespaceContract(pluginName, "Basenames_BaseRegistrar")]: { + chain: chainConfigForContract( + config.globalBlockrange, + basenamesDatasource.chain.id, + basenamesDatasource.contracts.BaseRegistrar, + ), + abi: basenamesDatasource.contracts.BaseRegistrar.abi, + }, + }; + + const basenamesRegistrarControllerContracts = { + [namespaceContract(pluginName, "Basenames_EARegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + basenamesDatasource.chain.id, + basenamesDatasource.contracts.EARegistrarController, + ), + abi: basenamesDatasource.contracts.EARegistrarController.abi, + }, + [namespaceContract(pluginName, "Basenames_RegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + basenamesDatasource.chain.id, + basenamesDatasource.contracts.RegistrarController, + ), + abi: basenamesDatasource.contracts.RegistrarController.abi, + }, + [namespaceContract(pluginName, "Basenames_UpgradeableRegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + basenamesDatasource.chain.id, + basenamesDatasource.contracts.UpgradeableRegistrarController, + ), + abi: basenamesDatasource.contracts.UpgradeableRegistrarController.abi, + }, + }; + + // configure Lineanames dependencies + const linenamesDatasource = getDatasourceAsFullyDefinedAtCompileTime( + config.namespace, + DatasourceNames.Lineanames, + ); + + const lineanamesRegistrarContracts = { + [namespaceContract(pluginName, "Lineanames_BaseRegistrar")]: { + chain: chainConfigForContract( + config.globalBlockrange, + linenamesDatasource.chain.id, + linenamesDatasource.contracts.BaseRegistrar, + ), + abi: linenamesDatasource.contracts.BaseRegistrar.abi, + }, + }; + + const lineanamesRegistrarControllerContracts = { + [namespaceContract(pluginName, "Lineanames_EthRegistrarController")]: { + chain: chainConfigForContract( + config.globalBlockrange, + linenamesDatasource.chain.id, + linenamesDatasource.contracts.EthRegistrarController, + ), + abi: linenamesDatasource.contracts.EthRegistrarController.abi, + }, + }; + + return ponder.createConfig({ + chains: { + ...chainsConnectionConfig(config.rpcConfigs, ethnamesDatasource.chain.id), + ...chainsConnectionConfig(config.rpcConfigs, basenamesDatasource.chain.id), + ...chainsConnectionConfig(config.rpcConfigs, linenamesDatasource.chain.id), + }, + contracts: { + ...ethnamesRegistrarContracts, + ...ethnamesRegistrarControllerContracts, + ...basenamesRegistrarContracts, + ...basenamesRegistrarControllerContracts, + ...lineanamesRegistrarContracts, + ...lineanamesRegistrarControllerContracts, + }, + }); + }, +}); diff --git a/apps/ensindexer/src/plugins/registrars/shared/lib/registrar-action.ts b/apps/ensindexer/src/plugins/registrars/shared/lib/registrar-action.ts new file mode 100644 index 000000000..b124fc202 --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/shared/lib/registrar-action.ts @@ -0,0 +1,83 @@ +import type { Context } from "ponder:registry"; +import schema from "ponder:schema"; +import type { Hash } from "viem"; + +import { + type AccountId, + type Node, + type RegistrarAction, + serializeAccountId, +} from "@ensnode/ensnode-sdk"; + +/** + * Logical Event Key + * + * Fully lowercase string formatted as: + * `{accountId}:{node}:{transactionHash}`, where `accountId` follows + * the CAIP-10 standard. + * + * @see https://chainagnostic.org/CAIPs/caip-10 + */ +export type LogicalEventKey = string; + +/** + * Make a logical event key for a "logical registrar action". + */ +export function makeLogicalEventKey({ + subregistryId, + node, + transactionHash, +}: { + subregistryId: AccountId; + node: Node; + transactionHash: Hash; +}): LogicalEventKey { + return [serializeAccountId(subregistryId), node, transactionHash].join(":").toLowerCase(); +} + +/** + * Insert a record for the "logical registrar action". + */ +export async function insertRegistrarAction( + context: Context, + { + id, + type, + registrationLifecycle, + incrementalDuration, + registrant, + block, + transactionHash, + eventIds, + }: Omit, +): Promise { + const { node, subregistry } = registrationLifecycle; + const { subregistryId } = subregistry; + + // 1. Create logical event key + const logicalEventKey = makeLogicalEventKey({ + node, + subregistryId, + transactionHash, + }); + + // 2. Store mapping between logical event key and logical event id + await context.db.insert(schema.internal_registrarActionMetadata).values({ + logicalEventKey, + logicalEventId: id, + }); + + // 3. Store initial record for the "logical registrar action" + await context.db.insert(schema.registrarActions).values({ + id, + type, + subregistryId: serializeAccountId(subregistryId), + node, + incrementalDuration: BigInt(incrementalDuration), + registrant, + blockNumber: BigInt(block.number), + timestamp: BigInt(block.timestamp), + transactionHash, + eventIds, + }); +} diff --git a/apps/ensindexer/src/plugins/registrars/shared/lib/registrar-controller-events.ts b/apps/ensindexer/src/plugins/registrars/shared/lib/registrar-controller-events.ts new file mode 100644 index 000000000..ca55d5013 --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/shared/lib/registrar-controller-events.ts @@ -0,0 +1,121 @@ +import type { Context, Event } from "ponder:registry"; +import schema from "ponder:schema"; +import type { Address, Hash } from "viem"; + +import { + type AccountId, + type EncodedReferrer, + isRegistrarActionPricingAvailable, + isRegistrarActionReferralAvailable, + type Node, + type RegistrarActionPricing, + type RegistrarActionReferral, +} from "@ensnode/ensnode-sdk"; + +import { makeLogicalEventKey } from "./registrar-action"; + +/** + * Update the "logical registrar action": + * - set pricing data (if available) + * - set referral data (if available) + * - append new event ID to `eventIds` + */ +export async function handleRegistrarControllerEvent( + context: Context, + { + id, + subregistryId, + node, + pricing, + referral, + transactionHash, + }: { + id: Event["id"]; + subregistryId: AccountId; + node: Node; + pricing: RegistrarActionPricing; + referral: RegistrarActionReferral; + transactionHash: Hash; + }, +): Promise { + // 1. Make Logical Event Key + const logicalEventKey = makeLogicalEventKey({ + subregistryId, + node, + transactionHash, + }); + + // 2. Use the Logical Event Key to get the "logical registrar action" record + // which needs to be updated. + + // 2. a) Find subregistryActionMetadata record by logical event key. + const subregistryActionMetadata = await context.db.find(schema.internal_registrarActionMetadata, { + logicalEventKey, + }); + + // Invariant: the subregistryActionMetadata record must be available for `logicalEventKey` + if (!subregistryActionMetadata) { + throw new Error( + `The required "logical registrar action" ID could not be found for the following logical event key: '${logicalEventKey}'.`, + ); + } + + const { logicalEventId } = subregistryActionMetadata; + + // 2. b) Find "logical registrar action" record by `logicalEventId`. + const logicalRegistrarAction = await context.db.find(schema.registrarActions, { + id: logicalEventId, + }); + + // Invariant: the "logical registrar action" record must be available for `logicalEventId` + if (!logicalRegistrarAction) { + throw new Error( + `The "logical registrar action" record, which could not be found for the following logical event ID: '${logicalEventId}'.`, + ); + } + + // 2. c) Drop the subregistryActionMetadata record, as it won't be needed anymore. + await context.db.delete(schema.internal_registrarActionMetadata, { logicalEventKey }); + + // 3. Prepare pricing info + let baseCost: bigint | null; + let premium: bigint | null; + let total: bigint | null; + + if (isRegistrarActionPricingAvailable(pricing)) { + baseCost = pricing.baseCost.amount; + premium = pricing.premium.amount; + total = pricing.total.amount; + } else { + baseCost = null; + premium = null; + total = null; + } + + // 4. Prepare referral info + let encodedReferrer: EncodedReferrer | null; + let decodedReferrer: Address | null; + + if (isRegistrarActionReferralAvailable(referral)) { + encodedReferrer = referral.encodedReferrer; + decodedReferrer = referral.decodedReferrer; + } else { + encodedReferrer = null; + decodedReferrer = null; + } + + // 5. Update the "logical registrar action" record with + // - pricing data, + // - referral data + // - new event ID appended to `eventIds` + await context.db + .update(schema.registrarActions, { id: logicalRegistrarAction.id }) + .set(({ eventIds }) => ({ + baseCost, + premium, + total, + encodedReferrer, + decodedReferrer, + eventIds: [...eventIds, id], + })); +} diff --git a/apps/ensindexer/src/plugins/registrars/shared/lib/registrar-events.ts b/apps/ensindexer/src/plugins/registrars/shared/lib/registrar-events.ts new file mode 100644 index 000000000..cb7985a68 --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/shared/lib/registrar-events.ts @@ -0,0 +1,176 @@ +/** + * This file contains handlers used in event handlers for a Registrar contract. + */ + +import type { Context, Event } from "ponder:registry"; +import type { Address, Hash } from "viem"; + +import { + type AccountId, + type BlockRef, + bigIntToNumber, + durationBetween, + type Node, + RegistrarActionTypes, + serializeAccountId, + type UnixTimestamp, +} from "@ensnode/ensnode-sdk"; + +import { insertRegistrarAction } from "./registrar-action"; +import { + getRegistrationLifecycle, + insertRegistrationLifecycle, + updateRegistrationLifecycle, +} from "./registration-lifecycle"; +import { getSubregistry } from "./subregistry"; + +/** + * Handle Registrar Event: Registration + */ +export async function handleRegistrarEventRegistration( + context: Context, + { + id, + subregistryId, + node, + registrant, + expiresAt, + block, + transactionHash, + }: { + id: Event["id"]; + subregistryId: AccountId; + node: Node; + registrant: Address; + expiresAt: UnixTimestamp; + block: BlockRef; + transactionHash: Hash; + }, +): Promise { + // 0. Handle possible subsequent registration. + // Get the state of a possibly indexed registration record for this node + // before this registration occurred. + const currentRegistrationLifecycle = await getRegistrationLifecycle(context, { node }); + + if (currentRegistrationLifecycle) { + // 1. If a RegistrationLifecycle for the `node` has been already indexed, + // it means that another RegistrationLifecycle was made for the `node` after + // the previously indexed RegistrationLifecycle expired and its grace period ended. + await updateRegistrationLifecycle(context, { node, expiresAt }); + } else { + // 1. It's a first-time registration made for the `node` value. + await insertRegistrationLifecycle(context, { + subregistryId, + node, + expiresAt, + }); + } + + // 1. Get subregistry details. + const subregistry = await getSubregistry(context, { subregistryId }); + + // Invariant: subregistry record must exist + if (!subregistry) { + throw new Error(`Subregistry record must exists for '${serializeAccountId(subregistryId)}.'`); + } + + // 3. Calculate incremental duration + const incrementalDuration = durationBetween( + block.timestamp, // current block timestamp + expiresAt, // registrations lifecycle expiry date + ); + + // 4. Initialize the "logical registrar action" record for Registration + await insertRegistrarAction(context, { + id, + type: RegistrarActionTypes.Registration, + registrationLifecycle: { + expiresAt, + node, + subregistry: { + subregistryId, + node: subregistry.node, + }, + }, + incrementalDuration, + registrant, + block, + transactionHash, + eventIds: [id], + }); +} + +/** + * Handle Registrar Event: Renewal + */ +export async function handleRegistrarEventRenewal( + context: Context, + { + id, + subregistryId, + node, + registrant, + expiresAt, + block, + transactionHash, + }: { + id: Event["id"]; + subregistryId: AccountId; + node: Node; + registrant: Address; + expiresAt: UnixTimestamp; + block: BlockRef; + transactionHash: Hash; + }, +): Promise { + // TODO: 0. enforce an invariant that for Renewal actions, + // the registration must be in a "renewable" state. + // We can't add the state invariant about name renewals yet, because + // doing so would require us to index more historical RegistrarControllers + + // 1. Get subregistry details. + const subregistry = await getSubregistry(context, { subregistryId }); + + // Invariant: subregistry record must exist + if (!subregistry) { + throw new Error(`Subregistry record must exists for '${serializeAccountId(subregistryId)}.'`); + } + + // 2. Get the current registration lifecycle before this registrar action + // could update it. + const currentRegistrationLifecycle = await getRegistrationLifecycle(context, { + node, + }); + + if (!currentRegistrationLifecycle) { + throw new Error(`Current Registration Lifecycle record was not found for node '${node}'`); + } + + // 3. Calculate incremental duration + const incrementalDuration = durationBetween( + bigIntToNumber(currentRegistrationLifecycle.expiresAt), // current expiry date + expiresAt, // new expiry date + ); + + // 4. Initialize the "logical registrar action" record for Renewal + await insertRegistrarAction(context, { + id, + type: RegistrarActionTypes.Renewal, + registrationLifecycle: { + expiresAt, + node, + subregistry: { + subregistryId, + node: subregistry.node, + }, + }, + incrementalDuration, + registrant, + block, + transactionHash, + eventIds: [id], + }); + + // 5. Extend Registration Lifecycle's expiry. + await updateRegistrationLifecycle(context, { node, expiresAt }); +} diff --git a/apps/ensindexer/src/plugins/registrars/shared/lib/registration-lifecycle.ts b/apps/ensindexer/src/plugins/registrars/shared/lib/registration-lifecycle.ts new file mode 100644 index 000000000..4cc4491ad --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/shared/lib/registration-lifecycle.ts @@ -0,0 +1,64 @@ +import type { Context } from "ponder:registry"; +import schema from "ponder:schema"; + +import { + type AccountId, + type Node, + serializeAccountId, + type UnixTimestamp, +} from "@ensnode/ensnode-sdk"; + +/** + * Get RegistrationLifecycle by node value. + */ +export async function getRegistrationLifecycle( + context: Context, + { node }: { node: Node }, +): Promise { + return context.db.find(schema.registrationLifecycles, { node }); +} + +/** + * Insert Registration Lifecycle + * + * Inserts a new record to track the current state of + * the Registration Lifecycle by node value. + */ +export async function insertRegistrationLifecycle( + context: Context, + { + subregistryId, + node, + expiresAt, + }: { + subregistryId: AccountId; + node: Node; + expiresAt: UnixTimestamp; + }, +): Promise { + await context.db.insert(schema.registrationLifecycles).values({ + subregistryId: serializeAccountId(subregistryId), + node, + expiresAt: BigInt(expiresAt), + }); +} + +/** + * Upsert Registration Lifecycle + * + * Updates the current state of the Registration Lifecycle by node value. + */ +export async function updateRegistrationLifecycle( + context: Context, + { + node, + expiresAt, + }: { + node: Node; + expiresAt: UnixTimestamp; + }, +): Promise { + await context.db + .update(schema.registrationLifecycles, { node }) + .set({ expiresAt: BigInt(expiresAt) }); +} diff --git a/apps/ensindexer/src/plugins/registrars/shared/lib/subregistry.ts b/apps/ensindexer/src/plugins/registrars/shared/lib/subregistry.ts new file mode 100644 index 000000000..880e8d9a4 --- /dev/null +++ b/apps/ensindexer/src/plugins/registrars/shared/lib/subregistry.ts @@ -0,0 +1,44 @@ +/** + * This file contains handlers used in event handlers for a subregistry contract. + */ + +import type { Context } from "ponder:registry"; +import schema from "ponder:schema"; + +import { type AccountId, type Node, serializeAccountId } from "@ensnode/ensnode-sdk"; + +/** + * Upsert Subregistry record + * + * If the record already exists, do nothing. + */ +export async function upsertSubregistry( + context: Context, + { + subregistryId, + node, + }: { + subregistryId: AccountId; + node: Node; + }, +): Promise { + await context.db + .insert(schema.subregistries) + .values({ + subregistryId: serializeAccountId(subregistryId), + node, + }) + .onConflictDoNothing(); +} + +/** + * Get Subregistry record by AccountId. + */ +export async function getSubregistry( + context: Context, + { subregistryId }: { subregistryId: AccountId }, +): Promise { + return context.db.find(schema.subregistries, { + subregistryId: serializeAccountId(subregistryId), + }); +} diff --git a/packages/ensnode-schema/src/ponder.schema.ts b/packages/ensnode-schema/src/ponder.schema.ts index 11ec675c7..bbe563a37 100644 --- a/packages/ensnode-schema/src/ponder.schema.ts +++ b/packages/ensnode-schema/src/ponder.schema.ts @@ -4,5 +4,6 @@ export * from "./schemas/protocol-acceleration.schema"; export * from "./schemas/referrals.schema"; +export * from "./schemas/registrars.schema"; export * from "./schemas/subgraph.schema"; export * from "./schemas/tokenscope.schema"; diff --git a/packages/ensnode-schema/src/schemas/registrars.schema.ts b/packages/ensnode-schema/src/schemas/registrars.schema.ts new file mode 100644 index 000000000..59712edb6 --- /dev/null +++ b/packages/ensnode-schema/src/schemas/registrars.schema.ts @@ -0,0 +1,491 @@ +/** + * Schema Definitions for tracking of ENS registrars. + */ + +import { index, onchainEnum, onchainTable, relations, uniqueIndex } from "ponder"; + +/** + * Subregistries + * + * @see https://ensnode.io/docs/reference/terminology/#subregistry + */ +export const subregistries = onchainTable( + "subregistries", + (t) => ({ + /** + * Subregistry ID + * + * Identifies the chainId and address of the smart contract associated + * with the subregistry. + * + * Guaranteed to be a fully lowercase string formatted according to + * the CAIP-10 standard. + * + * @see https://chainagnostic.org/CAIPs/caip-10 + */ + subregistryId: t.text().primaryKey(), + + /** + * The node (namehash) of the name the subregistry manages subnames of. + * Example subregistry managed names: + * - `eth` + * - `base.eth` + * - `linea.eth` + * + * Guaranteed to be a fully lowercase hex string representation of 32-bytes. + */ + node: t.hex().notNull(), + }), + (t) => ({ + uniqueNode: uniqueIndex().on(t.node), + }), +); + +/** + * Registration Lifecycles + * + * A "registration lifecycle" represents a single cycle of a name being + * registered once followed by renewals (expiry date extensions) any number of + * times. + * + * Note that this data model only tracks the *most recently created* + * "registration lifecycle" record for a name and doesn't track + * *all* "registration lifecycle" records for a name across time. + * Therefore, if a name goes through multiple cycles of: + * (registration -> expiry -> release) -> + * (registration -> expiry -> release) -> etc.. + * this data model only stores data of the most recently created + * "registration lifecycle". + * + * For now we make the following simplifying assumptions: + * 1. That no two subregistries hold state for the same node. + * 2. That the subregistry associated with the name X in the ENS root registry + * exclusively holds state for subnames of X. + * + * These simplifying assumptions happen to be true for the scope of our + * current indexing logic, but nothing in the ENS protocol fundamentally + * forces this to always be true. Therefore this data model will need + * refactoring in the future as our indexing logic expands to handle + * more complex scenarios. + */ +export const registrationLifecycles = onchainTable( + "registration_lifecycles", + (t) => ({ + /** + * The node (namehash) of the FQDN of the domain the registration lifecycle + * is associated with. + * + * Guaranteed to be a subname of the node (namehash) of the subregistry + * identified by `subregistryId`. + * + * Guaranteed to be a fully lowercase hex string representation of 32-bytes. + */ + node: t.hex().primaryKey(), + + /** + * Subregistry ID + * + * Identifies the chainId and address of the subregistry smart contract + * that manages the registration lifecycle. + * + * Guaranteed to be a fully lowercase string formatted according to + * the CAIP-10 standard. + * + * @see https://chainagnostic.org/CAIPs/caip-10 + */ + subregistryId: t.text().notNull(), + + /** + * Expires at + * + * Unix timestamp when the Registration Lifecycle is scheduled to expire. + */ + expiresAt: t.bigint().notNull(), + }), + (t) => ({ + bySubregistry: index().on(t.subregistryId), + }), +); + +/** + * "Logical registrar action type" enum + * + * Types of "logical registrar action". + */ +export const registrarActionType = onchainEnum("registrar_action_type", [ + "registration", + "renewal", +]); + +/** + * "Logical registrar actions" + * + * This table models "logical actions" rather than "events" because a single + * "logical action", such as a single registration or renewal, may emit + * multiple onchain events from multiple contracts where each of those + * individual events may only provide a subset of the data about the full + * "logical action". Therefore, here we aggregate data about each + * "logical action" that may be sourced from multiple onchain events from + * multiple contracts. + * + * Each "logical action" in this table is associated with a single transaction. + * However, it should be noted that a single transaction may perform any number + * of "logical actions". + * + * For example, consider the "logical registrar action" of registering a direct + * subname of .eth. This "logical action" spans interactions across multiple + * contracts that emit multiple onchain events: + * + * 1. The "EthBaseRegistrar" contract emits a `NameRegistered` event enabling + * the tracking of data including: + * - `node` + * - `incrementalDuration` + * - `registrant` + * 2. A "RegistrarController" contract emits its own `NameRegistered` event + * enabling the tracking of data that may include: + * - `baseCost` + * - `premium` + * - `total` + * - `encodedReferrer` + * + * Here we aggregate the state from both of these events into a single + * "logical registrar action". + */ +export const registrarActions = onchainTable( + "registrar_actions", + (t) => ({ + /** + * "Logical registrar action" ID + * + * The `id` value is a deterministic and globally unique identifier for + * the "logical registrar action". + * + * The `id` value represents the *initial* onchain event associated with + * the "logical registrar action", but the full state of + * the "logical registrar action" is an aggregate across each of + * the onchain events referenced in the `eventIds` field. + * + * Guaranteed to be the very first element in `eventIds` array. + */ + id: t.text().primaryKey(), + + /** + * The type of the "logical registrar action". + */ + type: registrarActionType().notNull(), + + /** + * Subregistry ID + * + * The ID of the subregistry the "logical registrar action" was taken on. + * + * Identifies the chainId and address of the associated subregistry smart + * contract. + * + * Guaranteed to be a fully lowercase string formatted according to + * the CAIP-10 standard. + * + * @see https://chainagnostic.org/CAIPs/caip-10 + */ + subregistryId: t.text().notNull(), + + /** + * The node (namehash) of the FQDN of the domain associated with + * the "logical registrar action". + * + * Guaranteed to be a fully lowercase hex string representation of 32-bytes. + */ + node: t.hex().notNull(), + + /** + * Incremental Duration + * + * If `type` is "registration": + * - Represents the duration between `blockTimestamp` and + * the initial `expiresAt` value that the associated + * "registration lifecycle" will be initialized with. + * If `type` is "renewal": + * - Represents the incremental increase in duration made to + * the `expiresAt` value in the associated "registration lifecycle". + * + * A "registration lifecycle" may be extended via renewal even after it + * expires if it is still within its grace period. + * + * Consider the following scenario: + * + * The "registration lifecycle" of a direct subname of .eth is scheduled to + * expire on Jan 1, midnight UTC. It is currently 30 days after this + * expiration time. Therefore, there are currently another 60 days of grace + * period remaining for this name. Anyone can still make a renewal to + * extend the "registration lifecycle" of this name. + * + * Given this scenario, consider the following examples: + * + * 1. If a renewal is made with 10 days incremental duration, + * the "registration lifecycle" for this name will remain in + * an "expired" state, but it will now have another 70 days of + * grace period remaining. + * + * 2. If a renewal is made with 50 days incremental duration, + * the "registration lifecycle" for this name will no longer be + * "expired" and will become "active", but the "registration lifecycle" + * will now be scheduled to expire again in 20 days. + * + * After the "registration lifecycle" for a name becomes expired by more + * than its grace period, it can no longer be renewed by anyone and is + * considered "released". The name must first be registered again, starting + * a new "registration lifecycle" of + * active / expired / grace period / released. + * + * May be 0. + * + * Guaranteed to be a non-negative bigint value. + */ + incrementalDuration: t.bigint().notNull(), + + /** + * Base cost + * + * Base cost (before any `premium`) of Ether measured in units of Wei + * paid to execute the "logical registrar action". + * + * May be 0. + * + * Guaranteed to be: + * 1) null if and only if `total` is null. + * 2) Otherwise, a non-negative bigint value. + */ + baseCost: t.bigint(), + + /** + * Premium + * + * "premium" cost (in excesses of the `baseCost`) of Ether measured in + * units of Wei paid to execute the "logical registrar action". + * + * May be 0. + * + * Guaranteed to be: + * 1) null if and only if `total` is null. + * 2) Otherwise, zero when `type` is `renewal`. + * 3) Otherwise, a non-negative bigint value. + */ + premium: t.bigint(), + + /** + * Total + * + * Total cost of Ether measured in units of Wei paid to execute + * the "logical registrar action". + * + * May be 0. + * + * Guaranteed to be: + * 1) null if and only if both `baseCost` and `premium` are null. + * 2) Otherwise, a non-negative bigint value, equal to the sum of + * `baseCost` and `premium`. + */ + total: t.bigint(), + + /** + * Registrant + * + * Identifies the address that initiated the "logical registrar action" and + * is paying the `total` cost (if applicable). + * + * It may not be the owner of the name: + * 1. When a name is registered, the initial owner of the name may be + * distinct from the registrant. + * 2. There are no restrictions on who may renew a name. + * Therefore the owner of the name may be distinct from the registrant. + * + * + * The "chainId" of this address is the same as is referenced in `subregistryId`. + * + * Guaranteed to be a fully lowercase string formatted according to + * the CAIP-10 standard. + */ + registrant: t.text().notNull(), + + /** + * Encoded Referrer + * + * Represents the "raw" 32-byte "referrer" value emitted onchain in + * association with the registrar action. + * + * Guaranteed to be: + * 1) null if the emitted `eventIds` contain no information about a referrer. + * 2) Otherwise, a fully lowercase hex string representation of 32-bytes. + */ + encodedReferrer: t.hex(), + + /** + * Decoded referrer + * + * Decoded referrer according to the subjective interpretation of + * `encodedReferrer` defined for ENS Holiday Awards. + * + * Identifies the interpreted address of the referrer. + * The "chainId" of this address is the same as is referenced in + * `subregistryId`. + * + * Guaranteed to be: + * 1) null if `encodedReferrer` is null. + * 2) Otherwise, a fully lowercase address. + * 3) May be the "zero address" to represent that an `encodedReferrer` is + * defined but that it is interpreted as no referrer. + */ + decodedReferrer: t.hex(), + + /** + * Number of the block that includes the "logical registrar action". + * + * The "chainId" of this block is the same as is referenced in + * `subregistryId`. + * + * Guaranteed to be a non-negative bigint value. + */ + blockNumber: t.bigint().notNull(), + + /** + * Unix timestamp of the block referenced by `blockNumber` that includes + * the "logical registrar action". + */ + timestamp: t.bigint().notNull(), + + /** + * Transaction hash of the transaction associated with + * the "logical registrar action". + * + * The "chainId" of this transaction is the same as is referenced in + * `subregistryId`. + * + * Note that a single transaction may be associated with any number of + * "logical registrar actions". + * + * Guaranteed to be a fully lowercase hex string representation of 32-bytes. + */ + transactionHash: t.hex().notNull(), + + /** + * Event IDs + * + * Array of the eventIds that have contributed to the state of + * the "logical registrar action" record. + * + * Each eventId is a deterministic and globally unique onchain event + * identifier. + * + * Guarantees: + * - Each eventId is of events that occurred within the block + * referenced by `blockNumber`. + * - At least 1 eventId. + * - Ordered chronologically (ascending) by logIndex within `blockNumber`. + * - The first element in the array is equal to the `id` of + * the overall "logical registrar action" record. + * + * The following ideas are not generalized for ENS overall but happen to + * be a characteristic of the scope of our current indexing logic: + * 1. These id's always reference events emitted by + * a related "BaseRegistrar" contract. + * 2. These id's optionally reference events emitted by + * a related "Registrar Controller" contract. This is because our + * current indexing logic doesn't guarantee to index + * all "Registrar Controller" contracts. + */ + eventIds: t.text().array().notNull(), + }), + (t) => ({ + byDecodedReferrer: index().on(t.decodedReferrer), + byTimestamp: index().on(t.timestamp), + }), +); + +/** + * Logical Registrar Action Metadata + * + * NOTE: This table is an internal implementation detail of ENSIndexer and + * should not be queried outside of ENSIndexer. + * + * Building a "logical registrar action" record may require data from + * multiple onchain events. To help aggregate data from multiple events into + * a single "logical registrar action" ENSIndexer may temporarily store data + * here to achieve this data aggregation. + * + * Note how multiple "logical registrar actions" may be taken on + * the same `node` in the same `transactionHash`. For example, consider + * a case of a single transaction registering a name and subsequently renewing + * it twice. While this may be silly it is technically possible and therefore + * such cases must be considered. To support such cases, when + * the last event handler for a "logical registrar action" has completed its + * processing the record referenced by the `logicalEventKey` must be removed. + */ +export const internal_registrarActionMetadata = onchainTable( + "_ensindexer_registrar_action_metadata", + (t) => ({ + /** + * Logical Event Key + * + * A fully lowercase string formatted as: + * `{chainId}:{subregistryAddress}:{node}:{transactionHash}` + */ + logicalEventKey: t.text().primaryKey(), + + /** + * Logical Event ID + * + * A string holding the `id` value of the existing "logical registrar action" + * record that is currently being built as an aggregation of onchain events. + * + * May be used by subsequent event handlers to identify which + * "logical registrar action" to aggregate additional indexed state into. + */ + logicalEventId: t.text().notNull(), + }), +); + +/// Relations + +/** + * Subregistry Relations + * + * Each Subregistry is related to: + * - 0 or more RegistrationLifecycles + */ +export const subregistryRelations = relations(subregistries, ({ many }) => ({ + registrationLifecycle: many(registrationLifecycles), +})); + +/** + * Registration Lifecycle Relations + * + * Each Registration Lifecycle is related to: + * - exactly one Subregistry + * - 0 or more "logical registrar action" + */ +export const registrationLifecycleRelations = relations( + registrationLifecycles, + ({ one, many }) => ({ + subregistry: one(subregistries, { + fields: [registrationLifecycles.subregistryId], + references: [subregistries.subregistryId], + }), + + registrarAction: many(registrarActions), + }), +); + +/** + * "Logical registrar action" Relations + * + * Each "logical registrar action" is related to: + * - exactly one Registration Lifecycle (note the docs on + * Registration Lifecycle explaining how these records may + * be recycled across time). + */ +export const registrarActionRelations = relations(registrarActions, ({ one }) => ({ + registrationLifecycle: one(registrationLifecycles, { + fields: [registrarActions.node], + references: [registrationLifecycles.node], + }), +})); diff --git a/packages/ensnode-sdk/package.json b/packages/ensnode-sdk/package.json index c29462d3a..490942d10 100644 --- a/packages/ensnode-sdk/package.json +++ b/packages/ensnode-sdk/package.json @@ -55,6 +55,7 @@ "@adraffy/ens-normalize": "catalog:", "@ensdomains/address-encoder": "^1.1.2", "@ensnode/datasources": "workspace:*", + "@namehash/ens-referrals": "workspace:*", "caip": "catalog:", "zod": "catalog:" } diff --git a/packages/ensnode-sdk/src/ensindexer/config/types.ts b/packages/ensnode-sdk/src/ensindexer/config/types.ts index 9f4f68038..18dbb2034 100644 --- a/packages/ensnode-sdk/src/ensindexer/config/types.ts +++ b/packages/ensnode-sdk/src/ensindexer/config/types.ts @@ -14,6 +14,7 @@ export enum PluginName { ThreeDNS = "threedns", ProtocolAcceleration = "protocol-acceleration", Referrals = "referrals", + Registrars = "registrars", TokenScope = "tokenscope", } diff --git a/packages/ensnode-sdk/src/index.ts b/packages/ensnode-sdk/src/index.ts index 48c604020..aba4fcb95 100644 --- a/packages/ensnode-sdk/src/index.ts +++ b/packages/ensnode-sdk/src/index.ts @@ -6,6 +6,7 @@ export * from "./ensapi"; export * from "./ensindexer"; export * from "./ensrainbow"; export * from "./identity"; +export * from "./registrars"; export * from "./resolution"; export * from "./shared"; export * from "./tracing"; diff --git a/packages/ensnode-sdk/src/registrars/index.ts b/packages/ensnode-sdk/src/registrars/index.ts new file mode 100644 index 000000000..d59bd32c9 --- /dev/null +++ b/packages/ensnode-sdk/src/registrars/index.ts @@ -0,0 +1,3 @@ +export * from "./registrar-action"; +export * from "./registration-lifecycle"; +export * from "./subregistry"; diff --git a/packages/ensnode-sdk/src/registrars/registrar-action.ts b/packages/ensnode-sdk/src/registrars/registrar-action.ts new file mode 100644 index 000000000..3de389dd5 --- /dev/null +++ b/packages/ensnode-sdk/src/registrars/registrar-action.ts @@ -0,0 +1,324 @@ +import type { EncodedReferrer } from "@namehash/ens-referrals"; + +export type { EncodedReferrer } from "@namehash/ens-referrals"; +export { decodeEncodedReferrer, zeroEncodedReferrer } from "@namehash/ens-referrals"; + +import type { Address, Hash } from "viem"; + +import type { BlockRef, Duration, PriceEth } from "../shared"; +import type { RegistrationLifecycle } from "./registration-lifecycle"; + +/** + * Globally unique, deterministic ID of an indexed onchain event. + */ +type RegistrarActionEventId = string; + +/** + * Types of "logical registrar action". + */ +export const RegistrarActionTypes = { + Registration: "registration", + Renewal: "renewal", +} as const; + +export type RegistrarActionType = (typeof RegistrarActionTypes)[keyof typeof RegistrarActionTypes]; + +/** + * Pricing information for a "logical registrar action". + */ +export interface RegistrarActionPricingAvailable { + /** + * Base cost + * + * Base cost (before any `premium`) of Ether measured in units of Wei + * paid to execute the "logical registrar action". + * + * May be 0. + */ + baseCost: PriceEth; + + /** + * Premium + * + * "premium" cost (in excesses of the `baseCost`) of Ether measured in + * units of Wei paid to execute the "logical registrar action". + * + * May be 0. + */ + premium: PriceEth; + + /** + * Total + * + * Total cost of Ether measured in units of Wei paid to execute + * the "logical registrar action". + * + * May be 0. + */ + total: PriceEth; +} + +/** + * Pricing information for a "logical registrar action" when + * there is no known pricing data. + */ +export interface RegistrarActionPricingUnknown { + /** + * Base cost + * + * Base cost (before any `premium`) of Ether measured in units of Wei + * paid to execute the "logical registrar action". + */ + baseCost: null; + + /** + * Premium + * + * "premium" cost (in excesses of the `baseCost`) of Ether measured in + * units of Wei paid to execute the "logical registrar action". + */ + premium: null; + + /** + * Total + * + * Total cost of Ether measured in units of Wei paid to execute + * the "logical registrar action". + */ + total: null; +} + +export type RegistrarActionPricing = + | RegistrarActionPricingAvailable + | RegistrarActionPricingUnknown; + +export function isRegistrarActionPricingAvailable( + registrarActionPricing: RegistrarActionPricing, +): registrarActionPricing is RegistrarActionPricingAvailable { + const { baseCost, premium, total } = registrarActionPricing; + + return baseCost !== null && premium !== null && total !== null; +} + +/** + * * Referral information for performing a "logical registrar action". + */ +export interface RegistrarActionReferralAvailable { + /** + * Encoded Referrer + * + * Represents the "raw" 32-byte "referrer" value emitted onchain in + * association with the registrar action. + */ + encodedReferrer: EncodedReferrer; + + /** + * Decoded Referrer + * + * Decoded referrer according to the subjective interpretation of + * `encodedReferrer` defined for ENS Holiday Awards. + * + * Identifies the interpreted address of the referrer. + * The "chainId" of this address is the same as is referenced in + * `subregistryId`. + * + * May be the "zero address" to represent that an `encodedReferrer` is + * defined but that it is interpreted as no referrer. + */ + decodedReferrer: Address; +} + +/** + * Referral information for performing a "logical registrar action" when + * registrar controller does not implement referrals. + */ +export interface RegistrarActionReferralNotApplicable { + /** + * Encoded Referrer + * + * Represents the "raw" 32-byte "referrer" value emitted onchain in + * association with the registrar action. + */ + encodedReferrer: null; + + /** + * Decoded Referrer + * + * Decoded referrer according to the subjective interpretation of + * `encodedReferrer` defined for ENS Holiday Awards. + * + */ + decodedReferrer: null; +} + +export type RegistrarActionReferral = + | RegistrarActionReferralAvailable + | RegistrarActionReferralNotApplicable; + +export function isRegistrarActionReferralAvailable( + registrarActionReferral: RegistrarActionReferral, +): registrarActionReferral is RegistrarActionReferralAvailable { + const { encodedReferrer, decodedReferrer } = registrarActionReferral; + + return encodedReferrer !== null && decodedReferrer !== null; +} + +/** + * "Logical registrar action" + * + * Represents a state of "logical registrar action". May be built using data + * from multiple events within the same "logical" registration / renewal action. + */ +export interface RegistrarAction { + /** + * "Logical registrar action" ID + * + * The `id` value is a deterministic and globally unique identifier for + * the "logical registrar action". + * + * The `id` value represents the *initial* onchain event associated with + * the "logical registrar action", but the full state of + * the "logical registrar action" is an aggregate across each of + * the onchain events referenced in the `eventIds` field. + * + * Guaranteed to be the very first element in `eventIds` array. + */ + id: RegistrarActionEventId; + + /** + * The type of the "logical registrar action". + */ + type: RegistrarActionType; + + /** + * + * Incremental Duration + * + * If `type` is "registration": + * - Represents the duration between `block.timestamp` and + * the initial `registrationLifecycle.expiresAt` value that the associated + * "registration lifecycle" will be initialized with. + * If `type` is "renewal": + * - Represents the incremental increase in duration made to + * the `registrationLifecycle.expiresAt` value in the associated + * "registration lifecycle". + * + * A "registration lifecycle" may be extended via renewal even after it + * expires if it is still within its grace period. + * + * Consider the following scenario: + * + * The "registration lifecycle" of a direct subname of .eth is scheduled to + * expire on Jan 1, midnight UTC. It is currently 30 days after this + * expiration time. Therefore, there are currently another 60 days of grace + * period remaining for this name. Anyone can still make a renewal to + * extend the "registration lifecycle" of this name. + * + * Given this scenario, consider the following examples: + * + * 1. If a renewal is made with 10 days incremental duration, + * the "registration lifecycle" for this name will remain in + * an "expired" state, but it will now have another 70 days of + * grace period remaining. + * + * 2. If a renewal is made with 50 days incremental duration, + * the "registration lifecycle" for this name will no longer be + * "expired" and will become "active", but the "registration lifecycle" + * will now be scheduled to expire again in 20 days. + * + * After the "registration lifecycle" for a name becomes expired by more + * than its grace period, it can no longer be renewed by anyone and is + * considered "released". The name must first be registered again, starting + * a new "registration lifecycle" of + * active / expired / grace period / released. + * + * May be 0. + * + * Guaranteed to be a non-negative bigint value. + */ + incrementalDuration: Duration; + + /** + * Registrant + * + * Identifies the address that initiated the "logical registrar action" and + * is paying the `pricing.total` cost (if applicable). + * + * It may not be the owner of the name: + * 1. When a name is registered, the initial owner of the name may be + * distinct from the registrant. + * 2. There are no restrictions on who may renew a name. + * Therefore the owner of the name may be distinct from the registrant. + * + * The "chainId" of this address is the same as is referenced in + * `registrationLifecycle.subregistry.subregistryId`. + */ + registrant: Address; + + /** + * Registration Lifecycle associated with this "logical registrar action". + */ + registrationLifecycle: RegistrationLifecycle; + + /** + * Pricing information associated with this "logical registrar action". + */ + pricing: RegistrarActionPricing; + + /** + * Referral information associated with this "logical registrar action". + */ + referral: RegistrarActionReferral; + + /** + * Block ref + * + * References the block where the "logical registrar action" was executed. + * + * The "chainId" of this block is the same as is referenced in + * `registrationLifecycle.subregistry.subregistryId`. + */ + block: BlockRef; + + /** + * Transaction hash + * + * Transaction hash of the transaction associated with + * the "logical registrar action". + * + * The "chainId" of this transaction is the same as is referenced in + * `registrationLifecycle.subregistry.subregistryId`. + * + * Note that a single transaction may be associated with any number of + * "logical registrar actions". + */ + transactionHash: Hash; + + /** + * Event IDs + * + * Array of the eventIds that have contributed to the state of + * the "logical registrar action" record. + * + * Each eventId is a deterministic and globally unique onchain event + * identifier. + * + * Guarantees: + * - Each eventId is of events that occurred within the block + * referenced by `block.number`. + * - At least 1 eventId. + * - Ordered chronologically (ascending) by logIndex within `block.number`. + * - The first element in the array is equal to the `id` of + * the overall "logical registrar action" record. + * + * The following ideas are not generalized for ENS overall but happen to + * be a characteristic of the scope of our current indexing logic: + * 1. These id's always reference events emitted by + * a related "BaseRegistrar" contract. + * 2. These id's optionally reference events emitted by + * a related "Registrar Controller" contract. This is because our + * current indexing logic doesn't guarantee to index + * all "Registrar Controller" contracts. + */ + eventIds: [RegistrarActionEventId, ...RegistrarActionEventId[]]; +} diff --git a/packages/ensnode-sdk/src/registrars/registration-lifecycle.ts b/packages/ensnode-sdk/src/registrars/registration-lifecycle.ts new file mode 100644 index 000000000..de4ad765d --- /dev/null +++ b/packages/ensnode-sdk/src/registrars/registration-lifecycle.ts @@ -0,0 +1,72 @@ +import type { Node } from "../ens"; +import type { UnixTimestamp } from "../shared"; +import type { Subregistry } from "./subregistry"; + +/** + * Registration Lifecycle Stages + * + * Important: this definition should not be used anywhere. + * It's only here to capture some ideas that were shared in the team. + */ +const RegistrationLifecycleStages = { + /** + * Active + * + * Happens when + * the current timestamp <= expiry. + */ + Active: "registrationLifecycle_active", + + /** + * Grace Period + * + * Happens when + * `expiry < the current timestamp <= expiry + 90 days`. + */ + GracePeriod: "registrationLifecycle_gracePeriod", + + /** + * Released with Temporary Premium Price + * + * Happens when + * `expiry + 90 days < the current timestamp <= expiry + 120 days`. + */ + ReleasedWithTempPrice: "registrationLifecycle_releasedWithTempPrice", + + /** + * Fully Released (Regular Price) + * + * Happens when + * ` expiry + 120 days < the current timestamp`. + */ + FullyReleased: "registrationLifecycle_fullyReleased", +} as const; + +export type RegistrationLifecycleStage = + (typeof RegistrationLifecycleStages)[keyof typeof RegistrationLifecycleStages]; + +/** + * Registration Lifecycle + */ +export interface RegistrationLifecycle { + /** + * Subregistry that manages this Registration Lifecycle. + */ + subregistry: Subregistry; + + /** + * The node (namehash) of the FQDN of the domain the registration lifecycle + * is associated with. + * + * Guaranteed to be a subname of the node (namehash) of the subregistry + * identified by `subregistryId.subregistryId`. + */ + node: Node; + + /** + * Expires at + * + * Identifies when the Registration Lifecycle is scheduled to expire. + */ + expiresAt: UnixTimestamp; +} diff --git a/packages/ensnode-sdk/src/registrars/subregistry.ts b/packages/ensnode-sdk/src/registrars/subregistry.ts new file mode 100644 index 000000000..f821d805b --- /dev/null +++ b/packages/ensnode-sdk/src/registrars/subregistry.ts @@ -0,0 +1,26 @@ +import type { Node } from "../ens"; +import type { AccountId } from "../shared"; + +/** + * Subregistry + */ +export interface Subregistry { + /** + * Subregistry ID + * + * The ID of the subregistry the "logical registrar action" was taken on. + * + * Identifies the chainId and address of the associated subregistry smart + * contract. + */ + subregistryId: AccountId; + + /** + * The node (namehash) of the name the subregistry manages subnames of. + * Example subregistry managed names: + * - `eth` + * - `base.eth` + * - `linea.eth` + */ + node: Node; +} diff --git a/packages/ensnode-sdk/src/shared/currencies.test.ts b/packages/ensnode-sdk/src/shared/currencies.test.ts index f06ed9729..cdcee2002 100644 --- a/packages/ensnode-sdk/src/shared/currencies.test.ts +++ b/packages/ensnode-sdk/src/shared/currencies.test.ts @@ -108,6 +108,7 @@ describe("Currencies", () => { expect(addPrices(priceEth(1n), priceEth(2n), priceEth(3n))).toEqual(priceEth(6n)); }); it("throws an error if all prices do not have the same currency", () => { + // @ts-expect-error expect(() => addPrices(priceEth(1n), priceDai(2n), priceEth(3n))).toThrowError( /All prices must have the same currency to be added together/i, ); diff --git a/packages/ensnode-sdk/src/shared/currencies.ts b/packages/ensnode-sdk/src/shared/currencies.ts index 7b9c2f068..5ddcc0a64 100644 --- a/packages/ensnode-sdk/src/shared/currencies.ts +++ b/packages/ensnode-sdk/src/shared/currencies.ts @@ -130,7 +130,9 @@ export function isPriceEqual(priceA: Price, priceB: Price): boolean { * @returns total of all prices. * @throws if not all prices have the same currency. */ -export function addPrices(...prices: [Price, Price, ...Price[]]): Price { +export function addPrices( + ...prices: [PriceType, PriceType, ...PriceType[]] +): PriceType { const firstPrice = prices[0]; const allPricesInSameCurrency = prices.every((price) => isPriceCurrencyEqual(firstPrice, price)); @@ -148,6 +150,6 @@ export function addPrices(...prices: [Price, Price, ...Price[]]): Price { { amount: 0n, currency: firstPrice.currency, - } satisfies Price, - ); + }, + ) as PriceType; } diff --git a/packages/ensnode-sdk/src/shared/datetime.test.ts b/packages/ensnode-sdk/src/shared/datetime.test.ts new file mode 100644 index 000000000..cc444b0a1 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/datetime.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { durationBetween } from "./datetime"; + +describe("datetime", () => { + describe("durationBetween()", () => { + it("returns duration for valid input where start is before end", () => { + expect(durationBetween(1234, 4321)).toEqual(3087); + expect(durationBetween(1234, 1234)).toEqual(0); + }); + it("throws an error for invalid input where end is before start", () => { + expect(() => durationBetween(1234, 1233)).toThrowError( + /Duration must be a non-negative integer/i, + ); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/shared/datetime.ts b/packages/ensnode-sdk/src/shared/datetime.ts new file mode 100644 index 000000000..870b05af2 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/datetime.ts @@ -0,0 +1,9 @@ +import { deserializeDuration } from "./deserialize"; +import type { Duration, UnixTimestamp } from "./types"; + +/** + * Duration between two moments in time. + */ +export function durationBetween(start: UnixTimestamp, end: UnixTimestamp): Duration { + return deserializeDuration(end - start, "Duration"); +} diff --git a/packages/ensnode-sdk/src/shared/deserialize.ts b/packages/ensnode-sdk/src/shared/deserialize.ts index f0b607416..c4840d804 100644 --- a/packages/ensnode-sdk/src/shared/deserialize.ts +++ b/packages/ensnode-sdk/src/shared/deserialize.ts @@ -102,7 +102,7 @@ export function deserializeBlockRef( return parsed.data; } -export function deserializeDuration(maybeDuration: string, valueLabel?: string): Duration { +export function deserializeDuration(maybeDuration: unknown, valueLabel?: string): Duration { const schema = makeDurationSchema(valueLabel); const parsed = schema.safeParse(maybeDuration); diff --git a/packages/ensnode-sdk/src/shared/index.ts b/packages/ensnode-sdk/src/shared/index.ts index 70eae532b..6dde8f001 100644 --- a/packages/ensnode-sdk/src/shared/index.ts +++ b/packages/ensnode-sdk/src/shared/index.ts @@ -4,6 +4,7 @@ export * from "./address"; export * from "./cache"; export * from "./collections"; export * from "./currencies"; +export * from "./datetime"; export { deserializeBlockNumber, deserializeBlockRef, @@ -17,6 +18,7 @@ export { export * from "./interpretation"; export * from "./labelhash"; export * from "./null-bytes"; +export * from "./numbers"; export * from "./serialize"; export * from "./serialized-types"; export * from "./types"; diff --git a/packages/ensnode-sdk/src/shared/numbers.test.ts b/packages/ensnode-sdk/src/shared/numbers.test.ts new file mode 100644 index 000000000..af5fbaa07 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/numbers.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { bigIntToNumber } from "./numbers"; + +describe("Numbers", () => { + describe("bigIntToNumber()", () => { + it("can convert bigint to number when possible", () => { + expect(bigIntToNumber(BigInt(Number.MIN_SAFE_INTEGER))).toEqual(Number.MIN_SAFE_INTEGER); + + expect(bigIntToNumber(BigInt(Number.MAX_SAFE_INTEGER))).toEqual(Number.MAX_SAFE_INTEGER); + }); + + it("refuses to convert to low bigint value", () => { + expect(() => bigIntToNumber(BigInt(Number.MIN_SAFE_INTEGER - 1))).toThrowError( + /The bigint '-9007199254740992' value is too low to be to converted into a number/i, + ); + }); + it("refuses to convert to high bigint value", () => { + expect(() => bigIntToNumber(BigInt(Number.MAX_SAFE_INTEGER + 1))).toThrowError( + /The bigint '9007199254740992' value is too high to be to converted into a number/i, + ); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/shared/numbers.ts b/packages/ensnode-sdk/src/shared/numbers.ts new file mode 100644 index 000000000..b37f0a328 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/numbers.ts @@ -0,0 +1,21 @@ +/** + * Converts a bigint value into a number value. + * + * @throws when value is outside the range of `Number.MIN_SAFE_INTEGER` and + * `Number.MAX_SAFE_INTEGER`. + */ +export function bigIntToNumber(n: bigint): number { + if (n < Number.MIN_SAFE_INTEGER) { + throw new Error( + `The bigint '${n.toString()}' value is too low to be to converted into a number.'`, + ); + } + + if (n > Number.MAX_SAFE_INTEGER) { + throw new Error( + `The bigint '${n.toString()}' value is too high to be to converted into a number.'`, + ); + } + + return Number(n); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9297e793..33319ca98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -757,6 +757,9 @@ importers: '@ensnode/datasources': specifier: workspace:* version: link:../datasources + '@namehash/ens-referrals': + specifier: workspace:* + version: link:../ens-referrals caip: specifier: 'catalog:' version: 1.1.1