From bb158ff11a273a40fa51c608b32f8851a6abe184 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Fri, 2 Aug 2024 14:23:14 +0300 Subject: [PATCH 01/27] Refine changeset --- .changeset/neat-lamps-sell.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/neat-lamps-sell.md b/.changeset/neat-lamps-sell.md index 0ce347bac..de3849d79 100644 --- a/.changeset/neat-lamps-sell.md +++ b/.changeset/neat-lamps-sell.md @@ -2,4 +2,4 @@ "@namehash/ens-utils": minor --- -Add domain related logics to ens-utils (Registration, DomainName, DomainCard, etc.) +Add domain related logics to ens-utils (Registration, DomainCard, etc.) From da472428dd061703c84ec9d7d22afad1fcadbe12 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Fri, 2 Aug 2024 14:36:59 +0300 Subject: [PATCH 02/27] Refine docs for UserOwnershipOfDomain --- packages/ens-utils/src/domain.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/ens-utils/src/domain.ts b/packages/ens-utils/src/domain.ts index 8aee68335..f3ad3a461 100644 --- a/packages/ens-utils/src/domain.ts +++ b/packages/ens-utils/src/domain.ts @@ -25,20 +25,34 @@ export interface DomainCard { formerManagerAddress: Address | null; } -/* Defines the ownership of a domain for a given address */ +/* + * Defines the ownership relation between a domain and a user. + */ export const UserOwnershipOfDomain = { - /* NoOwner: If domain has no owner */ + /* + * The domain has no `ActiveOwner` or `FormerOwner`. + */ NoOwner: "NoOwner", - /* NotOwner: If domain has an owner but user is not the owner */ + /* + * The domain has an `ActiveOwner` or a `FormerOwner` but they are not the + * user. + */ NotOwner: "NotOwner", - /* FormerOwner: If user is owner of the domain and domain is in Grace Period */ + /* + * The user was previously the `ActiveOwner` of the domain, however the + * registration of the domain is now in grace period. + */ FormerOwner: "FormerOwner", - /* ActiveOwner: If user is owner of the domain and domain is not in Grace Period */ + /* + * The user is the owner of the domain that has an active registration (not + * in grace period). + */ ActiveOwner: "ActiveOwner", }; + export type UserOwnershipOfDomain = (typeof UserOwnershipOfDomain)[keyof typeof UserOwnershipOfDomain]; From c0b096ac7099a933e6fa113ae8bae1353a29e77c Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Fri, 2 Aug 2024 14:39:33 +0300 Subject: [PATCH 03/27] Refine docs for getCurrentUserOwnership --- packages/ens-utils/src/domain.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ens-utils/src/domain.ts b/packages/ens-utils/src/domain.ts index f3ad3a461..aec2dc698 100644 --- a/packages/ens-utils/src/domain.ts +++ b/packages/ens-utils/src/domain.ts @@ -57,11 +57,12 @@ export type UserOwnershipOfDomain = (typeof UserOwnershipOfDomain)[keyof typeof UserOwnershipOfDomain]; /** - * Returns the ownership status of a domain in comparison to the current user's address + * Returns the ownership relation between a domain and the current user. + * * @param domain Domain that is being checked. If null, returns UserOwnershipOfDomain.NoOwner * @param currentUserAddress Address of the current user. * If null, returns UserOwnershipOfDomain.NoOwner or UserOwnershipOfDomain.NotOwner - * @returns UserOwnershipOfDomain + * @returns The appropriate `UserOwnershipOfDomain` value given `domain` and `currentUserAddress`. */ export const getCurrentUserOwnership = ( domain: DomainCard | null, From 1523847e0e0a51a9439e78599ef87dc9e9cca12f Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Fri, 2 Aug 2024 14:46:20 +0300 Subject: [PATCH 04/27] Refine getCurrentUserOwnership Stop accepting null `domain` param and remove redundant logic. --- packages/ens-utils/src/domain.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/ens-utils/src/domain.ts b/packages/ens-utils/src/domain.ts index aec2dc698..ed51f8703 100644 --- a/packages/ens-utils/src/domain.ts +++ b/packages/ens-utils/src/domain.ts @@ -59,38 +59,30 @@ export type UserOwnershipOfDomain = /** * Returns the ownership relation between a domain and the current user. * - * @param domain Domain that is being checked. If null, returns UserOwnershipOfDomain.NoOwner + * @param domain The `DomainCard` to check the ownership relationship with. * @param currentUserAddress Address of the current user. * If null, returns UserOwnershipOfDomain.NoOwner or UserOwnershipOfDomain.NotOwner * @returns The appropriate `UserOwnershipOfDomain` value given `domain` and `currentUserAddress`. */ export const getCurrentUserOwnership = ( - domain: DomainCard | null, + domain: DomainCard, currentUserAddress: Address | null, ): UserOwnershipOfDomain => { - const formerDomainOwnerAddress = - domain && domain.formerOwnerAddress ? domain.formerOwnerAddress : null; - const ownerAddress = - domain && domain.ownerAddress ? domain.ownerAddress : null; - if (currentUserAddress && formerDomainOwnerAddress) { - const isFormerOwner = - formerDomainOwnerAddress && - isAddressEqual(formerDomainOwnerAddress, currentUserAddress); - - if (isFormerOwner) { + if (currentUserAddress && domain.formerOwnerAddress) { + if (isAddressEqual(domain.formerOwnerAddress, currentUserAddress)) { return UserOwnershipOfDomain.FormerOwner; } const isOwner = - ownerAddress && isAddressEqual(currentUserAddress, ownerAddress); + domain.ownerAddress && isAddressEqual(currentUserAddress, domain.ownerAddress); if (isOwner) { return UserOwnershipOfDomain.ActiveOwner; } } - if (!ownerAddress) { + if (!domain.ownerAddress) { return UserOwnershipOfDomain.NoOwner; } From 31c46951a641bb1dd2507cdef96affd1a6398378 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Fri, 2 Aug 2024 14:58:30 +0300 Subject: [PATCH 05/27] Refine getCurrentUserOwnership --- packages/ens-utils/src/domain.ts | 33 +++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/ens-utils/src/domain.ts b/packages/ens-utils/src/domain.ts index ed51f8703..3a0e3fff7 100644 --- a/packages/ens-utils/src/domain.ts +++ b/packages/ens-utils/src/domain.ts @@ -57,33 +57,36 @@ export type UserOwnershipOfDomain = (typeof UserOwnershipOfDomain)[keyof typeof UserOwnershipOfDomain]; /** - * Returns the ownership relation between a domain and the current user. + * Returns the `UserOwnershipOfDomain` relation between a `DomainCard` and the + * `Address` of the current user. * - * @param domain The `DomainCard` to check the ownership relationship with. - * @param currentUserAddress Address of the current user. - * If null, returns UserOwnershipOfDomain.NoOwner or UserOwnershipOfDomain.NotOwner - * @returns The appropriate `UserOwnershipOfDomain` value given `domain` and `currentUserAddress`. + * @param domain The `DomainCard` to check the `UserOwnershipOfDomain` + * relationship with. + * @param currentUserAddress `Address` of the current user, or `null` if there + * is no current user signed in. + * @returns The appropriate `UserOwnershipOfDomain` value given the provided + * `domain` and `currentUserAddress`. */ export const getCurrentUserOwnership = ( domain: DomainCard, currentUserAddress: Address | null, ): UserOwnershipOfDomain => { - if (currentUserAddress && domain.formerOwnerAddress) { - if (isAddressEqual(domain.formerOwnerAddress, currentUserAddress)) { - return UserOwnershipOfDomain.FormerOwner; - } + if (!domain.ownerAddress && !domain.formerOwnerAddress) { + return UserOwnershipOfDomain.NoOwner; + } - const isOwner = - domain.ownerAddress && isAddressEqual(currentUserAddress, domain.ownerAddress); + if (currentUserAddress) { - if (isOwner) { + if (domain.ownerAddress && + isAddressEqual(domain.ownerAddress, currentUserAddress)) { return UserOwnershipOfDomain.ActiveOwner; } - } - if (!domain.ownerAddress) { - return UserOwnershipOfDomain.NoOwner; + if (domain.formerOwnerAddress && + isAddressEqual(domain.formerOwnerAddress, currentUserAddress)) { + return UserOwnershipOfDomain.FormerOwner; + } } return UserOwnershipOfDomain.NotOwner; From fb2897ea58ad32477ca0d5991e9c1b6b93c41207 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Fri, 2 Aug 2024 15:07:58 +0300 Subject: [PATCH 06/27] Move logic from domain.ts that belongs in ensname.ts --- packages/ens-utils/src/domain.ts | 89 ++++--------------------------- packages/ens-utils/src/ensname.ts | 67 ++++++++++++++++++++++- 2 files changed, 77 insertions(+), 79 deletions(-) diff --git a/packages/ens-utils/src/domain.ts b/packages/ens-utils/src/domain.ts index 3a0e3fff7..38366b462 100644 --- a/packages/ens-utils/src/domain.ts +++ b/packages/ens-utils/src/domain.ts @@ -1,7 +1,6 @@ import { NFTRef } from "./nft"; import { ENSName } from "./ensname"; import { Address, isAddressEqual } from "./address"; -import { keccak256, labelhash as labelHash } from "viem"; import { Registration } from "./ethregistrar"; export interface DomainCard { @@ -46,7 +45,7 @@ export const UserOwnershipOfDomain = { */ FormerOwner: "FormerOwner", - /* + /* * The user is the owner of the domain that has an active registration (not * in grace period). */ @@ -59,7 +58,7 @@ export type UserOwnershipOfDomain = /** * Returns the `UserOwnershipOfDomain` relation between a `DomainCard` and the * `Address` of the current user. - * + * * @param domain The `DomainCard` to check the `UserOwnershipOfDomain` * relationship with. * @param currentUserAddress `Address` of the current user, or `null` if there @@ -71,91 +70,25 @@ export const getCurrentUserOwnership = ( domain: DomainCard, currentUserAddress: Address | null, ): UserOwnershipOfDomain => { - if (!domain.ownerAddress && !domain.formerOwnerAddress) { return UserOwnershipOfDomain.NoOwner; } if (currentUserAddress) { - - if (domain.ownerAddress && - isAddressEqual(domain.ownerAddress, currentUserAddress)) { + if ( + domain.ownerAddress && + isAddressEqual(domain.ownerAddress, currentUserAddress) + ) { return UserOwnershipOfDomain.ActiveOwner; } - if (domain.formerOwnerAddress && - isAddressEqual(domain.formerOwnerAddress, currentUserAddress)) { + if ( + domain.formerOwnerAddress && + isAddressEqual(domain.formerOwnerAddress, currentUserAddress) + ) { return UserOwnershipOfDomain.FormerOwner; } } return UserOwnershipOfDomain.NotOwner; -}; - -export enum ParseNameErrorCode { - Empty = "Empty", - TooShort = "TooShort", - UnsupportedTLD = "UnsupportedTLD", - UnsupportedSubdomain = "UnsupportedSubdomain", - MalformedName = "MalformedName", - MalformedLabelHash = "MalformedLabelHash", -} - -type ParseNameErrorDetails = { - normalizedName: string | null; - displayName: string | null; -}; -export class ParseNameError extends Error { - public readonly errorCode: ParseNameErrorCode; - public readonly errorDetails: ParseNameErrorDetails | null; - - constructor( - message: string, - errorCode: ParseNameErrorCode, - errorDetails: ParseNameErrorDetails | null, - ) { - super(message); - - this.errorCode = errorCode; - this.errorDetails = errorDetails; - } -} - -export const DEFAULT_TLD = "eth"; - -export const DefaultParseNameError = new ParseNameError( - "Empty name", - ParseNameErrorCode.Empty, - null, -); - -export const hasMissingNameFormat = (label: string) => - new RegExp("\\[([0123456789abcdef]*)\\]").test(label) && label.length === 66; - -const labelhash = (label: string) => labelHash(label); - -const keccak = (input: Buffer | string) => { - let out = null; - if (Buffer.isBuffer(input)) { - out = keccak256(input); - } else { - out = labelhash(input); - } - return out.slice(2); // cut 0x -}; - -const initialNode = - "0000000000000000000000000000000000000000000000000000000000000000"; - -export const namehashFromMissingName = (inputName: string): string => { - let node = initialNode; - - const split = inputName.split("."); - const labels = [split[0].slice(1, -1), keccak(split[1])]; - - for (let i = labels.length - 1; i >= 0; i--) { - const labelSha = labels[i]; - node = keccak(Buffer.from(node + labelSha, "hex")); - } - return "0x" + node; -}; +}; \ No newline at end of file diff --git a/packages/ens-utils/src/ensname.ts b/packages/ens-utils/src/ensname.ts index 27e4ad14a..3be2ce7ac 100644 --- a/packages/ens-utils/src/ensname.ts +++ b/packages/ens-utils/src/ensname.ts @@ -5,7 +5,7 @@ import { labelhash, normalizeEncodedLabelhash, } from "./hashutils"; -import { namehash } from "viem"; +import { keccak256, namehash } from "viem"; export const LABEL_SEPARATOR = "."; export const ETH_TLD = "eth"; @@ -358,3 +358,68 @@ export function getRegistrationPotential(name: ENSName): RegistrationPotential { export function charCount(label: string) { return [...label].length; } + +export enum ParseNameErrorCode { + Empty = "Empty", + TooShort = "TooShort", + UnsupportedTLD = "UnsupportedTLD", + UnsupportedSubdomain = "UnsupportedSubdomain", + MalformedName = "MalformedName", + MalformedLabelHash = "MalformedLabelHash" +} + +export type ParseNameErrorDetails = { + normalizedName: string | null; + displayName: string | null; +}; + +export class ParseNameError extends Error { + public readonly errorCode: ParseNameErrorCode; + public readonly errorDetails: ParseNameErrorDetails | null; + + constructor( + message: string, + errorCode: ParseNameErrorCode, + errorDetails: ParseNameErrorDetails | null + ) { + super(message); + + this.errorCode = errorCode; + this.errorDetails = errorDetails; + } +} + +export const DEFAULT_TLD = ETH_TLD; + +export const DefaultParseNameError = new ParseNameError( + "Empty name", + ParseNameErrorCode.Empty, + null +); + +export const hasMissingNameFormat = (label: string) => new RegExp("\\[([0123456789abcdef]*)\\]").test(label) && label.length === 66; + +export const keccak = (input: Buffer | string) => { + let out = null; + if (Buffer.isBuffer(input)) { + out = keccak256(input); + } else { + out = labelhash(input); + } + return out.slice(2); // cut 0x +}; + +export const initialNode = "0000000000000000000000000000000000000000000000000000000000000000"; + +export const namehashFromMissingName = (inputName: string): string => { + let node = initialNode; + + const split = inputName.split("."); + const labels = [split[0].slice(1, -1), keccak(split[1])]; + + for (let i = labels.length - 1; i >= 0; i--) { + const labelSha = labels[i]; + node = keccak(Buffer.from(node + labelSha, "hex")); + } + return "0x" + node; +}; \ No newline at end of file From a57c826f6d960b4e4369de48a68e33849c8d5385 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Fri, 2 Aug 2024 15:13:42 +0300 Subject: [PATCH 07/27] Move function out of ensname to ethregistrar where it belongs --- packages/ens-utils/src/ensname.ts | 15 --------------- packages/ens-utils/src/ethregistrar.ts | 15 ++++++++++++++- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/ens-utils/src/ensname.ts b/packages/ens-utils/src/ensname.ts index 3be2ce7ac..87dd47245 100644 --- a/packages/ens-utils/src/ensname.ts +++ b/packages/ens-utils/src/ensname.ts @@ -83,21 +83,6 @@ export interface ENSName { node: `0x${string}`; } -export const getDomainLabelFromENSName = (ensName: ENSName): string | null => { - if (ensName.labels.length !== 2) return null; - - if (ensName.labels[1] !== ETH_TLD) return null; - - // NOTE: now we know we have a direct subname of ".eth" - - const subnameLength = charCount(ensName.labels[0]); - - // ensure this subname is even possible to register - if (subnameLength < MIN_ETH_REGISTRABLE_LABEL_LENGTH) return null; - - return ensName.labels[0]; -}; - /** * Compares two sets of labels for deep equality * @param labels1 the first set of labels diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index f08d10ddf..9183308b8 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -3,7 +3,6 @@ import { MIN_ETH_REGISTRABLE_LABEL_LENGTH, ETH_TLD, charCount, - getDomainLabelFromENSName, } from "./ensname"; import { NFTRef, TokenId, buildNFTRef, buildTokenId } from "./nft"; import { namehash, labelhash } from "viem/ens"; @@ -589,3 +588,17 @@ export function domainReleaseTimestamp( const releaseTimestamp = addSeconds(expirationTimestamp, GRACE_PERIOD); return releaseTimestamp; } + +export const getDomainLabelFromENSName = (ensName: ENSName): string | null => { + if (ensName.labels.length !== 2) return null; + + if (ensName.labels[1] !== ETH_TLD) return null; + + // NOTE: now we know we have a direct subname of ".eth" + const subnameLength = charCount(ensName.labels[0]); + + // ensure this subname is even possible to register + if (subnameLength < MIN_ETH_REGISTRABLE_LABEL_LENGTH) return null; + + return ensName.labels[0]; +}; From f3bebbccac94e1330f1932a5591c54f777159603 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Fri, 2 Aug 2024 15:47:39 +0300 Subject: [PATCH 08/27] Refine basePrice related logic for Eth2LD names --- packages/ens-utils/src/ethregistrar.ts | 85 +++++++++++++++----------- packages/ens-utils/src/price.ts | 4 ++ 2 files changed, 54 insertions(+), 35 deletions(-) diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index 9183308b8..aa5e46ecf 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -22,7 +22,9 @@ import { Price, addPrices, approxScalePrice, + buildPrice, formattedPrice, + isEqualPrice, multiplyPriceByNumber, subtractPrices, } from "./price"; @@ -183,8 +185,6 @@ export const TEMPORARY_PREMIUM_PERIOD: Readonly = buildDuration( TEMPORARY_PREMIUM_DAYS * SECONDS_PER_DAY.seconds, ); -export const DOMAIN_HAS_SPECIAL_PRICE_IF_LENGTH_EQUAL_OR_LESS_THAN = 5; - // PRICE TEXT DESCRIPTION ⬇️ /* @@ -253,20 +253,22 @@ export const getPriceDescription = ( descriptiveTextEnd: ".", }; } else { - const ensNameLabel = getDomainLabelFromENSName(ensName); + const basePrice = getEth2LDBasePrice(ensName); + if (basePrice === null) + return null; - if (ensNameLabel === null) return null; + if (isEqualPrice(basePrice, DEFAULT_BASE_PRICE)) { + // domain has standard pricing so no need to provide a special price description + return null; + } - const domainLabelLength = charCount(ensNameLabel); + const labelLength = charCount(ensName.labels[0]); - return domainLabelLength < - DOMAIN_HAS_SPECIAL_PRICE_IF_LENGTH_EQUAL_OR_LESS_THAN - ? { - pricePerYearDescription, - descriptiveTextBeginning: `${domainLabelLength}-character names are `, - descriptiveTextEnd: " to register.", - } - : null; + return { + pricePerYearDescription, + descriptiveTextBeginning: `${labelLength}-character names are `, + descriptiveTextEnd: " to register.", + }; } } }; @@ -404,20 +406,38 @@ export const registrationCurrentTemporaryPremium = ( } }; -const DEFAULT_NAME_PRICE: Readonly = { - value: 500n, - currency: Currency.Usd, -}; -const SHORT_NAME_PREMIUM_PRICE: Record> = { - [MIN_ETH_REGISTRABLE_LABEL_LENGTH]: { - value: 64000n, - currency: Currency.Usd, - }, - 4: { - value: 16000n, - currency: Currency.Usd, - }, -}; +/** + * $5.00 USD + */ +const DEFAULT_BASE_PRICE: Readonly = buildPrice(500n, Currency.Usd); + +/** + * $640.00 USD + */ +const THREE_CHAR_BASE_PRICE: Readonly = buildPrice(64000n, Currency.Usd); + +/** + * $160.00 USD + */ +const FOUR_CHAR_BASE_PRICE: Readonly = buildPrice(16000n, Currency.Usd); + +const getEth2LDBasePrice = (ensName: ENSName): Price | null => { + const label = getEth2LDSubname(ensName); + + if (label === null) + return null; + + const labelLength = charCount(label); + + switch (labelLength) { + case 3: + return THREE_CHAR_BASE_PRICE; + case 4: + return FOUR_CHAR_BASE_PRICE; + default: + return DEFAULT_BASE_PRICE; + } +} /* This is an "internal" helper function only. It can't be directly used anywhere else because @@ -431,13 +451,8 @@ const AvailableNamePriceUSD = ( registration: Registration | null = null, additionalFee: Price | null = null, ): Price | null => { - const ensNameLabel = getDomainLabelFromENSName(ensName); - - if (ensNameLabel === null) return null; - - const basePrice = SHORT_NAME_PREMIUM_PRICE[charCount(ensNameLabel)] - ? SHORT_NAME_PREMIUM_PRICE[charCount(ensNameLabel)] - : DEFAULT_NAME_PRICE; + const basePrice = getEth2LDBasePrice(ensName); + if (basePrice === null) return null; const namePriceForYears = multiplyPriceByNumber( basePrice, @@ -589,7 +604,7 @@ export function domainReleaseTimestamp( return releaseTimestamp; } -export const getDomainLabelFromENSName = (ensName: ENSName): string | null => { +const getEth2LDSubname = (ensName: ENSName): string | null => { if (ensName.labels.length !== 2) return null; if (ensName.labels[1] !== ETH_TLD) return null; diff --git a/packages/ens-utils/src/price.ts b/packages/ens-utils/src/price.ts index 16683fae3..21bc084ab 100644 --- a/packages/ens-utils/src/price.ts +++ b/packages/ens-utils/src/price.ts @@ -18,6 +18,10 @@ export interface Price { currency: Currency; } +export const isEqualPrice = (price1: Price, price2: Price): boolean => { + return price1.currency === price2.currency && price1.value === price2.value; +} + export const priceAsNumber = (price: Price): number => { return ( Number(price.value) / From eec5b92ecbf2dabc01cc16d720a4d92b14721153 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Fri, 2 Aug 2024 16:04:34 +0300 Subject: [PATCH 09/27] Move ideas that apply to any Registrar to registrar.ts --- packages/ens-utils/src/ethregistrar.test.ts | 6 +- packages/ens-utils/src/ethregistrar.ts | 77 ++------------------- packages/ens-utils/src/registrar.ts | 74 ++++++++++++++++++++ 3 files changed, 82 insertions(+), 75 deletions(-) create mode 100644 packages/ens-utils/src/registrar.ts diff --git a/packages/ens-utils/src/ethregistrar.test.ts b/packages/ens-utils/src/ethregistrar.test.ts index 107d96db7..ebfa33bd2 100644 --- a/packages/ens-utils/src/ethregistrar.test.ts +++ b/packages/ens-utils/src/ethregistrar.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from "vitest"; import { - Registrar, UNWRAPPED_MAINNET_ETH_REGISTRAR, WRAPPED_MAINNET_ETH_REGISTRAR, - buildNFTRefFromENSName, } from "./ethregistrar"; +import { buildNFTRefFromENSName } from "./registrar"; +import { Registrar } from "./registrar"; import { ENSName, buildENSName } from "./ensname"; import { MAINNET, SEPOLIA } from "./chain"; import { buildNFTRef } from "./nft"; @@ -84,7 +84,7 @@ describe("getPriceDescription", () => { - pricePerYearDescription is a string that represents: Price + "/ year" (e.g. "$5.99 / year"). In order to return a PriceDescription object, the getPriceDescription function - makes usage of premiumEndsIn function and DOMAIN_HAS_SPECIAL_PRICE_IF_LENGTH_EQUAL_OR_LESS_THAN + makes usage of premiumEndsIn function and DOMAIN_HAS_SPECIAL_PRICE_IF_LENGTH_LESS_THAN constant, defining by condition the descriptiveTextBeginning, pricePerYear and descriptiveTextEnd. For every PriceDescription response, the domain price is get from AvailableNameTimelessPriceUSD. diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index aa5e46ecf..a4e8e8ec8 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -4,10 +4,10 @@ import { ETH_TLD, charCount, } from "./ensname"; -import { NFTRef, TokenId, buildNFTRef, buildTokenId } from "./nft"; +import { TokenId, buildTokenId } from "./nft"; import { namehash, labelhash } from "viem/ens"; import { buildAddress } from "./address"; -import { ChainId, MAINNET } from "./chain"; +import { MAINNET } from "./chain"; import { ContractRef, buildContractRef } from "./contract"; import { Duration, @@ -29,13 +29,7 @@ import { subtractPrices, } from "./price"; import { Currency } from "./currency"; - -export interface Registrar { - contract: ContractRef; - - getTokenId(name: ENSName, isWrapped: boolean): TokenId; - isClaimable(name: ENSName, isWrapped: boolean): boolean; -} +import { KNOWN_REGISTRARS, Registrar } from "./registrar"; // known registrars export const WRAPPED_MAINNET_ETH_REGISTRAR_CONTRACT = buildContractRef( @@ -112,69 +106,8 @@ export const WRAPPED_MAINNET_ETH_REGISTRAR = new NameWrapper( export const UNWRAPPED_MAINNET_ETH_REGISTRAR = new ClassicETHRegistrarController(UNWRAPPED_MAINNET_ETH_REGISTRAR_CONTRACT); -export const KNOWN_REGISTRARS = [ - WRAPPED_MAINNET_ETH_REGISTRAR, - UNWRAPPED_MAINNET_ETH_REGISTRAR, -]; - -export function getPotentialKnownRegistrars( - name: ENSName, - chain: ChainId, - isWrapped: boolean, -): Registrar[] { - return KNOWN_REGISTRARS.filter( - (registrar) => - registrar.contract.chain.chainId === chain.chainId && - registrar.isClaimable(name, isWrapped), - ); -} - -/** - * Identifies the registrar for the provided name, if known. - * - * @param name the name to evaluate. - * @param chainId the id of the chain the name is managed on. - * @param isWrapped if the name is wrapped or not. - * @returns the requested registrar - */ -export function getKnownRegistrar( - name: ENSName, - chain: ChainId, - isWrapped: boolean, -): Registrar { - const registrars = getPotentialKnownRegistrars(name, chain, isWrapped); - if (registrars.length > 1) { - throw new Error( - `Multiple potential registrars found for name: "${name.name}" on chainId: ${chain.chainId} when isWrapped: ${isWrapped}`, - ); - } else if (registrars.length === 0) { - throw new Error( - `No known registrars found for name: "${name.name}" on chainId: ${chain.chainId} when isWrapped: ${isWrapped}`, - ); - } else { - return registrars[0]; - } -} - -export function getKnownPotentialNFTRefs( - name: ENSName, - chain: ChainId, -): NFTRef[] { - const wrappedNFT = buildNFTRefFromENSName(name, chain, true); - const unwrappedNFT = buildNFTRefFromENSName(name, chain, false); - return [wrappedNFT, unwrappedNFT].filter((nft) => nft !== null); -} - -export function buildNFTRefFromENSName( - name: ENSName, - chain: ChainId, - isWrapped: boolean, -): NFTRef { - const registrar = getKnownRegistrar(name, chain, isWrapped); - const token = registrar.getTokenId(name, isWrapped); - - return buildNFTRef(registrar.contract, token); -} +KNOWN_REGISTRARS.push(WRAPPED_MAINNET_ETH_REGISTRAR); +KNOWN_REGISTRARS.push(UNWRAPPED_MAINNET_ETH_REGISTRAR); export const GRACE_PERIOD: Readonly = buildDuration( 90n * SECONDS_PER_DAY.seconds, diff --git a/packages/ens-utils/src/registrar.ts b/packages/ens-utils/src/registrar.ts new file mode 100644 index 000000000..c0bf8e060 --- /dev/null +++ b/packages/ens-utils/src/registrar.ts @@ -0,0 +1,74 @@ +import { ChainId } from "./chain"; +import { ContractRef } from "./contract"; +import { ENSName } from "./ensname"; +import { buildNFTRef, NFTRef, TokenId } from "./nft"; + + +export interface Registrar { + contract: ContractRef; + + getTokenId(name: ENSName, isWrapped: boolean): TokenId; + isClaimable(name: ENSName, isWrapped: boolean): boolean; +} + +// NOTE: Need to add `Registrar` objects to `KNOWN_REGISTRARS` as they are +// defined in order to use +export const KNOWN_REGISTRARS: Registrar[] = []; + +export function buildNFTRefFromENSName( + name: ENSName, + chain: ChainId, + isWrapped: boolean +): NFTRef { + const registrar = getKnownRegistrar(name, chain, isWrapped); + const token = registrar.getTokenId(name, isWrapped); + + return buildNFTRef(registrar.contract, token); +} + +export function getKnownPotentialNFTRefs( + name: ENSName, + chain: ChainId +): NFTRef[] { + const wrappedNFT = buildNFTRefFromENSName(name, chain, true); + const unwrappedNFT = buildNFTRefFromENSName(name, chain, false); + return [wrappedNFT, unwrappedNFT].filter((nft) => nft !== null); +} + +export function getPotentialKnownRegistrars( + name: ENSName, + chain: ChainId, + isWrapped: boolean +): Registrar[] { + return KNOWN_REGISTRARS.filter( + (registrar) => registrar.contract.chain.chainId === chain.chainId && + registrar.isClaimable(name, isWrapped) + ); +} + +/** + * Identifies the registrar for the provided name, if known. + * + * @param name the name to evaluate. + * @param chainId the id of the chain the name is managed on. + * @param isWrapped if the name is wrapped or not. + * @returns the requested registrar + */ +export function getKnownRegistrar( + name: ENSName, + chain: ChainId, + isWrapped: boolean +): Registrar { + const registrars = getPotentialKnownRegistrars(name, chain, isWrapped); + if (registrars.length > 1) { + throw new Error( + `Multiple potential registrars found for name: "${name.name}" on chainId: ${chain.chainId} when isWrapped: ${isWrapped}` + ); + } else if (registrars.length === 0) { + throw new Error( + `No known registrars found for name: "${name.name}" on chainId: ${chain.chainId} when isWrapped: ${isWrapped}` + ); + } else { + return registrars[0]; + } +} \ No newline at end of file From 8515d4a8f018f475ab2e8f6b80a01c9d9bb4e222 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Mon, 5 Aug 2024 23:06:11 +0300 Subject: [PATCH 10/27] Significant refactoring --- packages/ens-utils/src/domain.ts | 2 +- packages/ens-utils/src/ethregistrar.test.ts | 74 +-- packages/ens-utils/src/ethregistrar.ts | 646 ++++++++++---------- packages/ens-utils/src/namekitregistrar.ts | 51 ++ packages/ens-utils/src/nft.test.ts | 71 ++- packages/ens-utils/src/nft.ts | 165 ++++- packages/ens-utils/src/registrar.ts | 253 ++++++-- 7 files changed, 796 insertions(+), 466 deletions(-) create mode 100644 packages/ens-utils/src/namekitregistrar.ts diff --git a/packages/ens-utils/src/domain.ts b/packages/ens-utils/src/domain.ts index 38366b462..a34715efd 100644 --- a/packages/ens-utils/src/domain.ts +++ b/packages/ens-utils/src/domain.ts @@ -1,7 +1,7 @@ import { NFTRef } from "./nft"; import { ENSName } from "./ensname"; import { Address, isAddressEqual } from "./address"; -import { Registration } from "./ethregistrar"; +import { Registration } from "./registrar"; export interface DomainCard { name: ENSName; diff --git a/packages/ens-utils/src/ethregistrar.test.ts b/packages/ens-utils/src/ethregistrar.test.ts index ebfa33bd2..c4ce5704b 100644 --- a/packages/ens-utils/src/ethregistrar.test.ts +++ b/packages/ens-utils/src/ethregistrar.test.ts @@ -1,79 +1,7 @@ -import { describe, it, expect } from "vitest"; -import { - UNWRAPPED_MAINNET_ETH_REGISTRAR, - WRAPPED_MAINNET_ETH_REGISTRAR, -} from "./ethregistrar"; -import { buildNFTRefFromENSName } from "./registrar"; -import { Registrar } from "./registrar"; -import { ENSName, buildENSName } from "./ensname"; -import { MAINNET, SEPOLIA } from "./chain"; -import { buildNFTRef } from "./nft"; +import { describe, it } from "vitest"; // TODO: add a lot more unit tests here -function testNFTRefFromRegistrar( - name: ENSName, - registrar: Registrar, - isWrapped: boolean, -): void { - const expectedToken = registrar.getTokenId(name, isWrapped); - const expectedNFT = buildNFTRef(registrar.contract, expectedToken); - const result = buildNFTRefFromENSName( - name, - registrar.contract.chain, - isWrapped, - ); - expect(result).toStrictEqual(expectedNFT); -} - -describe("buildNFTRefFromENSName", () => { - it("unrecognized registrar", () => { - expect(() => - buildNFTRefFromENSName(buildENSName("foo.eth"), SEPOLIA, false), - ).toThrow(); - }); - - it("unwrapped non-.eth TLD", () => { - expect(() => - buildNFTRefFromENSName(buildENSName("foo.com"), MAINNET, false), - ).toThrow(); - }); - - it("wrapped non-.eth TLD", () => { - const name = buildENSName("foo.com"); - const registrar = WRAPPED_MAINNET_ETH_REGISTRAR; - const isWrapped = true; - testNFTRefFromRegistrar(name, registrar, isWrapped); - }); - - it("unwrapped subname of a .eth subname", () => { - expect(() => - buildNFTRefFromENSName(buildENSName("x.foo.eth"), MAINNET, false), - ).toThrow(); - }); - - it("wrapped subname of a .eth subname", () => { - const name = buildENSName("x.foo.eth"); - const registrar = WRAPPED_MAINNET_ETH_REGISTRAR; - const isWrapped = true; - testNFTRefFromRegistrar(name, registrar, isWrapped); - }); - - it("unwrapped direct subname of .eth", () => { - const name = buildENSName("foo.eth"); - const registrar = UNWRAPPED_MAINNET_ETH_REGISTRAR; - const isWrapped = false; - testNFTRefFromRegistrar(name, registrar, isWrapped); - }); - - it("wrapped direct subname of .eth", () => { - const name = buildENSName("foo.eth"); - const registrar = WRAPPED_MAINNET_ETH_REGISTRAR; - const isWrapped = true; - testNFTRefFromRegistrar(name, registrar, isWrapped); - }); -}); - describe("getPriceDescription", () => { /* The getPriceDescription returns either PriceDescription | null. diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index a4e8e8ec8..af925c705 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -3,18 +3,16 @@ import { MIN_ETH_REGISTRABLE_LABEL_LENGTH, ETH_TLD, charCount, + buildENSName, } from "./ensname"; -import { TokenId, buildTokenId } from "./nft"; -import { namehash, labelhash } from "viem/ens"; -import { buildAddress } from "./address"; -import { MAINNET } from "./chain"; -import { ContractRef, buildContractRef } from "./contract"; +import { buildContractRef, ContractRef } from "./contract"; import { Duration, SECONDS_PER_DAY, Timestamp, addSeconds, buildDuration, + buildTimePeriod, formatTimestampAsDistanceToNow, now, } from "./time"; @@ -24,236 +22,332 @@ import { approxScalePrice, buildPrice, formattedPrice, - isEqualPrice, - multiplyPriceByNumber, subtractPrices, } from "./price"; import { Currency } from "./currency"; -import { KNOWN_REGISTRARS, Registrar } from "./registrar"; +import { + PrimaryRegistrationStatus, + RegistrarChargeType, + RegistrarUnsupportedNameError, + Registration, + scaleAnnualPrice, + SecondaryRegistrationStatus, + RegistrarCharge, + RegistrarTemporaryFee, + RegistrarBaseFee, + RegistrarSpecialNameFee, + RegistrationPriceQuote, + RegistrarAction, + RenewalPriceQuote, + OnchainRegistrar, +} from "./registrar"; +import { MAINNET } from "./chain"; +import { buildAddress } from "./address"; -// known registrars -export const WRAPPED_MAINNET_ETH_REGISTRAR_CONTRACT = buildContractRef( - MAINNET, - buildAddress("0xD4416b13d2b3a9aBae7AcD5D6C2BbDBE25686401"), -); -export const UNWRAPPED_MAINNET_ETH_REGISTRAR_CONTRACT = buildContractRef( - MAINNET, - buildAddress("0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85"), -); +export class EthRegistrar implements OnchainRegistrar { + protected readonly registrarContract: ContractRef; -export class NameWrapper implements Registrar { - constructor(public contract: ContractRef) { - this.contract = contract; + public constructor(registrarContract: ContractRef) { + this.registrarContract = registrarContract; } - getTokenId(name: ENSName, isWrapped: boolean): TokenId { - if (!this.isClaimable(name, isWrapped)) { - throw new Error( - `Wrapped tokenId for name: "${name.name}" is not claimable by registrar: ${this.contract.address} on chainId: ${this.contract.chain.chainId}`, + public getManagedSubname = (name: ENSName): ENSName | null => { + // must have exactly 2 labels to be a direct subname of ".eth" + if (name.labels.length !== 2) return null; + + // last label must be "eth" + if (name.labels[1] !== ETH_TLD) return null; + + // NOTE: now we know we have a direct subname of ".eth" + + // first label must be of sufficient length + const subnameLength = charCount(name.labels[0]); + if (subnameLength < MIN_ETH_REGISTRABLE_LABEL_LENGTH) return null; + + // TODO: also add a check for a maximum length limit as enforced by max block size, etc? + + return buildENSName(name.labels[0]); + }; + + public getValidatedSubname = (name: ENSName): ENSName => { + const subname = this.getManagedSubname(name); + if (subname === null) + throw new RegistrarUnsupportedNameError( + "Name is not directly managed by the .ETH registrar", + name, ); - } - return buildTokenId(BigInt(namehash(name.name))); + + return subname; + }; + + public getOnchainRegistrar(): ContractRef { + return this.registrarContract; } - isClaimable(name: ENSName, isWrapped: boolean): boolean { - if (!isWrapped) return false; + public canRegister( + name: ENSName, + atTimestamp: Timestamp, + duration: Duration, + existingRegistration?: Registration, + ): boolean { + if (!this.getManagedSubname(name)) { + // name is not directly managed by this registrar + return false; + } + + if (existingRegistration) { + const releaseTimestamp = getDomainReleaseTimestamp(existingRegistration); - if (name.labels.length >= 2) { - if (name.labels[1] === ETH_TLD) { - // first label must be of sufficient length - if (charCount(name.labels[0]) < MIN_ETH_REGISTRABLE_LABEL_LENGTH) - return false; + if (releaseTimestamp && releaseTimestamp.time > atTimestamp.time) { + // if the name is not yet released, we can't register it + // TODO: check for possible off-by-1 errors in the logic above + return false; } } - // TODO: refine this. For example, there's a maximum length limit, etc. + if (duration.seconds < 1n) { + // TODO: enforce that `duration` is the minimum duration allowed for a registration + // TODO: need to put the right constant here. + return false; + } + return true; } -} -export class ClassicETHRegistrarController implements Registrar { - constructor(public contract: ContractRef) { - this.contract = contract; + public getRegistrationPriceQuote( + name: ENSName, + atTimestamp: Timestamp, + duration: Duration, + existingRegistration?: Registration, + ): RegistrationPriceQuote { + if (!this.canRegister(name, atTimestamp, duration, existingRegistration)) { + // TODO: refine the way we handle these exception cases. + throw new Error( + `Cannot build registration price quote for name: "${name.name}".`, + ); + } + + const rentalPeriod = buildTimePeriod( + atTimestamp, + addSeconds(atTimestamp, duration), + ); + + let charges = this.getDurationCharges(name, duration); + + let temporaryPremium: RegistrarTemporaryFee | null = null; + + if (existingRegistration) { + temporaryPremium = this.getTemporaryPremiumCharge( + name, + existingRegistration, + atTimestamp, + ); + if (temporaryPremium) { + charges = [...charges, temporaryPremium]; + } + } + + const totalPrice = addPrices(charges.map((charge) => charge.price)); + + return { + action: RegistrarAction.Register, + rentalPeriod, + charges, + totalPrice, + }; } - getTokenId(name: ENSName, isWrapped: boolean): TokenId { - if (!this.isClaimable(name, isWrapped)) { + public canRenew( + name: ENSName, + atTimestamp: Timestamp, + duration: Duration, + existingRegistration: Registration, + ): boolean { + if (!this.getManagedSubname(name)) { + // name is not directly managed by this registrar + return false; + } + + if (existingRegistration) { + if ( + existingRegistration.registrationTimestamp && + atTimestamp.time < existingRegistration.registrationTimestamp.time + ) { + // if the renewal is requested before the registration, we can't renew it + // TODO: check for possible off-by-1 errors in the logic above + return false; + } + + const releaseTimestamp = getDomainReleaseTimestamp(existingRegistration); + + if (releaseTimestamp && releaseTimestamp.time > atTimestamp.time) { + // if the name is released, we can't renew it anymore + // TODO: check for possible off-by-1 errors in the logic above + return false; + } + } + + if (duration.seconds < 1n) { + // always need to renew for at least 1 second + return false; + } + + return true; + } + + public getRenewalPriceQuote( + name: ENSName, + atTimestamp: Timestamp, + duration: Duration, + existingRegistration: Registration, + ): RenewalPriceQuote { + if (!this.canRenew(name, atTimestamp, duration, existingRegistration)) { throw new Error( - `Unwrapped tokenId for name: "${name.name}" is not claimable by registrar: ${this.contract.address} on chainId: ${this.contract.chain.chainId}`, + `Cannot build renewal price quote for name: "${name.name}".`, ); } - return buildTokenId(BigInt(labelhash(name.labels[0]))); - } - isClaimable(name: ENSName, isWrapped: boolean): boolean { - // name must be unwrapped - if (isWrapped) return false; + if (!existingRegistration.expirationTimestamp) { + throw new Error(`Invariant violation`); // TODO: refine message... just making the type system happy. + } - // must have exactly 2 labels to be a direct subname of ".eth" - if (name.labels.length !== 2) return false; + // TODO: review for possible off-by-1 errors + const newExpiration = addSeconds( + existingRegistration.expirationTimestamp, + duration, + ); + const rentalPeriod = buildTimePeriod( + existingRegistration.expirationTimestamp, + newExpiration, + ); - // last label must be "eth" - if (name.labels[1] !== ETH_TLD) return false; + const charges = this.getDurationCharges(name, duration); + const totalPrice = addPrices(charges.map((charge) => charge.price)); - // first label must be of sufficient length - return charCount(name.labels[0]) >= MIN_ETH_REGISTRABLE_LABEL_LENGTH; + return { + action: RegistrarAction.Renew, + rentalPeriod, + charges, + totalPrice, + }; } -} -export const WRAPPED_MAINNET_ETH_REGISTRAR = new NameWrapper( - WRAPPED_MAINNET_ETH_REGISTRAR_CONTRACT, -); -export const UNWRAPPED_MAINNET_ETH_REGISTRAR = - new ClassicETHRegistrarController(UNWRAPPED_MAINNET_ETH_REGISTRAR_CONTRACT); + protected getAnnualCharges(name: ENSName): RegistrarCharge[] { + let baseRate: Price; + let hasSpecialNameFee: boolean; -KNOWN_REGISTRARS.push(WRAPPED_MAINNET_ETH_REGISTRAR); -KNOWN_REGISTRARS.push(UNWRAPPED_MAINNET_ETH_REGISTRAR); + const subname = this.getValidatedSubname(name); + const subnameLength = charCount(subname.name); -export const GRACE_PERIOD: Readonly = buildDuration( - 90n * SECONDS_PER_DAY.seconds, -); -export const TEMPORARY_PREMIUM_DAYS = 21n; + if (subnameLength === 3) { + baseRate = THREE_CHAR_BASE_PRICE; + hasSpecialNameFee = true; + } else if (subnameLength === 4) { + baseRate = FOUR_CHAR_BASE_PRICE; + hasSpecialNameFee = true; + } else { + baseRate = DEFAULT_BASE_PRICE; + hasSpecialNameFee = false; + } -export const TEMPORARY_PREMIUM_PERIOD: Readonly = buildDuration( - TEMPORARY_PREMIUM_DAYS * SECONDS_PER_DAY.seconds, -); + if (!hasSpecialNameFee) { + const baseFee: RegistrarBaseFee = { + type: RegistrarChargeType.BaseFee, + price: baseRate, + }; -// PRICE TEXT DESCRIPTION ⬇️ - -/* - This interface defines data that is used to display the price of a domain - in the Ui. The reason we are separating this text in different fields is because: - - 1. We want to be able to display different texts depending on wether the price of - the domain is a premium price or not. In each one of these cases, the text displayed - is different. - 2. Since the design for this data displaying is differently defined for the price field - and the descriptive text, we separate it so we can render these two fields separately in the - HTML that will be created inside the component. e.g. the price field is bold and the descriptive - text is not. Please refer to this Figma artboard for more details: https:/*www.figma.com/file/lZ8HZaBcfx1xfrgx7WOsB0/Namehash?type=design&node-id=12959-119258&mode=design&t=laEDaXW0rg9nIVn7-0 -*/ -export interface PriceDescription { - /* descriptiveTextBeginning references the text that is displayed before the price */ - descriptiveTextBeginning: string; - /* pricePerYear is a string that represents: Price + "/ year" (e.g. "$5.99 / year") */ - pricePerYearDescription: string; - /* descriptiveTextBeginning references the text that is displayed after the price */ - descriptiveTextEnd: string; -} + return [baseFee]; + } -/** - * Returns a PriceDescription object that contains the price of a domain and a descriptive text. - * @param registration Domain registration data - * @param ensName Domain name, labelhash, namehash, normalization, etc. data - * @returns PriceDescription | null - */ -export const getPriceDescription = ( - registration: Registration, - ensName: ENSName, -): PriceDescription | null => { - const isExpired = - registration.primaryStatus === PrimaryRegistrationStatus.Expired; - const wasRecentlyReleased = - registration.secondaryStatus === - SecondaryRegistrationStatus.RecentlyReleased; - const isRegistered = - registration.primaryStatus === PrimaryRegistrationStatus.Active; - - if (!(isExpired && wasRecentlyReleased) && isRegistered) return null; - const domainBasePrice = AvailableNameTimelessPriceUSD(ensName); - - if (!domainBasePrice) return null; - else { - const domainPrice = formattedPrice({ - price: domainBasePrice, + const priceStr = formattedPrice({ + price: baseRate, withPrefix: true, }); - const pricePerYearDescription = `${domainPrice} / year`; - const premiumEndsIn = premiumPeriodEndsIn(registration)?.relativeTimestamp; + // TODO NOTE FOR FRANCO: The approach here means we don't put special + // formatting on the price at a UI-level anymore for now. It's not worth the + // added complexity right now. + const specialNameFee: RegistrarSpecialNameFee = { + type: RegistrarChargeType.SpecialNameFee, + price: baseRate, + reason: `${subnameLength}-character names are ${priceStr} / year to register.`, + }; - if (premiumEndsIn) { - const premiumEndMessage = premiumEndsIn - ? ` Temporary premium ends ${premiumEndsIn}.` - : null; + return [specialNameFee]; + } + protected getDurationCharges = ( + name: ENSName, + duration: Duration, + ): RegistrarCharge[] => { + const rentalCharges = this.getAnnualCharges(name); + const scaledCharges = rentalCharges.map((charge) => { return { - pricePerYearDescription, - descriptiveTextBeginning: - "Recently released." + - premiumEndMessage + - " Discounts continuously until dropping to ", - descriptiveTextEnd: ".", + ...charge, + price: scaleAnnualPrice(charge.price, duration), }; - } else { - const basePrice = getEth2LDBasePrice(ensName); - if (basePrice === null) - return null; + }); - if (isEqualPrice(basePrice, DEFAULT_BASE_PRICE)) { - // domain has standard pricing so no need to provide a special price description - return null; - } + return scaledCharges; + }; - const labelLength = charCount(ensName.labels[0]); + protected getTemporaryPremiumCharge = ( + name: ENSName, + existingRegistration: Registration, + atTimestamp: Timestamp, + ): RegistrarTemporaryFee | null => { + if (!existingRegistration.expirationTimestamp) return null; - return { - pricePerYearDescription, - descriptiveTextBeginning: `${labelLength}-character names are `, - descriptiveTextEnd: " to register.", - }; - } - } -}; + const temporaryPremiumPrice = getTemporaryPremiumPriceAtTimestamp( + existingRegistration, + atTimestamp, + ); + if (!temporaryPremiumPrice) return null; -// PREMIUM PERIOD TEXT REPORT ⬇️ + const begin = addSeconds( + existingRegistration.expirationTimestamp, + GRACE_PERIOD, + ); + const end = addSeconds(begin, TEMPORARY_PREMIUM_PERIOD); + const premiumPeriod = buildTimePeriod(begin, end); -/* Interface for premium period end details */ -export interface PremiumPeriodEndsIn { - relativeTimestamp: string; - timestamp: Timestamp; -} + const standardAnnualCharges = this.getAnnualCharges(name); + const standardAnnualPrice = addPrices( + standardAnnualCharges.map((charge) => charge.price), + ); -/** - * Determines if a domain is in its premium period and returns the end timestamp and a human-readable distance to it. - * @param domainCard: DomainCard - * @returns PremiumPeriodEndsIn | null. Null if the domain is not in its premium period - * (to be, it should be expired and recently released). - */ -export const premiumPeriodEndsIn = ( - registration: Registration, -): PremiumPeriodEndsIn | null => { - const isExpired = - registration.primaryStatus === PrimaryRegistrationStatus.Expired; - const wasRecentlyReleased = - registration.secondaryStatus === - SecondaryRegistrationStatus.RecentlyReleased; + const priceStr = formattedPrice({ + price: standardAnnualPrice, + withPrefix: true, + }); - /* - A domain will only have a premium price if it has Expired and it was Recently Released - */ - if (!isExpired || !wasRecentlyReleased) return null; + const premiumEndsIn = formatTimestampAsDistanceToNow(premiumPeriod.end); - /* - This conditional should always be true because expiryTimestamp will only be null when - the domain was never registered before. Considering that the domain is Expired, - it means that it was registered before. It is just a type safety check. - */ - if (!registration.expiryTimestamp) return null; + // TODO NOTE FOR FRANCO: The approach here means we don't put special + // formatting on the price at a UI-level anymore for now. It's not worth the + // added complexity right now. + const temporaryPremiumReason = `Recently released. Temporary premium ends ${premiumEndsIn}. Discounts continuously until dropping to ${priceStr} / year.`; - const releasedEpoch = addSeconds(registration.expiryTimestamp, GRACE_PERIOD); - const temporaryPremiumEndTimestamp = addSeconds( - releasedEpoch, - TEMPORARY_PREMIUM_PERIOD, - ); + const temporaryFee: RegistrarTemporaryFee = { + type: RegistrarChargeType.TemporaryFee, + price: temporaryPremiumPrice, + reason: temporaryPremiumReason, + validity: premiumPeriod, // NOTE: This provides the exact `Timestamp` when the temporary premium is scheduled to begin and end. + }; - return { - relativeTimestamp: formatTimestampAsDistanceToNow( - temporaryPremiumEndTimestamp, - ), - timestamp: temporaryPremiumEndTimestamp, + return temporaryFee; }; -}; +} + +export const GRACE_PERIOD: Readonly = buildDuration( + 90n * SECONDS_PER_DAY.seconds, +); + +export const TEMPORARY_PREMIUM_DAYS = 21n; + +export const TEMPORARY_PREMIUM_PERIOD: Readonly = buildDuration( + TEMPORARY_PREMIUM_DAYS * SECONDS_PER_DAY.seconds, +); // REGISTRATION PRICE ⬇️ @@ -289,22 +383,25 @@ export const PREMIUM_OFFSET = approxScalePrice( ); /** + * @param registration The registration to calculate the temporary premium price for. * @param atTimestamp Timestamp. The moment to calculate the temporary premium price. - * @param expirationTimestamp Timestamp. The moment a name expires. - * @returns Price. The temporary premium price at the moment of `atTimestamp`. + * @returns Price. The temporary premium price at the moment of `atTimestamp` or `null` if there is no + * known temporary premium `atTimestamp`. */ -export function temporaryPremiumPriceAtTimestamp( +const getTemporaryPremiumPriceAtTimestamp = ( + registration: Registration, atTimestamp: Timestamp, - expirationTimestamp: Timestamp, -): Price { - const releasedTimestamp = addSeconds(expirationTimestamp, GRACE_PERIOD); +): Price | null => { + if (!registration.expirationTimestamp) return null; + + const releasedTimestamp = addSeconds( + registration.expirationTimestamp, + GRACE_PERIOD, + ); const secondsSinceRelease = atTimestamp.time - releasedTimestamp.time; if (secondsSinceRelease < 0) { /* if as of the moment of `atTimestamp` a name hasn't expired yet then there is no temporaryPremium */ - return { - value: 0n, - currency: Currency.Usd, - }; + return null; } const fractionalDaysSinceRelease = @@ -315,28 +412,12 @@ export function temporaryPremiumPriceAtTimestamp( const decayedPrice = approxScalePrice(PREMIUM_START_PRICE, decayFactor); const offsetDecayedPrice = subtractPrices(decayedPrice, PREMIUM_OFFSET); - /* the temporary premium can never be less than $0.00 */ if (offsetDecayedPrice.value < 0n) { - return { - value: 0n, - currency: Currency.Usd, - }; + /* the temporary premium can never be less than $0.00 */ + return null; } return offsetDecayedPrice; -} - -export const registrationCurrentTemporaryPremium = ( - registration: Registration, -): Price | null => { - if (registration.expirationTimestamp) { - return temporaryPremiumPriceAtTimestamp( - now(), - registration.expirationTimestamp, - ); - } else { - return null; - } }; /** @@ -354,97 +435,6 @@ const THREE_CHAR_BASE_PRICE: Readonly = buildPrice(64000n, Currency.Usd); */ const FOUR_CHAR_BASE_PRICE: Readonly = buildPrice(16000n, Currency.Usd); -const getEth2LDBasePrice = (ensName: ENSName): Price | null => { - const label = getEth2LDSubname(ensName); - - if (label === null) - return null; - - const labelLength = charCount(label); - - switch (labelLength) { - case 3: - return THREE_CHAR_BASE_PRICE; - case 4: - return FOUR_CHAR_BASE_PRICE; - default: - return DEFAULT_BASE_PRICE; - } -} - -/* - This is an "internal" helper function only. It can't be directly used anywhere else because - it is too easy to accidently not include the registration object when it should be passed. - Three different functions are created right below this one, which are the ones that are - safe to be used across the platform, and are then, the ones being exported. -*/ -const AvailableNamePriceUSD = ( - ensName: ENSName, - registerForYears = DEFAULT_REGISTRATION_YEARS, - registration: Registration | null = null, - additionalFee: Price | null = null, -): Price | null => { - const basePrice = getEth2LDBasePrice(ensName); - if (basePrice === null) return null; - - const namePriceForYears = multiplyPriceByNumber( - basePrice, - Number(registerForYears), - ); - - const resultPrice = additionalFee - ? addPrices([additionalFee, namePriceForYears]) - : namePriceForYears; - - if (registration) { - const premiumPrice = registrationCurrentTemporaryPremium(registration); - - return premiumPrice ? addPrices([premiumPrice, resultPrice]) : resultPrice; - } - - return resultPrice; -}; - -const DEFAULT_REGISTRATION_YEARS = 1; - -/* - Below function returns the "timeless" price for a name, that takes no consideration - of the current status of the name. This is useful for various cases, including in - generating messages that communicate how much a name costs to renew, how much - a name will cost at the end of a premium period, etc.. -*/ -export const AvailableNameTimelessPriceUSD = ( - ensName: ENSName, - registerForYears = DEFAULT_REGISTRATION_YEARS, -) => { - return AvailableNamePriceUSD(ensName, registerForYears); -}; - -// REGISTRATION STATUSES ⬇️ - -export enum PrimaryRegistrationStatus { - Active = "Active", - Expired = "Expired", - NeverRegistered = "NeverRegistered", -} - -export enum SecondaryRegistrationStatus { - ExpiringSoon = "ExpiringSoon", - FullyReleased = "FullyReleased", - GracePeriod = "GracePeriod", - RecentlyReleased = "RecentlyReleased", -} - -export type Registration = { - // Below timestamps are counted in seconds - registrationTimestamp: Timestamp | null; - expirationTimestamp: Timestamp | null; - expiryTimestamp: Timestamp | null; - - primaryStatus: PrimaryRegistrationStatus; - secondaryStatus: SecondaryRegistrationStatus | null; -}; - export const getDomainRegistration = ( /* When null, a domain is considered to be not registered. @@ -504,22 +494,6 @@ const getSecondaryRegistrationStatus = ( } }; -// EXPIRATION STATUS ⬇️ - -/** - * Returns the expiration timestamp of a domain - * @param domainRegistration Registration object from domain - * @returns Timestamp | null - */ -export function domainExpirationTimestamp( - domainRegistration: Registration, -): Timestamp | null { - if (domainRegistration.expirationTimestamp) { - return domainRegistration.expirationTimestamp; - } - return null; -} - // RELEASE STATUS ⬇️ /** @@ -527,26 +501,30 @@ export function domainExpirationTimestamp( * @param domainRegistration Registration object from domain * @returns Timestamp | null */ -export function domainReleaseTimestamp( +export function getDomainReleaseTimestamp( domainRegistration: Registration, ): Timestamp | null { - const expirationTimestamp = domainExpirationTimestamp(domainRegistration); - if (expirationTimestamp === null) return null; + if (!domainRegistration.expirationTimestamp) return null; - const releaseTimestamp = addSeconds(expirationTimestamp, GRACE_PERIOD); - return releaseTimestamp; + return addSeconds(domainRegistration.expirationTimestamp, GRACE_PERIOD); } -const getEth2LDSubname = (ensName: ENSName): string | null => { - if (ensName.labels.length !== 2) return null; - - if (ensName.labels[1] !== ETH_TLD) return null; +export const MAINNET_WRAPPING_ETH_REGISTRAR_CONTROLLER_CONTRACT = + buildContractRef( + MAINNET, + buildAddress("0x253553366Da8546fC250F225fe3d25d0C782303b"), + ); - // NOTE: now we know we have a direct subname of ".eth" - const subnameLength = charCount(ensName.labels[0]); +export const MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER_CONTRACT = + buildContractRef( + MAINNET, + buildAddress("0x283af0b28c62c092c9727f1ee09c02ca627eb7f5"), + ); - // ensure this subname is even possible to register - if (subnameLength < MIN_ETH_REGISTRABLE_LABEL_LENGTH) return null; +export const MAINNET_WRAPPING_ETH_REGISTRAR_CONTROLLER = new EthRegistrar( + MAINNET_WRAPPING_ETH_REGISTRAR_CONTROLLER_CONTRACT, +); - return ensName.labels[0]; -}; +export const MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER = new EthRegistrar( + MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER_CONTRACT, +); diff --git a/packages/ens-utils/src/namekitregistrar.ts b/packages/ens-utils/src/namekitregistrar.ts new file mode 100644 index 000000000..9cd9ee4e7 --- /dev/null +++ b/packages/ens-utils/src/namekitregistrar.ts @@ -0,0 +1,51 @@ +import { buildAddress } from "./address"; +import { MAINNET } from "./chain"; +import { buildContractRef, ContractRef } from "./contract"; +import { Currency } from "./currency"; +import { charCount, ENSName } from "./ensname"; +import { EthRegistrar } from "./ethregistrar"; +import { buildPrice, Price } from "./price"; +import { + RegistrarCharge, + RegistrarChargeType, + RegistrarServiceFee, +} from "./registrar"; + +// example of how to modify a pricing policy to add a service fee to the base registration rate +export class NameKitRegistrar extends EthRegistrar { + public constructor(registrarContract: ContractRef) { + super(registrarContract); + } + + protected getAnnualCharges(name: ENSName): RegistrarCharge[] { + const baseCharges = super.getAnnualCharges(name); + const subname = this.getValidatedSubname(name); + const subnameLength = charCount(subname.name); + let annualServiceFee: Price; + if (subnameLength === 3 || subnameLength == 4) { + annualServiceFee = buildPrice(999n, Currency.Usd); // $9.99 USD + } else { + annualServiceFee = buildPrice(99n, Currency.Usd); // $0.99 USD + } + + const serviceFee: RegistrarServiceFee = { + type: RegistrarChargeType.ServiceFee, + price: annualServiceFee, + reason: "Example NameKit convenience fee.", // TODO: update this + }; + + return [...baseCharges, serviceFee]; + } +} + +// NOTE: This is an example deployment of a NameKitRegistrar contract on the Ethereum Mainnet. +// You're not supposed to use this yourself directly. You should deploy your own instance of +// this contract. +export const MAINNET_EXAMPLE_NAMEKIT_REGISTRAR_CONTRACT = buildContractRef( + MAINNET, + buildAddress("0x232332263e6e4bd8a134b238975e2200c8b7dac1"), +); + +export const MAINNET_EXAMPLE_NAMEKIT_REGISTRAR = new NameKitRegistrar( + MAINNET_EXAMPLE_NAMEKIT_REGISTRAR_CONTRACT +); \ No newline at end of file diff --git a/packages/ens-utils/src/nft.test.ts b/packages/ens-utils/src/nft.test.ts index 0f6d1ce2c..f98fb1dca 100644 --- a/packages/ens-utils/src/nft.test.ts +++ b/packages/ens-utils/src/nft.test.ts @@ -1,14 +1,18 @@ import { describe, it, expect } from "vitest"; - import { buildNFTRef, convertNFTRefToString, buildNFTReferenceFromString, buildTokenId, + NFTIssuer, + buildNFTRefFromENSName, + MAINNET_NAMEWRAPPER, + MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION, } from "./nft"; -import { MAINNET } from "./chain"; +import { MAINNET, SEPOLIA } from "./chain"; import { buildContractRef } from "./contract"; import { buildAddress } from "./address"; +import { buildENSName, ENSName } from "./ensname"; describe("buildTokenId() function", () => { @@ -109,4 +113,67 @@ describe("buildNFTReferenceFromString() function", () => { expect(result).toStrictEqual(nft); }); +}); + +function testNFTRefFromIssuer( + name: ENSName, + issuer: NFTIssuer, + isWrapped: boolean, +): void { + const expectedToken = issuer.getTokenId(name, isWrapped); + const expectedNFT = buildNFTRef(issuer.getNftContract(), expectedToken); + const result = buildNFTRefFromENSName( + name, + issuer.getNftContract().chain, + isWrapped, + ); + expect(result).toStrictEqual(expectedNFT); +} + +describe("buildNFTRefFromENSName", () => { + it("unrecognized registrar", () => { + expect(() => + buildNFTRefFromENSName(buildENSName("foo.eth"), SEPOLIA, false), + ).toThrow(); + }); + + it("unwrapped non-.eth TLD", () => { + expect(() => + buildNFTRefFromENSName(buildENSName("foo.com"), MAINNET, false), + ).toThrow(); + }); + + it("wrapped non-.eth TLD", () => { + const name = buildENSName("foo.com"); + const registrar = MAINNET_NAMEWRAPPER; + const isWrapped = true; + testNFTRefFromIssuer(name, registrar, isWrapped); + }); + + it("unwrapped subname of a .eth subname", () => { + expect(() => + buildNFTRefFromENSName(buildENSName("x.foo.eth"), MAINNET, false), + ).toThrow(); + }); + + it("wrapped subname of a .eth subname", () => { + const name = buildENSName("x.foo.eth"); + const registrar = MAINNET_NAMEWRAPPER; + const isWrapped = true; + testNFTRefFromIssuer(name, registrar, isWrapped); + }); + + it("unwrapped direct subname of .eth", () => { + const name = buildENSName("foo.eth"); + const registrar = MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION; + const isWrapped = false; + testNFTRefFromIssuer(name, registrar, isWrapped); + }); + + it("wrapped direct subname of .eth", () => { + const name = buildENSName("foo.eth"); + const registrar = MAINNET_NAMEWRAPPER; + const isWrapped = true; + testNFTRefFromIssuer(name, registrar, isWrapped); + }); }); \ No newline at end of file diff --git a/packages/ens-utils/src/nft.ts b/packages/ens-utils/src/nft.ts index cd14d47f6..ac9f0883b 100644 --- a/packages/ens-utils/src/nft.ts +++ b/packages/ens-utils/src/nft.ts @@ -1,6 +1,8 @@ +import { labelhash, namehash } from "viem"; import { buildAddress } from "./address"; -import { buildChainId } from "./chain"; +import { buildChainId, ChainId, MAINNET } from "./chain"; import { ContractRef, buildContractRef } from "./contract"; +import { charCount, ENSName, ETH_TLD, MIN_ETH_REGISTRABLE_LABEL_LENGTH } from "./ensname"; export interface TokenId { /** @@ -106,4 +108,165 @@ export const buildNFTReferenceFromString = ( const tokenId = buildTokenId(parts[2]); return buildNFTRef(contract, tokenId); +} + +export interface NFTIssuer { + getNftContract(): ContractRef; + getTokenId(name: ENSName, isWrapped: boolean): TokenId; + isClaimable(name: ENSName, isWrapped: boolean): boolean; +} + +export class NameWrapper implements NFTIssuer { + + private readonly contract: ContractRef; + + public constructor(contract: ContractRef) { + this.contract = contract; + } + + public getNftContract(): ContractRef { + return this.contract; + } + + public getTokenId(name: ENSName, isWrapped: boolean): TokenId { + if (!this.isClaimable(name, isWrapped)) { + throw new Error( + `Wrapped tokenId for name: "${name.name}" is not claimable by registrar: ${this.contract.address.address} on chainId: ${this.contract.chain.chainId}` + ); + } + return buildTokenId(BigInt(namehash(name.name))); + } + + public isClaimable(name: ENSName, isWrapped: boolean): boolean { + + // TODO: build a more sophisticated implementation of this function + // for now, we just assume that all wrapped names are claimable by the NameWrapper + return isWrapped; + } + } + + export class ETHBaseRegistrarImplementation implements NFTIssuer { + + private readonly contract: ContractRef; + + public constructor(contract: ContractRef) { + this.contract = contract; + } + + public getNftContract(): ContractRef { + return this.contract; + } + + public getTokenId(name: ENSName, isWrapped: boolean): TokenId { + if (!this.isClaimable(name, isWrapped)) { + throw new Error( + `Unwrapped tokenId for name: "${name.name}" is not claimable by registrar: ${this.contract.address.address} on chainId: ${this.contract.chain.chainId}` + ); + } + return buildTokenId(BigInt(labelhash(name.labels[0]))); + } + + public isClaimable(name: ENSName, isWrapped: boolean): boolean { + // name must be unwrapped + if (isWrapped) return false; + + // must have exactly 2 labels to be a direct subname of ".eth" + if (name.labels.length !== 2) return false; + + // last label must be "eth" + if (name.labels[1] !== ETH_TLD) return false; + + // NOTE: now we know we have a direct subname of ".eth" + + // first label must be of sufficient length + const subnameLength = charCount(name.labels[0]); + if (subnameLength < MIN_ETH_REGISTRABLE_LABEL_LENGTH) return false; + + // TODO: also add a check for a maximum length limit as enforced by max block size, etc? + + return true; + } + } + +// known `NFTIssuer` contracts + +export const MAINNET_NAMEWRAPPER_CONTRACT = buildContractRef( + MAINNET, + buildAddress("0xD4416b13d2b3a9aBae7AcD5D6C2BbDBE25686401") +); + +export const MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION_CONTRACT = buildContractRef( + MAINNET, + buildAddress("0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85") +); + +export const MAINNET_NAMEWRAPPER = new NameWrapper( + MAINNET_NAMEWRAPPER_CONTRACT +); + +export const MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION = new ETHBaseRegistrarImplementation(MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION_CONTRACT); + +// NOTE: Need to add `NFTIssuer` objects to `KNOWN_NFT_ISSUERS` as they are +// defined in order to use +export const KNOWN_NFT_ISSUERS: NFTIssuer[] = []; + +KNOWN_NFT_ISSUERS.push(MAINNET_NAMEWRAPPER); +KNOWN_NFT_ISSUERS.push(MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION); + +export function buildNFTRefFromENSName( + name: ENSName, + chain: ChainId, + isWrapped: boolean +): NFTRef { + const issuer = getKnownNFTIssuer(name, chain, isWrapped); + const token = issuer.getTokenId(name, isWrapped); + + return buildNFTRef(issuer.getNftContract(), token); +} + +export function getKnownPotentialNFTRefs( + name: ENSName, + chain: ChainId +): NFTRef[] { + const wrappedNFT = buildNFTRefFromENSName(name, chain, true); + const unwrappedNFT = buildNFTRefFromENSName(name, chain, false); + return [wrappedNFT, unwrappedNFT].filter((nft) => nft !== null); +} + +export function getPotentialKnownIssuers( + name: ENSName, + chain: ChainId, + isWrapped: boolean +): NFTIssuer[] { + return KNOWN_NFT_ISSUERS.filter( + (issuer) => issuer.getNftContract().chain.chainId === chain.chainId && + issuer.isClaimable(name, isWrapped) + ); +} + +/** + * Identifies the `NFTIssuer` for the provided name, if known. + * + * @param name the name to evaluate. + * @param chainId the id of the chain the name is managed on. + * @param isWrapped if the name is wrapped or not. + * @returns the requested `NFTIssuer` + */ +export function getKnownNFTIssuer( + name: ENSName, + chain: ChainId, + isWrapped: boolean +): NFTIssuer { + const issuers = getPotentialKnownIssuers(name, chain, isWrapped); + if (issuers.length > 1) { + throw new Error( + `Multiple potential NFT issuers found for name: "${name.name}" on chainId: ${chain.chainId} when isWrapped: ${isWrapped}` + ); + } else if (issuers.length === 0) { + throw new Error( + `No known NFT issuers found for name: "${name.name}" on chainId: ${chain.chainId} when isWrapped: ${isWrapped}` + ); + } else { + return issuers[0]; + } } \ No newline at end of file diff --git a/packages/ens-utils/src/registrar.ts b/packages/ens-utils/src/registrar.ts index c0bf8e060..5baf4e12b 100644 --- a/packages/ens-utils/src/registrar.ts +++ b/packages/ens-utils/src/registrar.ts @@ -1,74 +1,217 @@ -import { ChainId } from "./chain"; import { ContractRef } from "./contract"; import { ENSName } from "./ensname"; -import { buildNFTRef, NFTRef, TokenId } from "./nft"; +import { NFTIssuer } from "./nft"; +import { approxScalePrice, Price } from "./price"; +import { Duration, SECONDS_PER_YEAR, TimePeriod, Timestamp } from "./time"; +// REGISTRATION STATUSES ⬇️ -export interface Registrar { - contract: ContractRef; +// TODO: make more generic to support non .eth 2nd level domains +export enum PrimaryRegistrationStatus { + Active = "Active", + Expired = "Expired", + NeverRegistered = "NeverRegistered" +} + +// TODO: make more generic to support non .eth 2nd level domains +export enum SecondaryRegistrationStatus { + ExpiringSoon = "ExpiringSoon", + FullyReleased = "FullyReleased", + GracePeriod = "GracePeriod", + RecentlyReleased = "RecentlyReleased" +} + +// TODO: make more generic to support non .eth 2nd level domains +export type Registration = { + // Below timestamps are counted in seconds + registrationTimestamp: Timestamp | null; + expirationTimestamp: Timestamp | null; + expiryTimestamp: Timestamp | null; // TODO: Franco, could you please remove this for us? + + primaryStatus: PrimaryRegistrationStatus; + secondaryStatus: SecondaryRegistrationStatus | null; +}; + +/** + * An action that may be taken on a name through a `Registrar`. + */ +export const RegistrarAction = { + /** Create a new registration. */ + Register: "register", + + /** Extend an existing registration. */ + Renew: "renew", +} as const; + +export type RegistrarAction = (typeof RegistrarAction)[keyof typeof RegistrarAction]; + +/** + * The type of registrar charge that is being applied to the domain. + */ +export const RegistrarChargeType = { + /** The base fee for the name. */ + BaseFee: "base-fee", + + /** A fee charged for names with special attributes. */ + SpecialNameFee: "special-name-fee", + + /** A temporary fee charged after a name has been recently released. */ + TemporaryFee: "recently-released-fee", + + /** A service fee. */ + ServiceFee: "service-fee", +} as const; + +export type RegistrarChargeType = (typeof RegistrarChargeType)[keyof typeof RegistrarChargeType]; + +/** + * A single distinct charge that must be paid to a `Registrar` to register or + * renew a domain. + */ +export interface AbstractRegistrarCharge { + + /** + * The type of `RegistrarCharge` that is being applied by the `Registrar` to + * rent the domain. + */ + type: RegistrarChargeType; + + /** + * The price of the `RegistrarCharge` that must be paid to the `Registrar` to + * rent the domain. + */ + price: Price; + + /** + * The reason why the `RegistrarCharge` is being applied by the `Registrar` + * to rent the domain. + */ + reason?: string; + + /** + * The period of time that the `RegistrarCharge` is valid for. + * + * If `null`, the `RegistrarCharge` is not guaranteed to be time-limited. + */ + validity?: TimePeriod; +} - getTokenId(name: ENSName, isWrapped: boolean): TokenId; - isClaimable(name: ENSName, isWrapped: boolean): boolean; +export interface RegistrarBaseFee extends AbstractRegistrarCharge { + type: typeof RegistrarChargeType.BaseFee; + price: Price; + reason?: undefined; + validity?: undefined; } -// NOTE: Need to add `Registrar` objects to `KNOWN_REGISTRARS` as they are -// defined in order to use -export const KNOWN_REGISTRARS: Registrar[] = []; +export interface RegistrarSpecialNameFee extends AbstractRegistrarCharge { + type: typeof RegistrarChargeType.SpecialNameFee; + price: Price; + reason: string; + validity?: undefined; +} + +export interface RegistrarTemporaryFee extends AbstractRegistrarCharge { + type: typeof RegistrarChargeType.TemporaryFee; + price: Price; + reason: string; + validity: TimePeriod; +} + +export interface RegistrarServiceFee extends AbstractRegistrarCharge { + type: typeof RegistrarChargeType.ServiceFee; + price: Price; + reason?: string; + validity?: undefined; +} + +export type RegistrarCharge = RegistrarBaseFee | RegistrarSpecialNameFee | RegistrarTemporaryFee | RegistrarServiceFee; + +/** + * A price quote from a `Registrar` to register or renew a domain for a given + * period of time. + */ +export interface AbstractRegistrarPriceQuote { + + /** + * The action associated with this `RegistrarPriceQuote`. + */ + action: RegistrarAction; + + /** + * The `TimePeriod` that this `RegistrarPriceQuote` is for. + */ + rentalPeriod: TimePeriod; + + /** + * The set of distinct `RegistrarCharge` values that must be paid to the + * `Registrar` to rent the domain for the period of `rentalPeriod`. + * + * May be empty if the domain is free to rent for `rentalPeriod`. + */ + charges: RegistrarCharge[]; + + /** + * The total price to rent the domain for the period of `rentalPeriod`. + * + * This is the sum of `charges` in the unit of currency that should be paid + * to the registrar. + */ + totalPrice: Price; +} -export function buildNFTRefFromENSName( - name: ENSName, - chain: ChainId, - isWrapped: boolean -): NFTRef { - const registrar = getKnownRegistrar(name, chain, isWrapped); - const token = registrar.getTokenId(name, isWrapped); +export interface RegistrationPriceQuote extends AbstractRegistrarPriceQuote { + action: typeof RegistrarAction.Register; +} - return buildNFTRef(registrar.contract, token); +export interface RenewalPriceQuote extends AbstractRegistrarPriceQuote { + action: typeof RegistrarAction.Renew; } -export function getKnownPotentialNFTRefs( - name: ENSName, - chain: ChainId -): NFTRef[] { - const wrappedNFT = buildNFTRefFromENSName(name, chain, true); - const unwrappedNFT = buildNFTRefFromENSName(name, chain, false); - return [wrappedNFT, unwrappedNFT].filter((nft) => nft !== null); +export interface Registrar { + getManagedSubname(name: ENSName): ENSName | null; + + // NOTE: Throws RegistrarUnsupportedNameError if name is not supported by this registrar + getValidatedSubname(name: ENSName): ENSName; + + getOnchainRegistrar(): ContractRef | null; + + canRegister(name: ENSName, atTimestamp: Timestamp, duration: Duration, existingRegistration?: Registration): boolean; + getRegistrationPriceQuote(name: ENSName, atTimestamp: Timestamp, duration: Duration, existingRegistration?: Registration): RegistrationPriceQuote; + + canRenew(name: ENSName, atTimestamp: Timestamp, duration: Duration, existingRegistration: Registration): boolean; + getRenewalPriceQuote(name: ENSName, atTimestamp: Timestamp, duration: Duration, existingRegistration: Registration): RenewalPriceQuote; } -export function getPotentialKnownRegistrars( - name: ENSName, - chain: ChainId, - isWrapped: boolean -): Registrar[] { - return KNOWN_REGISTRARS.filter( - (registrar) => registrar.contract.chain.chainId === chain.chainId && - registrar.isClaimable(name, isWrapped) - ); +export interface OnchainRegistrar extends Registrar { + getOnchainRegistrar(): ContractRef; +} + +export interface OffchainRegistrar extends Registrar { + getOnchainRegistrar(): null; } /** - * Identifies the registrar for the provided name, if known. - * - * @param name the name to evaluate. - * @param chainId the id of the chain the name is managed on. - * @param isWrapped if the name is wrapped or not. - * @returns the requested registrar + * Identifies that a name was passed to a `Registrar` function that was not relevant for that `Registrar`. + * + * @param message a reason why `name` was NOT relevant for `Registrar`. + * @param label the `ENSName` value that was NOT relevant. */ -export function getKnownRegistrar( - name: ENSName, - chain: ChainId, - isWrapped: boolean -): Registrar { - const registrars = getPotentialKnownRegistrars(name, chain, isWrapped); - if (registrars.length > 1) { - throw new Error( - `Multiple potential registrars found for name: "${name.name}" on chainId: ${chain.chainId} when isWrapped: ${isWrapped}` - ); - } else if (registrars.length === 0) { - throw new Error( - `No known registrars found for name: "${name.name}" on chainId: ${chain.chainId} when isWrapped: ${isWrapped}` - ); - } else { - return registrars[0]; +export class RegistrarUnsupportedNameError extends Error { + + public constructor(message: string, name: ENSName) { + super(`RegistrarUnsupportedNameError for name "${name.name}": ${message}`); + this.name = "RegistrarUnsupportedNameError"; + Error.captureStackTrace(this, this.constructor); } +} + +export const scaleAnnualPrice = (annualPrice: Price, duration: Duration) => { + // Small performance optimization if no scaling is needed + if (duration.seconds === SECONDS_PER_YEAR.seconds) + return annualPrice; + + // TODO: verify we're doing this division of bigints correctly + const scaledRate = Number(duration.seconds) / Number(SECONDS_PER_YEAR.seconds); + + return approxScalePrice(annualPrice, scaledRate); } \ No newline at end of file From fc782d513791ee71fc18e9a0db06396999b6202d Mon Sep 17 00:00:00 2001 From: "frankind.eth" <49823133+FrancoAguzzi@users.noreply.github.com> Date: Tue, 6 Aug 2024 17:01:01 -0300 Subject: [PATCH 11/27] Update packages/ens-utils/src/domain.ts --- packages/ens-utils/src/domain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ens-utils/src/domain.ts b/packages/ens-utils/src/domain.ts index a34715efd..1f88b04a3 100644 --- a/packages/ens-utils/src/domain.ts +++ b/packages/ens-utils/src/domain.ts @@ -91,4 +91,4 @@ export const getCurrentUserOwnership = ( } return UserOwnershipOfDomain.NotOwner; -}; \ No newline at end of file +}; From 08d52b3bade543a1d511ff2f2061b0b2a3b11944 Mon Sep 17 00:00:00 2001 From: "frankind.eth" <49823133+FrancoAguzzi@users.noreply.github.com> Date: Tue, 6 Aug 2024 17:01:09 -0300 Subject: [PATCH 12/27] Update packages/ens-utils/src/ensname.ts --- packages/ens-utils/src/ensname.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ens-utils/src/ensname.ts b/packages/ens-utils/src/ensname.ts index 87dd47245..ee1ac6ca9 100644 --- a/packages/ens-utils/src/ensname.ts +++ b/packages/ens-utils/src/ensname.ts @@ -407,4 +407,4 @@ export const namehashFromMissingName = (inputName: string): string => { node = keccak(Buffer.from(node + labelSha, "hex")); } return "0x" + node; -}; \ No newline at end of file +}; From 00e5d09fecbd43e395cab7bc9bddf080195774c7 Mon Sep 17 00:00:00 2001 From: "frankind.eth" <49823133+FrancoAguzzi@users.noreply.github.com> Date: Tue, 6 Aug 2024 17:01:15 -0300 Subject: [PATCH 13/27] Update packages/ens-utils/src/registrar.ts --- packages/ens-utils/src/registrar.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ens-utils/src/registrar.ts b/packages/ens-utils/src/registrar.ts index 5baf4e12b..b94686e62 100644 --- a/packages/ens-utils/src/registrar.ts +++ b/packages/ens-utils/src/registrar.ts @@ -214,4 +214,4 @@ export const scaleAnnualPrice = (annualPrice: Price, duration: Duration) => { const scaledRate = Number(duration.seconds) / Number(SECONDS_PER_YEAR.seconds); return approxScalePrice(annualPrice, scaledRate); -} \ No newline at end of file +} From 909f0cd37e6b5ee5dfc82383576eb98a2d3e43a8 Mon Sep 17 00:00:00 2001 From: "frankind.eth" <49823133+FrancoAguzzi@users.noreply.github.com> Date: Tue, 6 Aug 2024 20:36:21 -0300 Subject: [PATCH 14/27] Update packages/ens-utils/src/namekitregistrar.ts --- packages/ens-utils/src/namekitregistrar.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ens-utils/src/namekitregistrar.ts b/packages/ens-utils/src/namekitregistrar.ts index 9cd9ee4e7..5f9dc352c 100644 --- a/packages/ens-utils/src/namekitregistrar.ts +++ b/packages/ens-utils/src/namekitregistrar.ts @@ -48,4 +48,4 @@ export const MAINNET_EXAMPLE_NAMEKIT_REGISTRAR_CONTRACT = buildContractRef( export const MAINNET_EXAMPLE_NAMEKIT_REGISTRAR = new NameKitRegistrar( MAINNET_EXAMPLE_NAMEKIT_REGISTRAR_CONTRACT -); \ No newline at end of file +); From b62ee828bd27e571e34818fb40bcb692057e475b Mon Sep 17 00:00:00 2001 From: FrancoAguzzi Date: Tue, 6 Aug 2024 20:57:13 -0300 Subject: [PATCH 15/27] feat: update registrar.ts docs --- packages/ens-utils/src/registrar.ts | 85 +++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 24 deletions(-) diff --git a/packages/ens-utils/src/registrar.ts b/packages/ens-utils/src/registrar.ts index b94686e62..e4488f833 100644 --- a/packages/ens-utils/src/registrar.ts +++ b/packages/ens-utils/src/registrar.ts @@ -10,7 +10,7 @@ import { Duration, SECONDS_PER_YEAR, TimePeriod, Timestamp } from "./time"; export enum PrimaryRegistrationStatus { Active = "Active", Expired = "Expired", - NeverRegistered = "NeverRegistered" + NeverRegistered = "NeverRegistered", } // TODO: make more generic to support non .eth 2nd level domains @@ -18,7 +18,7 @@ export enum SecondaryRegistrationStatus { ExpiringSoon = "ExpiringSoon", FullyReleased = "FullyReleased", GracePeriod = "GracePeriod", - RecentlyReleased = "RecentlyReleased" + RecentlyReleased = "RecentlyReleased", } // TODO: make more generic to support non .eth 2nd level domains @@ -43,7 +43,8 @@ export const RegistrarAction = { Renew: "renew", } as const; -export type RegistrarAction = (typeof RegistrarAction)[keyof typeof RegistrarAction]; +export type RegistrarAction = + (typeof RegistrarAction)[keyof typeof RegistrarAction]; /** * The type of registrar charge that is being applied to the domain. @@ -62,14 +63,14 @@ export const RegistrarChargeType = { ServiceFee: "service-fee", } as const; -export type RegistrarChargeType = (typeof RegistrarChargeType)[keyof typeof RegistrarChargeType]; +export type RegistrarChargeType = + (typeof RegistrarChargeType)[keyof typeof RegistrarChargeType]; /** * A single distinct charge that must be paid to a `Registrar` to register or * renew a domain. */ export interface AbstractRegistrarCharge { - /** * The type of `RegistrarCharge` that is being applied by the `Registrar` to * rent the domain. @@ -90,7 +91,7 @@ export interface AbstractRegistrarCharge { /** * The period of time that the `RegistrarCharge` is valid for. - * + * * If `null`, the `RegistrarCharge` is not guaranteed to be time-limited. */ validity?: TimePeriod; @@ -124,14 +125,17 @@ export interface RegistrarServiceFee extends AbstractRegistrarCharge { validity?: undefined; } -export type RegistrarCharge = RegistrarBaseFee | RegistrarSpecialNameFee | RegistrarTemporaryFee | RegistrarServiceFee; +export type RegistrarCharge = + | RegistrarBaseFee + | RegistrarSpecialNameFee + | RegistrarTemporaryFee + | RegistrarServiceFee; /** * A price quote from a `Registrar` to register or renew a domain for a given * period of time. */ export interface AbstractRegistrarPriceQuote { - /** * The action associated with this `RegistrarPriceQuote`. */ @@ -145,14 +149,14 @@ export interface AbstractRegistrarPriceQuote { /** * The set of distinct `RegistrarCharge` values that must be paid to the * `Registrar` to rent the domain for the period of `rentalPeriod`. - * + * * May be empty if the domain is free to rent for `rentalPeriod`. */ charges: RegistrarCharge[]; /** * The total price to rent the domain for the period of `rentalPeriod`. - * + * * This is the sum of `charges` in the unit of currency that should be paid * to the registrar. */ @@ -167,6 +171,18 @@ export interface RenewalPriceQuote extends AbstractRegistrarPriceQuote { action: typeof RegistrarAction.Renew; } +/* + Top-Level Domains (TLDs), like .eth and .test, are owned by smart contracts + called registrars, which specify rules governing the allocation of their names. + Enabling seamless interoperability with the DNS (Domain Name System). + + The source of truth for a name and its subdomains does not always have to be on-chain + or on Ethereum L1 at all. By leveraging EIP-3668, the Cross Chain Interoperability + Protocol (or CCIP Read for short), we can load information by hitting a so called + "Gateway". Within the context of ENS, this enables us, to read names, addresses, + records, and more from other chains, or even off-chain. This is where the + concept of "on-chain registrars" and "off-chain registrars" comes into play. +*/ export interface Registrar { getManagedSubname(name: ENSName): ENSName | null; @@ -175,29 +191,50 @@ export interface Registrar { getOnchainRegistrar(): ContractRef | null; - canRegister(name: ENSName, atTimestamp: Timestamp, duration: Duration, existingRegistration?: Registration): boolean; - getRegistrationPriceQuote(name: ENSName, atTimestamp: Timestamp, duration: Duration, existingRegistration?: Registration): RegistrationPriceQuote; - - canRenew(name: ENSName, atTimestamp: Timestamp, duration: Duration, existingRegistration: Registration): boolean; - getRenewalPriceQuote(name: ENSName, atTimestamp: Timestamp, duration: Duration, existingRegistration: Registration): RenewalPriceQuote; + canRegister( + name: ENSName, + atTimestamp: Timestamp, + duration: Duration, + existingRegistration?: Registration, + ): boolean; + getRegistrationPriceQuote( + name: ENSName, + atTimestamp: Timestamp, + duration: Duration, + existingRegistration?: Registration, + ): RegistrationPriceQuote; + + canRenew( + name: ENSName, + atTimestamp: Timestamp, + duration: Duration, + existingRegistration: Registration, + ): boolean; + getRenewalPriceQuote( + name: ENSName, + atTimestamp: Timestamp, + duration: Duration, + existingRegistration: Registration, + ): RenewalPriceQuote; } +// Allows for trustless on-chain name registration export interface OnchainRegistrar extends Registrar { getOnchainRegistrar(): ContractRef; } +// Allows for trustless off-chain name registration export interface OffchainRegistrar extends Registrar { getOnchainRegistrar(): null; } /** - * Identifies that a name was passed to a `Registrar` function that was not relevant for that `Registrar`. - * - * @param message a reason why `name` was NOT relevant for `Registrar`. - * @param label the `ENSName` value that was NOT relevant. + * Identifies that a name was passed to a `Registrar` function that is not managed by that `Registrar`. + * + * @param message a reason why `name` is NOT managed by `Registrar`. + * @param label the `ENSName` value that is NOT managed by `Registrar`. */ export class RegistrarUnsupportedNameError extends Error { - public constructor(message: string, name: ENSName) { super(`RegistrarUnsupportedNameError for name "${name.name}": ${message}`); this.name = "RegistrarUnsupportedNameError"; @@ -207,11 +244,11 @@ export class RegistrarUnsupportedNameError extends Error { export const scaleAnnualPrice = (annualPrice: Price, duration: Duration) => { // Small performance optimization if no scaling is needed - if (duration.seconds === SECONDS_PER_YEAR.seconds) - return annualPrice; + if (duration.seconds === SECONDS_PER_YEAR.seconds) return annualPrice; // TODO: verify we're doing this division of bigints correctly - const scaledRate = Number(duration.seconds) / Number(SECONDS_PER_YEAR.seconds); + const scaledRate = + Number(duration.seconds) / Number(SECONDS_PER_YEAR.seconds); return approxScalePrice(annualPrice, scaledRate); -} +}; From d582ba9b7ae5640bd0f0aefa094f092a1b40ac19 Mon Sep 17 00:00:00 2001 From: FrancoAguzzi Date: Tue, 6 Aug 2024 20:57:35 -0300 Subject: [PATCH 16/27] feat: create registrar.ts unit tests --- packages/ens-utils/src/registrar.test.ts | 77 ++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 packages/ens-utils/src/registrar.test.ts diff --git a/packages/ens-utils/src/registrar.test.ts b/packages/ens-utils/src/registrar.test.ts new file mode 100644 index 000000000..0f844c092 --- /dev/null +++ b/packages/ens-utils/src/registrar.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { scaleAnnualPrice } from "./registrar"; +import { buildPrice } from "./price"; +import { Currency } from "./currency"; +import { buildDuration, SECONDS_PER_YEAR } from "./time"; + +describe("scaleAnnualPrice() function", (t) => { + it("should scale a one year price to a half an year price", () => { + const price = buildPrice(100n, Currency.Usd); + const years = 0.5; + const secondsPerYearAsNumber = Number(SECONDS_PER_YEAR.seconds); + const duration = BigInt(secondsPerYearAsNumber * years); + const secondsPerYears = buildDuration(duration); + + const expectedResult = { + value: BigInt(Number(price.value) * years), + currency: "USD", + }; + + const result = scaleAnnualPrice(price, secondsPerYears); + + expect(result).toEqual(expectedResult); + }); + it("should scale a one year price to an one and a half an year price", () => { + const price = buildPrice(100n, Currency.Usd); + const years = 1.5; + const secondsPerYearAsNumber = Number(SECONDS_PER_YEAR.seconds); + const duration = BigInt(secondsPerYearAsNumber * years); + const secondsPerYears = buildDuration(duration); + + const expectedResult = { + value: BigInt(Number(price.value) * years), + currency: "USD", + }; + + const result = scaleAnnualPrice(price, secondsPerYears); + + expect(result).toEqual(expectedResult); + }); + it("should scale a one year price to a two years price", () => { + const price = buildPrice(100n, Currency.Usd); + const years = 2n; + const secondsPerYears = buildDuration(SECONDS_PER_YEAR.seconds * years); + + const expectedResult = { + value: price.value * years, + currency: "USD", + }; + + const result = scaleAnnualPrice(price, secondsPerYears); + + expect(result).toEqual(expectedResult); + }); + it("should scale a one year price to a five years price", () => { + const price = buildPrice(100n, Currency.Usd); + const years = 5n; + const secondsPerYears = buildDuration(SECONDS_PER_YEAR.seconds * years); + + const expectedResult = { + value: price.value * years, + currency: "USD", + }; + + const result = scaleAnnualPrice(price, secondsPerYears); + + expect(result).toEqual(expectedResult); + }); + it("should not scale the price if the duration is the same as current price duration", () => { + const price = buildPrice(100n, Currency.Usd); + const years = 1n; + const secondsPerYears = buildDuration(SECONDS_PER_YEAR.seconds * years); + + const result = scaleAnnualPrice(price, secondsPerYears); + + expect(result).toEqual(result); + }); +}); From c8440adb4e67f1c5d359fffb529d1288e7bf07df Mon Sep 17 00:00:00 2001 From: FrancoAguzzi Date: Tue, 6 Aug 2024 20:57:45 -0300 Subject: [PATCH 17/27] feat: update ethregistrar.ts docs --- packages/ens-utils/src/ethregistrar.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index af925c705..7272f7178 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -44,6 +44,13 @@ import { import { MAINNET } from "./chain"; import { buildAddress } from "./address"; +/* + The ETH Registrar is the registrar for the .eth TLD, + it allows for trustless decentralized names to be issued + as tokens on the Ethereum Blockchain. Registration is done + through smart-contracts, and name ownership is secured by the + Ethereum blockchain. +*/ export class EthRegistrar implements OnchainRegistrar { protected readonly registrarContract: ContractRef; From 231a82acb7e043124c6c2e3658627610f732fdf3 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Wed, 7 Aug 2024 20:30:37 +0200 Subject: [PATCH 18/27] Remove comments that aren't necessarily correct --- packages/ens-utils/src/registrar.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ens-utils/src/registrar.ts b/packages/ens-utils/src/registrar.ts index e4488f833..631f57fcd 100644 --- a/packages/ens-utils/src/registrar.ts +++ b/packages/ens-utils/src/registrar.ts @@ -218,12 +218,10 @@ export interface Registrar { ): RenewalPriceQuote; } -// Allows for trustless on-chain name registration export interface OnchainRegistrar extends Registrar { getOnchainRegistrar(): ContractRef; } -// Allows for trustless off-chain name registration export interface OffchainRegistrar extends Registrar { getOnchainRegistrar(): null; } From ae49965af5fb43c59d144b985bbe610df68aca4b Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Thu, 8 Aug 2024 00:09:28 +0200 Subject: [PATCH 19/27] Multiple refinements --- packages/ens-utils/src/ethregistrar.ts | 28 +++-- packages/ens-utils/src/registrar.test.ts | 69 +++++------ packages/ens-utils/src/registrar.ts | 148 +++++++++++++++++++---- 3 files changed, 173 insertions(+), 72 deletions(-) diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index 7272f7178..9c2c8ce42 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -40,24 +40,32 @@ import { RegistrarAction, RenewalPriceQuote, OnchainRegistrar, + MAINNET_ENS_REGISTRY_WITH_FALLBACK_CONTRACT, } from "./registrar"; import { MAINNET } from "./chain"; import { buildAddress } from "./address"; -/* - The ETH Registrar is the registrar for the .eth TLD, - it allows for trustless decentralized names to be issued - as tokens on the Ethereum Blockchain. Registration is done - through smart-contracts, and name ownership is secured by the - Ethereum blockchain. -*/ +/** + * The `EthRegistrar` models the policy implmentations shared by both of + * the `OnchainRegistrar` contracts that actively issue subnames for the .eth + * TLD (as of July 2024). + * + * These registrars enable trustless decentralized subnames to be issued + * as NFTs on Ethereum L1. + */ export class EthRegistrar implements OnchainRegistrar { + public static readonly Name = buildENSName(ETH_TLD); + protected readonly registrarContract: ContractRef; public constructor(registrarContract: ContractRef) { this.registrarContract = registrarContract; } + public getName = (): ENSName => { + return EthRegistrar.Name; + } + public getManagedSubname = (name: ENSName): ENSName | null => { // must have exactly 2 labels to be a direct subname of ".eth" if (name.labels.length !== 2) return null; @@ -87,10 +95,14 @@ export class EthRegistrar implements OnchainRegistrar { return subname; }; - public getOnchainRegistrar(): ContractRef { + public getContractRef(): ContractRef { return this.registrarContract; } + public getOnchainRegistry(): ContractRef { + return MAINNET_ENS_REGISTRY_WITH_FALLBACK_CONTRACT; + } + public canRegister( name: ENSName, atTimestamp: Timestamp, diff --git a/packages/ens-utils/src/registrar.test.ts b/packages/ens-utils/src/registrar.test.ts index 0f844c092..616e4b2b2 100644 --- a/packages/ens-utils/src/registrar.test.ts +++ b/packages/ens-utils/src/registrar.test.ts @@ -2,76 +2,61 @@ import { describe, expect, it } from "vitest"; import { scaleAnnualPrice } from "./registrar"; import { buildPrice } from "./price"; import { Currency } from "./currency"; -import { buildDuration, SECONDS_PER_YEAR } from "./time"; +import { scaleDuration, SECONDS_PER_YEAR } from "./time"; describe("scaleAnnualPrice() function", (t) => { it("should scale a one year price to a half an year price", () => { - const price = buildPrice(100n, Currency.Usd); + const annualPrice = buildPrice(100n, Currency.Usd); const years = 0.5; - const secondsPerYearAsNumber = Number(SECONDS_PER_YEAR.seconds); - const duration = BigInt(secondsPerYearAsNumber * years); - const secondsPerYears = buildDuration(duration); + const duration = scaleDuration(SECONDS_PER_YEAR, years); + const result = scaleAnnualPrice(annualPrice, duration); - const expectedResult = { - value: BigInt(Number(price.value) * years), - currency: "USD", - }; - - const result = scaleAnnualPrice(price, secondsPerYears); + const expectedResult = buildPrice(50n, Currency.Usd); expect(result).toEqual(expectedResult); }); + it("should scale a one year price to an one and a half an year price", () => { - const price = buildPrice(100n, Currency.Usd); + const annualPrice = buildPrice(100n, Currency.Usd); const years = 1.5; - const secondsPerYearAsNumber = Number(SECONDS_PER_YEAR.seconds); - const duration = BigInt(secondsPerYearAsNumber * years); - const secondsPerYears = buildDuration(duration); - - const expectedResult = { - value: BigInt(Number(price.value) * years), - currency: "USD", - }; + const duration = scaleDuration(SECONDS_PER_YEAR, years); + const result = scaleAnnualPrice(annualPrice, duration); - const result = scaleAnnualPrice(price, secondsPerYears); + const expectedResult = buildPrice(150n, Currency.Usd); expect(result).toEqual(expectedResult); }); + it("should scale a one year price to a two years price", () => { - const price = buildPrice(100n, Currency.Usd); + const annualPrice = buildPrice(100n, Currency.Usd); const years = 2n; - const secondsPerYears = buildDuration(SECONDS_PER_YEAR.seconds * years); - - const expectedResult = { - value: price.value * years, - currency: "USD", - }; + const duration = scaleDuration(SECONDS_PER_YEAR, years); + const result = scaleAnnualPrice(annualPrice, duration); - const result = scaleAnnualPrice(price, secondsPerYears); + const expectedResult = buildPrice(200n, Currency.Usd); expect(result).toEqual(expectedResult); }); + it("should scale a one year price to a five years price", () => { - const price = buildPrice(100n, Currency.Usd); + const annualPrice = buildPrice(100n, Currency.Usd); const years = 5n; - const secondsPerYears = buildDuration(SECONDS_PER_YEAR.seconds * years); - - const expectedResult = { - value: price.value * years, - currency: "USD", - }; + const duration = scaleDuration(SECONDS_PER_YEAR, years); + const result = scaleAnnualPrice(annualPrice, duration); - const result = scaleAnnualPrice(price, secondsPerYears); + const expectedResult = buildPrice(500n, Currency.Usd); expect(result).toEqual(expectedResult); }); - it("should not scale the price if the duration is the same as current price duration", () => { - const price = buildPrice(100n, Currency.Usd); + + it("should scale a one year price to a one years price", () => { + const annualPrice = buildPrice(100n, Currency.Usd); const years = 1n; - const secondsPerYears = buildDuration(SECONDS_PER_YEAR.seconds * years); + const duration = scaleDuration(SECONDS_PER_YEAR, years); + const result = scaleAnnualPrice(annualPrice, duration); - const result = scaleAnnualPrice(price, secondsPerYears); + const expectedResult = buildPrice(100n, Currency.Usd); - expect(result).toEqual(result); + expect(result).toEqual(expectedResult); }); }); diff --git a/packages/ens-utils/src/registrar.ts b/packages/ens-utils/src/registrar.ts index 631f57fcd..1ba1f289e 100644 --- a/packages/ens-utils/src/registrar.ts +++ b/packages/ens-utils/src/registrar.ts @@ -1,4 +1,6 @@ -import { ContractRef } from "./contract"; +import { buildAddress } from "./address"; +import { MAINNET } from "./chain"; +import { buildContractRef, ContractRef } from "./contract"; import { ENSName } from "./ensname"; import { NFTIssuer } from "./nft"; import { approxScalePrice, Price } from "./price"; @@ -171,25 +173,81 @@ export interface RenewalPriceQuote extends AbstractRegistrarPriceQuote { action: typeof RegistrarAction.Renew; } -/* - Top-Level Domains (TLDs), like .eth and .test, are owned by smart contracts - called registrars, which specify rules governing the allocation of their names. - Enabling seamless interoperability with the DNS (Domain Name System). - - The source of truth for a name and its subdomains does not always have to be on-chain - or on Ethereum L1 at all. By leveraging EIP-3668, the Cross Chain Interoperability - Protocol (or CCIP Read for short), we can load information by hitting a so called - "Gateway". Within the context of ENS, this enables us, to read names, addresses, - records, and more from other chains, or even off-chain. This is where the - concept of "on-chain registrars" and "off-chain registrars" comes into play. -*/ +/** + * A `Registrar` in NameKit aims to provide a standardized "common denominator" + * interface for interacting with one of the smart contracts responsible for + * issuing subdomains in ENS. + * + * ENS enables a `Registrar` to be configured at any level of the ENS domain + * hierarchy. For example, ENS has a `Registrar` for the overall ENS root. + * Beneath the root, ENS has a `Registrar` for the `.eth` TLD. Beneath `.eth`, + * there is the `Registrar` for `uni.eth`, and so on. + * + * NOTE: ENS enables an infinite set of possible registrar implementations. + * NameKit aims for `Registrar` to support the registrar implementations that + * are most popular within the ENS community, however some registrar + * implementations may include policies that fall outside the range of what a + * `Registrar` in NameKit is capable of modeling. If this happens, please + * contact the team at NameHash Labs to discuss how we might better support + * your registrar of interest. + */ export interface Registrar { + + /** + * @returns the name that this `Registrar` issues subnames for. + */ + getName(): ENSName; + + /** + * Checks if the provided `name` is a subname issued by this `Registrar`. + * + * @param name the name to check if it is issued by this `Registrar`. + * @returns the subname of `name` that is issued by this `Registrar`, or + * `null` if `name` is not a subname issued by this `Registrar`. + * @example + * // in the case that `getName()` for the `Registrar` is "cb.id" + * getManagedSubname(buildENSName("abc.cb.id")) => buildENSName("abc") + * @example + * // in the case that `getName()` for the `Registrar` is "eth" + * getManagedSubname(buildENSName("abc.cb.id")) => null + */ getManagedSubname(name: ENSName): ENSName | null; - // NOTE: Throws RegistrarUnsupportedNameError if name is not supported by this registrar + /** + * Gets the subname of `name` that is issued by this `Registrar`. + * + * @param name the name to get the issued subname of that was issued by this + * `Registrar`. + * @returns the subname of `name` that is issued by this `Registrar`. + * @throws `RegistrarUnsupportedNameError` if `name` is not a subname issued + * by this `Registrar`. + */ getValidatedSubname(name: ENSName): ENSName; - getOnchainRegistrar(): ContractRef | null; + /** + * Gets the `ContractRef` for where the registrar being modeled by this + * `Registrar` is found onchain. + * + * NOTE: The returned `ContractRef` may not be the only contract with the + * ability to serve as a subname registrar for `getName()`. + * + * @returns the requested `ContractRef`. + */ + getContractRef(): ContractRef; + + /** + * Gets the `ContractRef` of the registry that this `Registrar` records + * subdomain registrations in. + * + * If a `Registrar` issues subnames onchain then generally this returns + * `MAINNET_ENS_REGISTRY_WITH_FALLBACK_CONTRACT`. + * + * If the `Registrar` issues subnames offchain then this returns `null`. + * + * @returns the requested `ContractRef` or `null` if the `Registrar` issues + * subnames offchain. + */ + getOnchainRegistry(): ContractRef | null; canRegister( name: ENSName, @@ -197,6 +255,7 @@ export interface Registrar { duration: Duration, existingRegistration?: Registration, ): boolean; + getRegistrationPriceQuote( name: ENSName, atTimestamp: Timestamp, @@ -210,6 +269,7 @@ export interface Registrar { duration: Duration, existingRegistration: Registration, ): boolean; + getRenewalPriceQuote( name: ENSName, atTimestamp: Timestamp, @@ -218,23 +278,43 @@ export interface Registrar { ): RenewalPriceQuote; } +/** + * Models a `Registrar` that issues subdomains into an onchain registry. + * + * An `OnchainRegistrar` may live on Ethereum L1 or elsewhere on an L2, etc. + */ export interface OnchainRegistrar extends Registrar { - getOnchainRegistrar(): ContractRef; + getOnchainRegistry(): ContractRef; } +/** + * Models a `Registrar` that issues subdomains into an offchain registry + * through the use of ENSIP-10 (also known as "Wildcard Resolution"). + * + * Subdomains issued into an offchain registry as generally refered to as + * "offchain subnames". + * + * See https://docs.ens.domains/ensip/10 for more info. + * + * Generally an `OffchainRegistrar` also makes use of the Cross Chain + * Interoperability Protocol (also known as EIP-3668 or CCIP-Read for short) to + * provide offchain management of resolver records for the offchain subnames it + * issues. + */ export interface OffchainRegistrar extends Registrar { - getOnchainRegistrar(): null; + getOnchainRegistry(): null; } /** - * Identifies that a name was passed to a `Registrar` function that is not managed by that `Registrar`. + * Identifies that a name was passed to a `Registrar` function that is not + * issuable by that `Registrar`. * - * @param message a reason why `name` is NOT managed by `Registrar`. - * @param label the `ENSName` value that is NOT managed by `Registrar`. + * @param message a reason why `name` is NOT issuable by `Registrar`. + * @param label the `ENSName` value that is NOT issuable by `Registrar`. */ export class RegistrarUnsupportedNameError extends Error { - public constructor(message: string, name: ENSName) { - super(`RegistrarUnsupportedNameError for name "${name.name}": ${message}`); + public constructor(message: string, unsupportedName: ENSName) { + super(`RegistrarUnsupportedNameError for name "${unsupportedName.name}": ${message}`); this.name = "RegistrarUnsupportedNameError"; Error.captureStackTrace(this, this.constructor); } @@ -250,3 +330,27 @@ export const scaleAnnualPrice = (annualPrice: Price, duration: Duration) => { return approxScalePrice(annualPrice, scaledRate); }; + +/** + * This is the current ENS registry. + * + * Given the lookup of a node for a name in this registry, this contract + * first attempts to find the data for that node in its own internal registry. + * If that internal lookup fails, this contract then attempts to lookup the + * same request in `MAINNET_OLD_ENS_REGISTRY` as a fallback in the case that + * the requested node hasn't been migrated to this (new) registry yet. + */ +export const MAINNET_ENS_REGISTRY_WITH_FALLBACK_CONTRACT = + buildContractRef( + MAINNET, + buildAddress("0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e"), + ); + +/** + * This is the old ENS registry. No new subnames should be issued here. + */ +export const MAINNET_OLD_ENS_REGISTRY = + buildContractRef( + MAINNET, + buildAddress("0x314159265dD8dbb310642f98f50C066173C1259b"), + ); \ No newline at end of file From 442b24a7eba202af41739d585e7321fa059c4304 Mon Sep 17 00:00:00 2001 From: FrancoAguzzi Date: Thu, 8 Aug 2024 18:16:14 -0300 Subject: [PATCH 20/27] fix: typo in unit test description --- packages/ens-utils/src/registrar.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ens-utils/src/registrar.test.ts b/packages/ens-utils/src/registrar.test.ts index 616e4b2b2..eb242ad08 100644 --- a/packages/ens-utils/src/registrar.test.ts +++ b/packages/ens-utils/src/registrar.test.ts @@ -49,7 +49,7 @@ describe("scaleAnnualPrice() function", (t) => { expect(result).toEqual(expectedResult); }); - it("should scale a one year price to a one years price", () => { + it("should scale a one year price to a one year price", () => { const annualPrice = buildPrice(100n, Currency.Usd); const years = 1n; const duration = scaleDuration(SECONDS_PER_YEAR, years); From 1192de5a9810b54f3959aad80b6c39d63d06da2d Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Tue, 13 Aug 2024 15:35:19 +0200 Subject: [PATCH 21/27] Refine min renewal duration --- packages/ens-utils/src/ethregistrar.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index 9c2c8ce42..b48c775e8 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -206,10 +206,8 @@ export class EthRegistrar implements OnchainRegistrar { } } - if (duration.seconds < 1n) { - // always need to renew for at least 1 second + if (duration.seconds < MIN_RENEWAL_DURATION.seconds) return false; - } return true; } @@ -358,12 +356,23 @@ export class EthRegistrar implements OnchainRegistrar { }; } +/** + * 1 second. + */ +export const MIN_RENEWAL_DURATION: Readonly = buildDuration(1n); + +/** + * 90 days. + */ export const GRACE_PERIOD: Readonly = buildDuration( 90n * SECONDS_PER_DAY.seconds, ); export const TEMPORARY_PREMIUM_DAYS = 21n; +/** + * 21 days. + */ export const TEMPORARY_PREMIUM_PERIOD: Readonly = buildDuration( TEMPORARY_PREMIUM_DAYS * SECONDS_PER_DAY.seconds, ); From 6c13884c1d3a64de78e222b440194ceee117f204 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Tue, 13 Aug 2024 16:16:02 +0200 Subject: [PATCH 22/27] Refine time related logic --- packages/ens-utils/src/ethregistrar.ts | 114 +++++++++++++++++++++---- 1 file changed, 98 insertions(+), 16 deletions(-) diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index b48c775e8..cc11d7391 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -15,6 +15,7 @@ import { buildTimePeriod, formatTimestampAsDistanceToNow, now, + scaleDuration, } from "./time"; import { Price, @@ -124,11 +125,8 @@ export class EthRegistrar implements OnchainRegistrar { } } - if (duration.seconds < 1n) { - // TODO: enforce that `duration` is the minimum duration allowed for a registration - // TODO: need to put the right constant here. + if (!isValidRegistrationDuration(duration)) return false; - } return true; } @@ -357,25 +355,109 @@ export class EthRegistrar implements OnchainRegistrar { } /** + * The minimum days a .eth name can be registered for. + * + * This value is enforced by EthRegistrarController contracts. + */ +export const MIN_REGISTRATION_PERIOD_DAYS = 28n; + +/** + * The minimum Duration a .eth name can be registered for. + * + * This value is enforced by EthRegistrarController contracts. + * + * 28 days or 2,419,200 seconds. + */ +export const MIN_REGISTRATION_PERIOD: Readonly = scaleDuration(SECONDS_PER_DAY, MIN_REGISTRATION_PERIOD_DAYS); + +/** + * The minimum Duration a .eth name can be renewed for. + * * 1 second. + * + * This value is enforced by EthRegistrarController contracts. */ export const MIN_RENEWAL_DURATION: Readonly = buildDuration(1n); /** - * 90 days. + * The maximum days before the registration of a .eth name expires when we + * consider it helpful to provide a more visible notice that the name expires + * soon and should be renewed as soon as possible to avoid loss. + * + * This is an arbitrary value we selected for UX purposes. It is not an ENS + * standard and is not enforced by any EthRegistrarController contracts. */ -export const GRACE_PERIOD: Readonly = buildDuration( - 90n * SECONDS_PER_DAY.seconds, -); +export const MAX_EXPIRING_SOON_PERIOD_DAYS = 90n; -export const TEMPORARY_PREMIUM_DAYS = 21n; +/** + * The Duration before the registration of a .eth name expires when we + * consider it helpful to provide a more visible notice that the name expires + * soon and should be renewed as soon as possible to avoid loss. + * + * This is an arbitrary value we selected for UX purposes. It is not an ENS + * standard and is not enforced by any EthRegistrarController contracts. + * + * 90 days or 7,776,000 seconds. + */ +export const MAX_EXPIRING_SOON_PERIOD: Readonly = scaleDuration(SECONDS_PER_DAY, MAX_EXPIRING_SOON_PERIOD_DAYS); /** - * 21 days. + * The number of days an expired registration of a .eth name is in a grace + * period prior to being released to the public. + * + * This value is enforced by EthRegistrarController contracts. */ -export const TEMPORARY_PREMIUM_PERIOD: Readonly = buildDuration( - TEMPORARY_PREMIUM_DAYS * SECONDS_PER_DAY.seconds, -); +export const GRACE_PERIOD_DAYS = 90n; + +/** + * The Duration an expired registration of a .eth name is in a grace period + * prior to being released to the public. + * + * This value is enforced by EthRegistrarController contracts. + * + * 90 days or 7,776,000 seconds. + */ +export const GRACE_PERIOD: Readonly = scaleDuration(SECONDS_PER_DAY, GRACE_PERIOD_DAYS); + +/** + * The number of days a recently released .eth name has a temporary premium + * price applied. + * + * This value is enforced by EthRegistrarController contracts. + */ +export const TEMPORARY_PREMIUM_PERIOD_DAYS = 21n; + +/** + * The Duration a recently released .eth name has a temporary premium price applied. + * + * This value is enforced by EthRegistrarController contracts. + * + * 21 days or 1,814,400 seconds. + */ +export const TEMPORARY_PREMIUM_PERIOD: Readonly = scaleDuration(SECONDS_PER_DAY, TEMPORARY_PREMIUM_PERIOD_DAYS); + +/** + * Identifies if the provided `duration` of registration would be accepted by + * EthRegistrarController contracts. + * + * @param duration The registration duration to evaluate. + * @returns true if the provided `duration` is valid, false otherwise. + */ +const isValidRegistrationDuration = (duration: Duration): boolean => { + return duration.seconds >= MIN_REGISTRATION_PERIOD.seconds; +} + +/** + * Validates that the provided `duration` of registration would be accepted by + * EthRegistrarController contracts. + * + * @param duration The registration duration to evaluate. + * @throws Error if the provided `duration` is not valid. + */ +const validateRegistrationDuration = (duration: Duration): void => { + if (!isValidRegistrationDuration(duration)) + throw new Error(`Invalid registration duration: ${duration.seconds} seconds. Minimum registration period is ${MIN_REGISTRATION_PERIOD_DAYS} days or ${MIN_REGISTRATION_PERIOD.seconds} seconds.`); +} // REGISTRATION PRICE ⬇️ @@ -383,7 +465,7 @@ export const TEMPORARY_PREMIUM_PERIOD: Readonly = buildDuration( * At the moment a .eth name expires, this recently released temporary premium is added to its price. * NOTE: The actual recently released temporary premium added subtracts `PREMIUM_OFFSET`. */ -export const PREMIUM_START_PRICE: Price = { +const PREMIUM_START_PRICE: Price = { value: 10000000000n /* $100,000,000.00 (100 million USD) */, currency: Currency.Usd, }; @@ -405,9 +487,9 @@ const PREMIUM_DECAY = 0.5; * Solution: * Subtract this value from the decayed temporary premium to get the actual temporary premium. */ -export const PREMIUM_OFFSET = approxScalePrice( +const PREMIUM_OFFSET = approxScalePrice( PREMIUM_START_PRICE, - PREMIUM_DECAY ** Number(TEMPORARY_PREMIUM_DAYS), + PREMIUM_DECAY ** Number(TEMPORARY_PREMIUM_PERIOD_DAYS), ); /** From 1f61716cac488f3d6a77ae5f46518cd1b37c66c2 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Tue, 13 Aug 2024 16:27:25 +0200 Subject: [PATCH 23/27] move scaleAnnualPrice --- packages/ens-utils/src/ethregistrar.ts | 2 +- packages/ens-utils/src/price.ts | 12 ++++++++++++ packages/ens-utils/src/registrar.ts | 15 ++------------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index cc11d7391..f79755d5d 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -31,7 +31,6 @@ import { RegistrarChargeType, RegistrarUnsupportedNameError, Registration, - scaleAnnualPrice, SecondaryRegistrationStatus, RegistrarCharge, RegistrarTemporaryFee, @@ -43,6 +42,7 @@ import { OnchainRegistrar, MAINNET_ENS_REGISTRY_WITH_FALLBACK_CONTRACT, } from "./registrar"; +import { scaleAnnualPrice } from "./price"; import { MAINNET } from "./chain"; import { buildAddress } from "./address"; diff --git a/packages/ens-utils/src/price.ts b/packages/ens-utils/src/price.ts index 21bc084ab..25da087f4 100644 --- a/packages/ens-utils/src/price.ts +++ b/packages/ens-utils/src/price.ts @@ -4,6 +4,7 @@ import { PriceCurrencyFormat, } from "./currency"; import { approxScaleBigInt, stringToBigInt } from "./number"; +import { Duration, SECONDS_PER_YEAR } from "./time"; export interface Price { // TODO: consider adding a constraint where value is never negative @@ -225,3 +226,14 @@ export const buildPrice = ( return { value: priceValue, currency: priceCurrency }; }; + +export const scaleAnnualPrice = (annualPrice: Price, duration: Duration) => { + // Small performance optimization if no scaling is needed + if (duration.seconds === SECONDS_PER_YEAR.seconds) return annualPrice; + + // TODO: verify we're doing this division of bigints correctly + const scaledRate = Number(duration.seconds) / Number(SECONDS_PER_YEAR.seconds); + + // TODO: verify we're using an appropriate number of digits of precision + return approxScalePrice(annualPrice, scaledRate); +}; diff --git a/packages/ens-utils/src/registrar.ts b/packages/ens-utils/src/registrar.ts index 1ba1f289e..1c19e4c3a 100644 --- a/packages/ens-utils/src/registrar.ts +++ b/packages/ens-utils/src/registrar.ts @@ -3,8 +3,8 @@ import { MAINNET } from "./chain"; import { buildContractRef, ContractRef } from "./contract"; import { ENSName } from "./ensname"; import { NFTIssuer } from "./nft"; -import { approxScalePrice, Price } from "./price"; -import { Duration, SECONDS_PER_YEAR, TimePeriod, Timestamp } from "./time"; +import { Price } from "./price"; +import { Duration, TimePeriod, Timestamp } from "./time"; // REGISTRATION STATUSES ⬇️ @@ -320,17 +320,6 @@ export class RegistrarUnsupportedNameError extends Error { } } -export const scaleAnnualPrice = (annualPrice: Price, duration: Duration) => { - // Small performance optimization if no scaling is needed - if (duration.seconds === SECONDS_PER_YEAR.seconds) return annualPrice; - - // TODO: verify we're doing this division of bigints correctly - const scaledRate = - Number(duration.seconds) / Number(SECONDS_PER_YEAR.seconds); - - return approxScalePrice(annualPrice, scaledRate); -}; - /** * This is the current ENS registry. * From dc69ace8f6233ce405d246495c05d0f0697c0149 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Tue, 13 Aug 2024 17:05:25 +0200 Subject: [PATCH 24/27] Refactor Registry --- packages/ens-utils/src/ethregistrar.ts | 16 +++-- packages/ens-utils/src/registrar.ts | 73 +++-------------------- packages/ens-utils/src/registry.ts | 82 ++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 71 deletions(-) create mode 100644 packages/ens-utils/src/registry.ts diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index f79755d5d..aeeaa4574 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -39,9 +39,9 @@ import { RegistrationPriceQuote, RegistrarAction, RenewalPriceQuote, - OnchainRegistrar, - MAINNET_ENS_REGISTRY_WITH_FALLBACK_CONTRACT, + Registrar, } from "./registrar"; +import { MAINNET_ENS_REGISTRY, Registry } from "./registry"; import { scaleAnnualPrice } from "./price"; import { MAINNET } from "./chain"; import { buildAddress } from "./address"; @@ -54,13 +54,15 @@ import { buildAddress } from "./address"; * These registrars enable trustless decentralized subnames to be issued * as NFTs on Ethereum L1. */ -export class EthRegistrar implements OnchainRegistrar { +export class EthRegistrar implements Registrar { public static readonly Name = buildENSName(ETH_TLD); protected readonly registrarContract: ContractRef; + protected readonly registry: Registry; - public constructor(registrarContract: ContractRef) { + public constructor(registrarContract: ContractRef, registry: Registry) { this.registrarContract = registrarContract; + this.registry = registry; } public getName = (): ENSName => { @@ -100,8 +102,8 @@ export class EthRegistrar implements OnchainRegistrar { return this.registrarContract; } - public getOnchainRegistry(): ContractRef { - return MAINNET_ENS_REGISTRY_WITH_FALLBACK_CONTRACT; + public getRegistry(): Registry { + return this.registry; } public canRegister( @@ -633,8 +635,10 @@ export const MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER_CONTRACT = export const MAINNET_WRAPPING_ETH_REGISTRAR_CONTROLLER = new EthRegistrar( MAINNET_WRAPPING_ETH_REGISTRAR_CONTROLLER_CONTRACT, + MAINNET_ENS_REGISTRY ); export const MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER = new EthRegistrar( MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER_CONTRACT, + MAINNET_ENS_REGISTRY ); diff --git a/packages/ens-utils/src/registrar.ts b/packages/ens-utils/src/registrar.ts index 1c19e4c3a..cefb1a7ed 100644 --- a/packages/ens-utils/src/registrar.ts +++ b/packages/ens-utils/src/registrar.ts @@ -1,9 +1,8 @@ -import { buildAddress } from "./address"; -import { MAINNET } from "./chain"; -import { buildContractRef, ContractRef } from "./contract"; +import { ContractRef } from "./contract"; import { ENSName } from "./ensname"; import { NFTIssuer } from "./nft"; import { Price } from "./price"; +import { Registry } from "./registry"; import { Duration, TimePeriod, Timestamp } from "./time"; // REGISTRATION STATUSES ⬇️ @@ -176,7 +175,7 @@ export interface RenewalPriceQuote extends AbstractRegistrarPriceQuote { /** * A `Registrar` in NameKit aims to provide a standardized "common denominator" * interface for interacting with one of the smart contracts responsible for - * issuing subdomains in ENS. + * issuing subdomains into a `Registry`. * * ENS enables a `Registrar` to be configured at any level of the ENS domain * hierarchy. For example, ENS has a `Registrar` for the overall ENS root. @@ -236,18 +235,13 @@ export interface Registrar { getContractRef(): ContractRef; /** - * Gets the `ContractRef` of the registry that this `Registrar` records - * subdomain registrations in. + * Gets the `Registry` where this `Registrar` records subdomain + * registrations. * - * If a `Registrar` issues subnames onchain then generally this returns - * `MAINNET_ENS_REGISTRY_WITH_FALLBACK_CONTRACT`. - * - * If the `Registrar` issues subnames offchain then this returns `null`. - * - * @returns the requested `ContractRef` or `null` if the `Registrar` issues - * subnames offchain. + * @returns the `Registry` where this `Registrar` records subdomain + * registrations. */ - getOnchainRegistry(): ContractRef | null; + getRegistry(): Registry; canRegister( name: ENSName, @@ -278,33 +272,6 @@ export interface Registrar { ): RenewalPriceQuote; } -/** - * Models a `Registrar` that issues subdomains into an onchain registry. - * - * An `OnchainRegistrar` may live on Ethereum L1 or elsewhere on an L2, etc. - */ -export interface OnchainRegistrar extends Registrar { - getOnchainRegistry(): ContractRef; -} - -/** - * Models a `Registrar` that issues subdomains into an offchain registry - * through the use of ENSIP-10 (also known as "Wildcard Resolution"). - * - * Subdomains issued into an offchain registry as generally refered to as - * "offchain subnames". - * - * See https://docs.ens.domains/ensip/10 for more info. - * - * Generally an `OffchainRegistrar` also makes use of the Cross Chain - * Interoperability Protocol (also known as EIP-3668 or CCIP-Read for short) to - * provide offchain management of resolver records for the offchain subnames it - * issues. - */ -export interface OffchainRegistrar extends Registrar { - getOnchainRegistry(): null; -} - /** * Identifies that a name was passed to a `Registrar` function that is not * issuable by that `Registrar`. @@ -319,27 +286,3 @@ export class RegistrarUnsupportedNameError extends Error { Error.captureStackTrace(this, this.constructor); } } - -/** - * This is the current ENS registry. - * - * Given the lookup of a node for a name in this registry, this contract - * first attempts to find the data for that node in its own internal registry. - * If that internal lookup fails, this contract then attempts to lookup the - * same request in `MAINNET_OLD_ENS_REGISTRY` as a fallback in the case that - * the requested node hasn't been migrated to this (new) registry yet. - */ -export const MAINNET_ENS_REGISTRY_WITH_FALLBACK_CONTRACT = - buildContractRef( - MAINNET, - buildAddress("0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e"), - ); - -/** - * This is the old ENS registry. No new subnames should be issued here. - */ -export const MAINNET_OLD_ENS_REGISTRY = - buildContractRef( - MAINNET, - buildAddress("0x314159265dD8dbb310642f98f50C066173C1259b"), - ); \ No newline at end of file diff --git a/packages/ens-utils/src/registry.ts b/packages/ens-utils/src/registry.ts new file mode 100644 index 000000000..d001fa734 --- /dev/null +++ b/packages/ens-utils/src/registry.ts @@ -0,0 +1,82 @@ +import { buildAddress } from "./address"; +import { MAINNET } from "./chain"; +import { buildContractRef, ContractRef } from "./contract"; + +/** + * A `Registry` in NameKit aims to provide a standardized "common denominator" + * interface for interacting with an ENS name registry. + * + * A `Registry` may live on Ethereum L1, on an L2, or offchain. + */ +export interface Registry { + /** + * Gets the `ContractRef` for where the registry being modeled by this + * `Registry` is recording subdomain registrations onchain. + * + * If a `Registry` records subdomain registrations offchain then this + * returns `null`. + * + * @returns the requested `ContractRef` or `null` if the `Registry` records + * subname registrations offchain. + */ + getContractRef(): ContractRef | null; +} + +/** + * This is the current official ENS registry. + * + * Given the lookup of a node for a name in this registry, this contract + * first attempts to find the data for that node in its own internal registry. + * If that internal lookup fails, this contract then attempts to lookup the + * same request in `MAINNET_OLD_ENS_REGISTRY` as a fallback in the case that + * the requested node hasn't been migrated to this (new) registry yet. + */ +export const MAINNET_ENS_REGISTRY_WITH_FALLBACK_CONTRACT = buildContractRef( + MAINNET, + buildAddress("0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e"), +); + +/** + * This is the old ENS registry. No new subnames should be issued here. + */ +export const MAINNET_OLD_ENS_REGISTRY = buildContractRef( + MAINNET, + buildAddress("0x314159265dD8dbb310642f98f50C066173C1259b"), +); + +/** + * This is the current official ENS registry. + * + * Given the lookup of a node for a name in this registry, this contract + * first attempts to find the data for that node in its own internal registry. + * If that internal lookup fails, this contract then attempts to lookup the + * same request in `MAINNET_OLD_ENS_REGISTRY` as a fallback in the case that + * the requested node hasn't been migrated to this (new) registry yet. + */ +export class MainnetENSRegistry implements Registry { + public getContractRef(): ContractRef | null { + return MAINNET_ENS_REGISTRY_WITH_FALLBACK_CONTRACT; + } +} + +export const MAINNET_ENS_REGISTRY = new MainnetENSRegistry(); + +/** + * Models a `Registry` that records subdomain registrations offchain + * through the use of ENSIP-10 (also known as "Wildcard Resolution"). + * + * Subdomains issued into an offchain registry as generally refered to as + * "offchain subnames". + * + * See https://docs.ens.domains/ensip/10 for more info. + * + * Generally an `OffchainRegistry` also makes use of the Cross Chain + * Interoperability Protocol (also known as EIP-3668 or CCIP-Read for short) to + * provide offchain management of resolver records for the offchain subnames it + * manages. + */ +export class OffchainRegistry implements Registry { + public getContractRef(): ContractRef | null { + return null; + } +} From 1c2145ce518579565b426fd64c1a2bdfe884d6ec Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Tue, 13 Aug 2024 18:10:11 +0200 Subject: [PATCH 25/27] Enhance Registrar --- packages/ens-utils/src/ethregistrar.ts | 54 +++++++++++------ packages/ens-utils/src/namekitregistrar.ts | 67 ++++++++++++++++++---- 2 files changed, 94 insertions(+), 27 deletions(-) diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index aeeaa4574..6a81b8f52 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -43,12 +43,12 @@ import { } from "./registrar"; import { MAINNET_ENS_REGISTRY, Registry } from "./registry"; import { scaleAnnualPrice } from "./price"; -import { MAINNET } from "./chain"; +import { ChainId, MAINNET } from "./chain"; import { buildAddress } from "./address"; /** * The `EthRegistrar` models the policy implmentations shared by both of - * the `OnchainRegistrar` contracts that actively issue subnames for the .eth + * the registrar controller contracts that actively issue subnames for the .eth * TLD (as of July 2024). * * These registrars enable trustless decentralized subnames to be issued @@ -57,12 +57,20 @@ import { buildAddress } from "./address"; export class EthRegistrar implements Registrar { public static readonly Name = buildENSName(ETH_TLD); - protected readonly registrarContract: ContractRef; + protected readonly registrar: ContractRef; protected readonly registry: Registry; - public constructor(registrarContract: ContractRef, registry: Registry) { - this.registrarContract = registrarContract; - this.registry = registry; + /** + * Builds a new `EthRegistrar` instance using the provided configuration. + * + * @param chain The chain to use for the `EthRegistrar`. + * @param useNameWrapper If `true`, this `EthRegistrar` will use the + * NameWrapper on the selected `chain`. + * @throws `Error` if the provided configuration is not supported. + */ + public constructor(chain: ChainId = MAINNET, useNameWrapper: boolean = true) { + this.registrar = getRegistrarForChain(chain, useNameWrapper); + this.registry = getRegistryForChain(chain); } public getName = (): ENSName => { @@ -99,7 +107,7 @@ export class EthRegistrar implements Registrar { }; public getContractRef(): ContractRef { - return this.registrarContract; + return this.registrar; } public getRegistry(): Registry { @@ -621,24 +629,36 @@ export function getDomainReleaseTimestamp( return addSeconds(domainRegistration.expirationTimestamp, GRACE_PERIOD); } -export const MAINNET_WRAPPING_ETH_REGISTRAR_CONTROLLER_CONTRACT = +const MAINNET_WRAPPING_ETH_REGISTRAR_CONTROLLER_CONTRACT = buildContractRef( MAINNET, buildAddress("0x253553366Da8546fC250F225fe3d25d0C782303b"), ); -export const MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER_CONTRACT = +const MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER_CONTRACT = buildContractRef( MAINNET, buildAddress("0x283af0b28c62c092c9727f1ee09c02ca627eb7f5"), ); -export const MAINNET_WRAPPING_ETH_REGISTRAR_CONTROLLER = new EthRegistrar( - MAINNET_WRAPPING_ETH_REGISTRAR_CONTROLLER_CONTRACT, - MAINNET_ENS_REGISTRY -); +export const getRegistrarForChain = (chain: ChainId, useNameWrapper: boolean): ContractRef => { + switch (chain.chainId) { + case MAINNET.chainId: + if (useNameWrapper) { + return MAINNET_WRAPPING_ETH_REGISTRAR_CONTROLLER_CONTRACT; + } else { + return MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER_CONTRACT; + } + default: + throw new Error(`Unsupported chainId: ${chain.chainId}`); + } +} -export const MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER = new EthRegistrar( - MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER_CONTRACT, - MAINNET_ENS_REGISTRY -); +export const getRegistryForChain = (chain: ChainId): Registry => { + switch (chain.chainId) { + case MAINNET.chainId: + return MAINNET_ENS_REGISTRY; + default: + throw new Error(`Unsupported chainId: ${chain.chainId}`); + } +} \ No newline at end of file diff --git a/packages/ens-utils/src/namekitregistrar.ts b/packages/ens-utils/src/namekitregistrar.ts index 5f9dc352c..e3b990d80 100644 --- a/packages/ens-utils/src/namekitregistrar.ts +++ b/packages/ens-utils/src/namekitregistrar.ts @@ -1,5 +1,5 @@ import { buildAddress } from "./address"; -import { MAINNET } from "./chain"; +import { ChainId, MAINNET } from "./chain"; import { buildContractRef, ContractRef } from "./contract"; import { Currency } from "./currency"; import { charCount, ENSName } from "./ensname"; @@ -11,10 +11,53 @@ import { RegistrarServiceFee, } from "./registrar"; -// example of how to modify a pricing policy to add a service fee to the base registration rate +/** + * Example of how to customize a pricing policy that adds a registrar service + * fee to the "base" .eth registration rate. + */ export class NameKitRegistrar extends EthRegistrar { - public constructor(registrarContract: ContractRef) { - super(registrarContract); + + /** + * Currently the NameKitRegistrar contract always uses NameWrapper. + */ + protected static readonly USES_NAMEWRAPPER: boolean = true; + + /** + * Each `NameKitRegistrar` is implemented as a wrapper around this official + * .eth registrar. + */ + protected readonly wrappingRegistrar: ContractRef; + + /** + * Builds a new `NameKitRegistrar` instance using the provided configuration. + * + * @param chain The chain to use for the `NameKitRegistrar`. + * @param nameKitRegistrarDeployment Your deployment of the NameKit registrar + * contract on the provided `chain`. + * @throws `Error` if the provided configuration is not supported. + */ + public constructor(chain: ChainId, nameKitRegistrarDeployment: ContractRef) { + super(chain, NameKitRegistrar.USES_NAMEWRAPPER); + this.wrappingRegistrar = nameKitRegistrarDeployment; + + if (chain.chainId !== nameKitRegistrarDeployment.chain.chainId) { + throw new Error( + `ChainId mismatch: ${chain.chainId} !== ${nameKitRegistrarDeployment.chain.chainId}`, + ); + } + } + + public getContractRef(): ContractRef { + return this.wrappingRegistrar; + } + + /** + * @returns the `ContractRef` for the .eth registrar wrapped by this + * `NameKitRegistrar` that executes the actual registration of .eth + * subdomains. + */ + public getWrappedRegistrar(): ContractRef { + return super.getContractRef(); } protected getAnnualCharges(name: ENSName): RegistrarCharge[] { @@ -31,21 +74,25 @@ export class NameKitRegistrar extends EthRegistrar { const serviceFee: RegistrarServiceFee = { type: RegistrarChargeType.ServiceFee, price: annualServiceFee, - reason: "Example NameKit convenience fee.", // TODO: update this + reason: "Example NameKitRegistrar service fee.", }; return [...baseCharges, serviceFee]; } } -// NOTE: This is an example deployment of a NameKitRegistrar contract on the Ethereum Mainnet. -// You're not supposed to use this yourself directly. You should deploy your own instance of -// this contract. -export const MAINNET_EXAMPLE_NAMEKIT_REGISTRAR_CONTRACT = buildContractRef( +const MAINNET_EXAMPLE_NAMEKIT_REGISTRAR_CONTRACT = buildContractRef( MAINNET, buildAddress("0x232332263e6e4bd8a134b238975e2200c8b7dac1"), ); +/** + * NOTE: This is an example deployment of a NameKitRegistrar contract on the + * Ethereum Mainnet. You're not supposed to use this yourself directly. You + * should deploy your own instance of this contract so that it will be + * possible to withdraw any collected funds into your own treasury. + */ export const MAINNET_EXAMPLE_NAMEKIT_REGISTRAR = new NameKitRegistrar( - MAINNET_EXAMPLE_NAMEKIT_REGISTRAR_CONTRACT + MAINNET, + MAINNET_EXAMPLE_NAMEKIT_REGISTRAR_CONTRACT, ); From 5fb1d09f1c4ded920ffe2c635cb879e304b0a343 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Tue, 13 Aug 2024 22:28:30 +0200 Subject: [PATCH 26/27] Refine use of NFTIssuer --- packages/ens-utils/src/ethregistrar.test.ts | 72 +++++- packages/ens-utils/src/ethregistrar.ts | 219 ++++++++++++---- packages/ens-utils/src/nft.test.ts | 168 ++++--------- packages/ens-utils/src/nft.ts | 263 +++++++------------- packages/ens-utils/src/registrar.ts | 29 ++- 5 files changed, 400 insertions(+), 351 deletions(-) diff --git a/packages/ens-utils/src/ethregistrar.test.ts b/packages/ens-utils/src/ethregistrar.test.ts index c4ce5704b..e79b06fca 100644 --- a/packages/ens-utils/src/ethregistrar.test.ts +++ b/packages/ens-utils/src/ethregistrar.test.ts @@ -1,4 +1,11 @@ -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; +import { buildENSName, ENSName } from "./ensname"; +import { buildNFTRef, buildNFTRefFromENSName, NFTIssuer } from "./nft"; +import { MAINNET, SEPOLIA } from "./chain"; +import { + MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION, + MAINNET_NAMEWRAPPER, +} from "./ethregistrar"; // TODO: add a lot more unit tests here @@ -78,3 +85,66 @@ describe("getPriceDescription", () => { it("should return a temporaryPremium for a domain that `atTimestamp` there is temporaryPremium", () => {}); }); }); + +function testNFTRefFromIssuer( + name: ENSName, + issuer: NFTIssuer, + isWrapped: boolean, +): void { + const expectedToken = issuer.getTokenId(name, isWrapped); + const expectedNFT = buildNFTRef(issuer.getContractRef(), expectedToken); + const result = buildNFTRefFromENSName( + name, + issuer.getContractRef().chain, + isWrapped, + ); + expect(result).toStrictEqual(expectedNFT); +} + +describe("buildNFTRefFromENSName", () => { + it("unrecognized registrar", () => { + expect(() => + buildNFTRefFromENSName(buildENSName("foo.eth"), SEPOLIA, false), + ).toThrow(); + }); + + it("unwrapped non-.eth TLD", () => { + expect(() => + buildNFTRefFromENSName(buildENSName("foo.com"), MAINNET, false), + ).toThrow(); + }); + + it("wrapped non-.eth TLD", () => { + const name = buildENSName("foo.com"); + const registrar = MAINNET_NAMEWRAPPER; + const isWrapped = true; + testNFTRefFromIssuer(name, registrar, isWrapped); + }); + + it("unwrapped subname of a .eth subname", () => { + expect(() => + buildNFTRefFromENSName(buildENSName("x.foo.eth"), MAINNET, false), + ).toThrow(); + }); + + it("wrapped subname of a .eth subname", () => { + const name = buildENSName("x.foo.eth"); + const registrar = MAINNET_NAMEWRAPPER; + const isWrapped = true; + testNFTRefFromIssuer(name, registrar, isWrapped); + }); + + it("unwrapped direct subname of .eth", () => { + const name = buildENSName("foo.eth"); + const registrar = MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION; + const isWrapped = false; + testNFTRefFromIssuer(name, registrar, isWrapped); + }); + + it("wrapped direct subname of .eth", () => { + const name = buildENSName("foo.eth"); + const registrar = MAINNET_NAMEWRAPPER; + const isWrapped = true; + testNFTRefFromIssuer(name, registrar, isWrapped); + }); +}); diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index 6a81b8f52..4f447d82d 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -45,24 +45,27 @@ import { MAINNET_ENS_REGISTRY, Registry } from "./registry"; import { scaleAnnualPrice } from "./price"; import { ChainId, MAINNET } from "./chain"; import { buildAddress } from "./address"; +import { buildTokenId, KNOWN_NFT_ISSUERS, NFTIssuer, TokenId } from "./nft"; +import { labelhash, namehash } from "viem"; /** * The `EthRegistrar` models the policy implmentations shared by both of * the registrar controller contracts that actively issue subnames for the .eth * TLD (as of July 2024). - * - * These registrars enable trustless decentralized subnames to be issued + * + * These registrars enable trustless decentralized subnames to be issued * as NFTs on Ethereum L1. */ export class EthRegistrar implements Registrar { public static readonly Name = buildENSName(ETH_TLD); - + protected readonly registrar: ContractRef; protected readonly registry: Registry; + protected readonly nftIssuer: NFTIssuer; /** * Builds a new `EthRegistrar` instance using the provided configuration. - * + * * @param chain The chain to use for the `EthRegistrar`. * @param useNameWrapper If `true`, this `EthRegistrar` will use the * NameWrapper on the selected `chain`. @@ -71,11 +74,12 @@ export class EthRegistrar implements Registrar { public constructor(chain: ChainId = MAINNET, useNameWrapper: boolean = true) { this.registrar = getRegistrarForChain(chain, useNameWrapper); this.registry = getRegistryForChain(chain); + this.nftIssuer = getNFTIssuerForChain(chain, useNameWrapper); } public getName = (): ENSName => { return EthRegistrar.Name; - } + }; public getManagedSubname = (name: ENSName): ENSName | null => { // must have exactly 2 labels to be a direct subname of ".eth" @@ -114,6 +118,10 @@ export class EthRegistrar implements Registrar { return this.registry; } + public getNFTIssuer(): NFTIssuer { + return this.nftIssuer; + } + public canRegister( name: ENSName, atTimestamp: Timestamp, @@ -135,8 +143,7 @@ export class EthRegistrar implements Registrar { } } - if (!isValidRegistrationDuration(duration)) - return false; + if (!isValidRegistrationDuration(duration)) return false; return true; } @@ -214,8 +221,7 @@ export class EthRegistrar implements Registrar { } } - if (duration.seconds < MIN_RENEWAL_DURATION.seconds) - return false; + if (duration.seconds < MIN_RENEWAL_DURATION.seconds) return false; return true; } @@ -366,25 +372,28 @@ export class EthRegistrar implements Registrar { /** * The minimum days a .eth name can be registered for. - * + * * This value is enforced by EthRegistrarController contracts. */ export const MIN_REGISTRATION_PERIOD_DAYS = 28n; /** * The minimum Duration a .eth name can be registered for. - * + * * This value is enforced by EthRegistrarController contracts. - * + * * 28 days or 2,419,200 seconds. */ -export const MIN_REGISTRATION_PERIOD: Readonly = scaleDuration(SECONDS_PER_DAY, MIN_REGISTRATION_PERIOD_DAYS); +export const MIN_REGISTRATION_PERIOD: Readonly = scaleDuration( + SECONDS_PER_DAY, + MIN_REGISTRATION_PERIOD_DAYS, +); /** * The minimum Duration a .eth name can be renewed for. - * + * * 1 second. - * + * * This value is enforced by EthRegistrarController contracts. */ export const MIN_RENEWAL_DURATION: Readonly = buildDuration(1n); @@ -393,7 +402,7 @@ export const MIN_RENEWAL_DURATION: Readonly = buildDuration(1n); * The maximum days before the registration of a .eth name expires when we * consider it helpful to provide a more visible notice that the name expires * soon and should be renewed as soon as possible to avoid loss. - * + * * This is an arbitrary value we selected for UX purposes. It is not an ENS * standard and is not enforced by any EthRegistrarController contracts. */ @@ -403,18 +412,21 @@ export const MAX_EXPIRING_SOON_PERIOD_DAYS = 90n; * The Duration before the registration of a .eth name expires when we * consider it helpful to provide a more visible notice that the name expires * soon and should be renewed as soon as possible to avoid loss. - * + * * This is an arbitrary value we selected for UX purposes. It is not an ENS * standard and is not enforced by any EthRegistrarController contracts. - * + * * 90 days or 7,776,000 seconds. */ -export const MAX_EXPIRING_SOON_PERIOD: Readonly = scaleDuration(SECONDS_PER_DAY, MAX_EXPIRING_SOON_PERIOD_DAYS); +export const MAX_EXPIRING_SOON_PERIOD: Readonly = scaleDuration( + SECONDS_PER_DAY, + MAX_EXPIRING_SOON_PERIOD_DAYS, +); /** * The number of days an expired registration of a .eth name is in a grace * period prior to being released to the public. - * + * * This value is enforced by EthRegistrarController contracts. */ export const GRACE_PERIOD_DAYS = 90n; @@ -422,52 +434,60 @@ export const GRACE_PERIOD_DAYS = 90n; /** * The Duration an expired registration of a .eth name is in a grace period * prior to being released to the public. - * + * * This value is enforced by EthRegistrarController contracts. - * + * * 90 days or 7,776,000 seconds. */ -export const GRACE_PERIOD: Readonly = scaleDuration(SECONDS_PER_DAY, GRACE_PERIOD_DAYS); +export const GRACE_PERIOD: Readonly = scaleDuration( + SECONDS_PER_DAY, + GRACE_PERIOD_DAYS, +); /** * The number of days a recently released .eth name has a temporary premium * price applied. - * + * * This value is enforced by EthRegistrarController contracts. */ export const TEMPORARY_PREMIUM_PERIOD_DAYS = 21n; /** * The Duration a recently released .eth name has a temporary premium price applied. - * + * * This value is enforced by EthRegistrarController contracts. - * + * * 21 days or 1,814,400 seconds. */ -export const TEMPORARY_PREMIUM_PERIOD: Readonly = scaleDuration(SECONDS_PER_DAY, TEMPORARY_PREMIUM_PERIOD_DAYS); +export const TEMPORARY_PREMIUM_PERIOD: Readonly = scaleDuration( + SECONDS_PER_DAY, + TEMPORARY_PREMIUM_PERIOD_DAYS, +); /** * Identifies if the provided `duration` of registration would be accepted by * EthRegistrarController contracts. - * + * * @param duration The registration duration to evaluate. * @returns true if the provided `duration` is valid, false otherwise. */ const isValidRegistrationDuration = (duration: Duration): boolean => { return duration.seconds >= MIN_REGISTRATION_PERIOD.seconds; -} +}; /** * Validates that the provided `duration` of registration would be accepted by * EthRegistrarController contracts. - * + * * @param duration The registration duration to evaluate. * @throws Error if the provided `duration` is not valid. */ const validateRegistrationDuration = (duration: Duration): void => { if (!isValidRegistrationDuration(duration)) - throw new Error(`Invalid registration duration: ${duration.seconds} seconds. Minimum registration period is ${MIN_REGISTRATION_PERIOD_DAYS} days or ${MIN_REGISTRATION_PERIOD.seconds} seconds.`); -} + throw new Error( + `Invalid registration duration: ${duration.seconds} seconds. Minimum registration period is ${MIN_REGISTRATION_PERIOD_DAYS} days or ${MIN_REGISTRATION_PERIOD.seconds} seconds.`, + ); +}; // REGISTRATION PRICE ⬇️ @@ -629,19 +649,20 @@ export function getDomainReleaseTimestamp( return addSeconds(domainRegistration.expirationTimestamp, GRACE_PERIOD); } -const MAINNET_WRAPPING_ETH_REGISTRAR_CONTROLLER_CONTRACT = - buildContractRef( - MAINNET, - buildAddress("0x253553366Da8546fC250F225fe3d25d0C782303b"), - ); +const MAINNET_WRAPPING_ETH_REGISTRAR_CONTROLLER_CONTRACT = buildContractRef( + MAINNET, + buildAddress("0x253553366Da8546fC250F225fe3d25d0C782303b"), +); -const MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER_CONTRACT = - buildContractRef( - MAINNET, - buildAddress("0x283af0b28c62c092c9727f1ee09c02ca627eb7f5"), - ); +const MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER_CONTRACT = buildContractRef( + MAINNET, + buildAddress("0x283af0b28c62c092c9727f1ee09c02ca627eb7f5"), +); -export const getRegistrarForChain = (chain: ChainId, useNameWrapper: boolean): ContractRef => { +export const getRegistrarForChain = ( + chain: ChainId, + useNameWrapper: boolean, +): ContractRef => { switch (chain.chainId) { case MAINNET.chainId: if (useNameWrapper) { @@ -652,7 +673,7 @@ export const getRegistrarForChain = (chain: ChainId, useNameWrapper: boolean): C default: throw new Error(`Unsupported chainId: ${chain.chainId}`); } -} +}; export const getRegistryForChain = (chain: ChainId): Registry => { switch (chain.chainId) { @@ -661,4 +682,112 @@ export const getRegistryForChain = (chain: ChainId): Registry => { default: throw new Error(`Unsupported chainId: ${chain.chainId}`); } -} \ No newline at end of file +}; + +export class ETHBaseRegistrarImplementation implements NFTIssuer { + protected readonly contract: ContractRef; + + public constructor(contract: ContractRef) { + this.contract = contract; + } + + public getContractRef(): ContractRef { + return this.contract; + } + + public getTokenId(name: ENSName, isWrapped: boolean): TokenId { + if (!this.isClaimable(name, isWrapped)) { + throw new Error( + `Unwrapped tokenId for name: "${name.name}" is not claimable by registrar: ${this.contract.address.address} on chainId: ${this.contract.chain.chainId}`, + ); + } + return buildTokenId(BigInt(labelhash(name.labels[0]))); + } + + public isClaimable(name: ENSName, isWrapped: boolean): boolean { + // name must be unwrapped + if (isWrapped) return false; + + // must have exactly 2 labels to be a direct subname of ".eth" + if (name.labels.length !== 2) return false; + + // last label must be "eth" + if (name.labels[1] !== ETH_TLD) return false; + + // NOTE: now we know we have a direct subname of ".eth" + // first label must be of sufficient length + const subnameLength = charCount(name.labels[0]); + if (subnameLength < MIN_ETH_REGISTRABLE_LABEL_LENGTH) return false; + + // TODO: also add a check for a maximum length limit as enforced by max block size, etc? + return true; + } +} + +export class NameWrapper implements NFTIssuer { + protected readonly contract: ContractRef; + + public constructor(contract: ContractRef) { + this.contract = contract; + } + + public getContractRef(): ContractRef { + return this.contract; + } + + public getTokenId(name: ENSName, isWrapped: boolean): TokenId { + if (!this.isClaimable(name, isWrapped)) { + throw new Error( + `Wrapped tokenId for name: "${name.name}" is not claimable by registrar: ${this.contract.address.address} on chainId: ${this.contract.chain.chainId}`, + ); + } + return buildTokenId(BigInt(namehash(name.name))); + } + + public isClaimable(name: ENSName, isWrapped: boolean): boolean { + // TODO: build a more sophisticated implementation of this function + // for now, we just assume that all wrapped names are claimable by the NameWrapper + return isWrapped; + } +} + +// known `NFTIssuer` contracts + +export const MAINNET_NAMEWRAPPER_CONTRACT = buildContractRef( + MAINNET, + buildAddress("0xD4416b13d2b3a9aBae7AcD5D6C2BbDBE25686401"), +); + +export const MAINNET_NAMEWRAPPER = new NameWrapper( + MAINNET_NAMEWRAPPER_CONTRACT, +); + +export const MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION_CONTRACT = + buildContractRef( + MAINNET, + buildAddress("0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85"), + ); + +export const MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION = + new ETHBaseRegistrarImplementation( + MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION_CONTRACT, + ); + +KNOWN_NFT_ISSUERS.push(MAINNET_NAMEWRAPPER); +KNOWN_NFT_ISSUERS.push(MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION); + +export const getNFTIssuerForChain = ( + chain: ChainId, + useNameWrapper: boolean, +): NFTIssuer => { + switch (chain.chainId) { + case MAINNET.chainId: + if (useNameWrapper) { + return MAINNET_NAMEWRAPPER; + } else { + return MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION; + } + default: + throw new Error(`Unsupported chainId: ${chain.chainId}`); + } +}; diff --git a/packages/ens-utils/src/nft.test.ts b/packages/ens-utils/src/nft.test.ts index f98fb1dca..0ff09ac97 100644 --- a/packages/ens-utils/src/nft.test.ts +++ b/packages/ens-utils/src/nft.test.ts @@ -1,21 +1,15 @@ import { describe, it, expect } from "vitest"; import { - buildNFTRef, - convertNFTRefToString, - buildNFTReferenceFromString, - buildTokenId, - NFTIssuer, - buildNFTRefFromENSName, - MAINNET_NAMEWRAPPER, - MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION, + buildNFTRef, + convertNFTRefToString, + buildNFTReferenceFromString, + buildTokenId, } from "./nft"; -import { MAINNET, SEPOLIA } from "./chain"; +import { MAINNET } from "./chain"; import { buildContractRef } from "./contract"; import { buildAddress } from "./address"; -import { buildENSName, ENSName } from "./ensname"; describe("buildTokenId() function", () => { - it("Non-integer tokenId", () => { const tokenId = "x"; @@ -34,17 +28,17 @@ describe("buildTokenId() function", () => { const result = buildTokenId(tokenId); expect(result).toStrictEqual({ - tokenId: tokenId, + tokenId: tokenId, }); }); it("Max allowed tokenId value", () => { - const tokenId = (2n ** 256n) - 1n; + const tokenId = 2n ** 256n - 1n; const result = buildTokenId(tokenId); expect(result).toStrictEqual({ - tokenId: tokenId, + tokenId: tokenId, }); }); @@ -53,127 +47,67 @@ describe("buildTokenId() function", () => { expect(() => buildTokenId(tokenId)).toThrow(); }); - }); describe("buildNFTRef() function", () => { + it("buildNFTRef", () => { + const chain = MAINNET; + const contractAddress = buildAddress( + "0x1234567890123456789012345678901234567890", + ); + const token = buildTokenId(1234567890123456789012345678901234567890n); - it("buildNFTRef", () => { - const chain = MAINNET; - const contractAddress = buildAddress("0x1234567890123456789012345678901234567890"); - const token = buildTokenId(1234567890123456789012345678901234567890n); - - const contract = buildContractRef(chain, contractAddress); - const result = buildNFTRef(contract, token); - - expect(result).toStrictEqual({ - contract: contract, - token: token, - }); - }); + const contract = buildContractRef(chain, contractAddress); + const result = buildNFTRef(contract, token); + expect(result).toStrictEqual({ + contract: contract, + token: token, + }); + }); }); describe("convertNFTRefToString() function", () => { + it("convertNFTRefToString", () => { + const chain = MAINNET; + const contractAddress = buildAddress( + "0x1234567890123456789012345678901234567890", + ); + const token = buildTokenId(1234567890123456789012345678901234567890n); - it("convertNFTRefToString", () => { - const chain = MAINNET; - const contractAddress = buildAddress("0x1234567890123456789012345678901234567890"); - const token = buildTokenId(1234567890123456789012345678901234567890n); + const contract = buildContractRef(chain, contractAddress); + const nft = buildNFTRef(contract, token); - const contract = buildContractRef(chain, contractAddress); - const nft = buildNFTRef(contract, token); - - const result = convertNFTRefToString(nft); - - expect(result).toEqual("1:0x1234567890123456789012345678901234567890:1234567890123456789012345678901234567890"); - }); + const result = convertNFTRefToString(nft); + expect(result).toEqual( + "1:0x1234567890123456789012345678901234567890:1234567890123456789012345678901234567890", + ); + }); }); describe("buildNFTReferenceFromString() function", () => { - - it("too few params", () => { - expect(() => buildNFTReferenceFromString(":")).toThrow(); - }); - - it("too many params", () => { - expect(() => buildNFTReferenceFromString(":::")).toThrow(); - }); - - it("valid params", () => { - const result = buildNFTReferenceFromString("1:0x1234567890123456789012345678901234567890:1234567890123456789012345678901234567890"); - - const chain = MAINNET; - const contractAddress = buildAddress("0x1234567890123456789012345678901234567890"); - const contract = buildContractRef(chain, contractAddress); - const token = buildTokenId(1234567890123456789012345678901234567890n); - const nft = buildNFTRef(contract, token); - - expect(result).toStrictEqual(nft); - }); - -}); - -function testNFTRefFromIssuer( - name: ENSName, - issuer: NFTIssuer, - isWrapped: boolean, -): void { - const expectedToken = issuer.getTokenId(name, isWrapped); - const expectedNFT = buildNFTRef(issuer.getNftContract(), expectedToken); - const result = buildNFTRefFromENSName( - name, - issuer.getNftContract().chain, - isWrapped, - ); - expect(result).toStrictEqual(expectedNFT); -} - -describe("buildNFTRefFromENSName", () => { - it("unrecognized registrar", () => { - expect(() => - buildNFTRefFromENSName(buildENSName("foo.eth"), SEPOLIA, false), - ).toThrow(); - }); - - it("unwrapped non-.eth TLD", () => { - expect(() => - buildNFTRefFromENSName(buildENSName("foo.com"), MAINNET, false), - ).toThrow(); - }); - - it("wrapped non-.eth TLD", () => { - const name = buildENSName("foo.com"); - const registrar = MAINNET_NAMEWRAPPER; - const isWrapped = true; - testNFTRefFromIssuer(name, registrar, isWrapped); + it("too few params", () => { + expect(() => buildNFTReferenceFromString(":")).toThrow(); }); - it("unwrapped subname of a .eth subname", () => { - expect(() => - buildNFTRefFromENSName(buildENSName("x.foo.eth"), MAINNET, false), - ).toThrow(); + it("too many params", () => { + expect(() => buildNFTReferenceFromString(":::")).toThrow(); }); - it("wrapped subname of a .eth subname", () => { - const name = buildENSName("x.foo.eth"); - const registrar = MAINNET_NAMEWRAPPER; - const isWrapped = true; - testNFTRefFromIssuer(name, registrar, isWrapped); - }); + it("valid params", () => { + const result = buildNFTReferenceFromString( + "1:0x1234567890123456789012345678901234567890:1234567890123456789012345678901234567890", + ); - it("unwrapped direct subname of .eth", () => { - const name = buildENSName("foo.eth"); - const registrar = MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION; - const isWrapped = false; - testNFTRefFromIssuer(name, registrar, isWrapped); - }); + const chain = MAINNET; + const contractAddress = buildAddress( + "0x1234567890123456789012345678901234567890", + ); + const contract = buildContractRef(chain, contractAddress); + const token = buildTokenId(1234567890123456789012345678901234567890n); + const nft = buildNFTRef(contract, token); - it("wrapped direct subname of .eth", () => { - const name = buildENSName("foo.eth"); - const registrar = MAINNET_NAMEWRAPPER; - const isWrapped = true; - testNFTRefFromIssuer(name, registrar, isWrapped); + expect(result).toStrictEqual(nft); }); -}); \ No newline at end of file +}); diff --git a/packages/ens-utils/src/nft.ts b/packages/ens-utils/src/nft.ts index ac9f0883b..18f8094c1 100644 --- a/packages/ens-utils/src/nft.ts +++ b/packages/ens-utils/src/nft.ts @@ -1,64 +1,67 @@ -import { labelhash, namehash } from "viem"; import { buildAddress } from "./address"; -import { buildChainId, ChainId, MAINNET } from "./chain"; +import { buildChainId, ChainId } from "./chain"; import { ContractRef, buildContractRef } from "./contract"; -import { charCount, ENSName, ETH_TLD, MIN_ETH_REGISTRABLE_LABEL_LENGTH } from "./ensname"; +import { ENSName } from "./ensname"; export interface TokenId { - /** - * Token ID of an NFT. - * Always a non-negative integer. - */ - tokenId: bigint; -}; + /** + * Token ID of an NFT. + * Always a non-negative integer. + */ + tokenId: bigint; +} -const MAX_UINT256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935n; +const MAX_UINT256 = + 115792089237316195423570985008687907853269984665640564039457584007913129639935n; /** * Builds a TokenId object. * @param maybeTokenId the token ID of an NFT. * @returns a TokenId object. */ -export const buildTokenId = ( - maybeTokenId: bigint | string -): TokenId => { - - let tokenId: bigint; - - if (typeof maybeTokenId === "string") { - try { - tokenId = BigInt(maybeTokenId); - } catch (e) { - throw new Error(`Invalid token ID: ${maybeTokenId}. All token ID values must be integers.`); - } - } else { - tokenId = maybeTokenId; +export const buildTokenId = (maybeTokenId: bigint | string): TokenId => { + let tokenId: bigint; + + if (typeof maybeTokenId === "string") { + try { + tokenId = BigInt(maybeTokenId); + } catch (e) { + throw new Error( + `Invalid token ID: ${maybeTokenId}. All token ID values must be integers.`, + ); } + } else { + tokenId = maybeTokenId; + } - if (tokenId < 0) { - throw new Error(`Invalid token ID: ${maybeTokenId}. All token ID values must be non-negative.`); - } + if (tokenId < 0) { + throw new Error( + `Invalid token ID: ${maybeTokenId}. All token ID values must be non-negative.`, + ); + } - if (tokenId > MAX_UINT256) { - throw new Error(`Invalid token ID: ${maybeTokenId}. All token ID values must be representable as a uint256 value.`); - } + if (tokenId > MAX_UINT256) { + throw new Error( + `Invalid token ID: ${maybeTokenId}. All token ID values must be representable as a uint256 value.`, + ); + } - return { - tokenId - }; -} + return { + tokenId, + }; +}; export interface NFTRef { - /** - * Contract of the NFT. - */ - contract: ContractRef; - - /** - * Reference to the token of the NFT within the related contract. - */ - token: TokenId; -}; + /** + * Contract of the NFT. + */ + contract: ContractRef; + + /** + * Reference to the token of the NFT within the related contract. + */ + token: TokenId; +} /** * Builds a NFTRef object. @@ -66,26 +69,20 @@ export interface NFTRef { * @param token the token ID of the NFT within the specified contract. * @returns a NFTRef object. */ -export const buildNFTRef = ( - contract: ContractRef, - token: TokenId -): NFTRef => { - - return { - contract, - token - }; -} +export const buildNFTRef = (contract: ContractRef, token: TokenId): NFTRef => { + return { + contract, + token, + }; +}; /** * Convert a NFTRef to a string. * @param nft: NFTRef - The NFTRef to convert. * @returns string - The converted string. */ -export const convertNFTRefToString = ( - nft: NFTRef - ): string => { - return `${nft.contract.chain.chainId}:${nft.contract.address.address}:${nft.token.tokenId}`; +export const convertNFTRefToString = (nft: NFTRef): string => { + return `${nft.contract.chain.chainId}:${nft.contract.address.address}:${nft.token.tokenId}`; }; /** @@ -93,140 +90,47 @@ export const convertNFTRefToString = ( * @param maybeNFT: string - The string to parse. * @returns NFTRef - The NFTRef object for the parsed string. */ -export const buildNFTReferenceFromString = ( - maybeNFT: string - ): NFTRef => { - const parts = maybeNFT.split(":"); +export const buildNFTReferenceFromString = (maybeNFT: string): NFTRef => { + const parts = maybeNFT.split(":"); - if (parts.length !== 3) { - throw new Error(`Cannot convert: "${maybeNFT}" to NFTRef`); - } + if (parts.length !== 3) { + throw new Error(`Cannot convert: "${maybeNFT}" to NFTRef`); + } - const chainId = buildChainId(parts[0]); - const contractAddress = buildAddress(parts[1]); - const contract = buildContractRef(chainId, contractAddress); - const tokenId = buildTokenId(parts[2]); + const chainId = buildChainId(parts[0]); + const contractAddress = buildAddress(parts[1]); + const contract = buildContractRef(chainId, contractAddress); + const tokenId = buildTokenId(parts[2]); - return buildNFTRef(contract, tokenId); -} + return buildNFTRef(contract, tokenId); +}; export interface NFTIssuer { - getNftContract(): ContractRef; - getTokenId(name: ENSName, isWrapped: boolean): TokenId; - isClaimable(name: ENSName, isWrapped: boolean): boolean; + getContractRef(): ContractRef; + getTokenId(name: ENSName, isWrapped: boolean): TokenId; + isClaimable(name: ENSName, isWrapped: boolean): boolean; } -export class NameWrapper implements NFTIssuer { - - private readonly contract: ContractRef; - - public constructor(contract: ContractRef) { - this.contract = contract; - } - - public getNftContract(): ContractRef { - return this.contract; - } - - public getTokenId(name: ENSName, isWrapped: boolean): TokenId { - if (!this.isClaimable(name, isWrapped)) { - throw new Error( - `Wrapped tokenId for name: "${name.name}" is not claimable by registrar: ${this.contract.address.address} on chainId: ${this.contract.chain.chainId}` - ); - } - return buildTokenId(BigInt(namehash(name.name))); - } - - public isClaimable(name: ENSName, isWrapped: boolean): boolean { - - // TODO: build a more sophisticated implementation of this function - // for now, we just assume that all wrapped names are claimable by the NameWrapper - return isWrapped; - } - } - - export class ETHBaseRegistrarImplementation implements NFTIssuer { - - private readonly contract: ContractRef; - - public constructor(contract: ContractRef) { - this.contract = contract; - } - - public getNftContract(): ContractRef { - return this.contract; - } - - public getTokenId(name: ENSName, isWrapped: boolean): TokenId { - if (!this.isClaimable(name, isWrapped)) { - throw new Error( - `Unwrapped tokenId for name: "${name.name}" is not claimable by registrar: ${this.contract.address.address} on chainId: ${this.contract.chain.chainId}` - ); - } - return buildTokenId(BigInt(labelhash(name.labels[0]))); - } - - public isClaimable(name: ENSName, isWrapped: boolean): boolean { - // name must be unwrapped - if (isWrapped) return false; - - // must have exactly 2 labels to be a direct subname of ".eth" - if (name.labels.length !== 2) return false; - - // last label must be "eth" - if (name.labels[1] !== ETH_TLD) return false; - - // NOTE: now we know we have a direct subname of ".eth" - - // first label must be of sufficient length - const subnameLength = charCount(name.labels[0]); - if (subnameLength < MIN_ETH_REGISTRABLE_LABEL_LENGTH) return false; - - // TODO: also add a check for a maximum length limit as enforced by max block size, etc? - - return true; - } - } - -// known `NFTIssuer` contracts - -export const MAINNET_NAMEWRAPPER_CONTRACT = buildContractRef( - MAINNET, - buildAddress("0xD4416b13d2b3a9aBae7AcD5D6C2BbDBE25686401") -); - -export const MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION_CONTRACT = buildContractRef( - MAINNET, - buildAddress("0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85") -); - -export const MAINNET_NAMEWRAPPER = new NameWrapper( - MAINNET_NAMEWRAPPER_CONTRACT -); - -export const MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION = new ETHBaseRegistrarImplementation(MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION_CONTRACT); - -// NOTE: Need to add `NFTIssuer` objects to `KNOWN_NFT_ISSUERS` as they are -// defined in order to use +/** + * NOTE: Need to add `NFTIssuer` objects to `KNOWN_NFT_ISSUERS` as they are + * defined in order to use + */ export const KNOWN_NFT_ISSUERS: NFTIssuer[] = []; -KNOWN_NFT_ISSUERS.push(MAINNET_NAMEWRAPPER); -KNOWN_NFT_ISSUERS.push(MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION); - export function buildNFTRefFromENSName( name: ENSName, chain: ChainId, - isWrapped: boolean + isWrapped: boolean, ): NFTRef { const issuer = getKnownNFTIssuer(name, chain, isWrapped); const token = issuer.getTokenId(name, isWrapped); - return buildNFTRef(issuer.getNftContract(), token); + return buildNFTRef(issuer.getContractRef(), token); } export function getKnownPotentialNFTRefs( name: ENSName, - chain: ChainId + chain: ChainId, ): NFTRef[] { const wrappedNFT = buildNFTRefFromENSName(name, chain, true); const unwrappedNFT = buildNFTRefFromENSName(name, chain, false); @@ -236,11 +140,12 @@ export function getKnownPotentialNFTRefs( export function getPotentialKnownIssuers( name: ENSName, chain: ChainId, - isWrapped: boolean + isWrapped: boolean, ): NFTIssuer[] { return KNOWN_NFT_ISSUERS.filter( - (issuer) => issuer.getNftContract().chain.chainId === chain.chainId && - issuer.isClaimable(name, isWrapped) + (issuer) => + issuer.getContractRef().chain.chainId === chain.chainId && + issuer.isClaimable(name, isWrapped), ); } @@ -255,18 +160,18 @@ export function getPotentialKnownIssuers( export function getKnownNFTIssuer( name: ENSName, chain: ChainId, - isWrapped: boolean + isWrapped: boolean, ): NFTIssuer { const issuers = getPotentialKnownIssuers(name, chain, isWrapped); if (issuers.length > 1) { throw new Error( - `Multiple potential NFT issuers found for name: "${name.name}" on chainId: ${chain.chainId} when isWrapped: ${isWrapped}` + `Multiple potential NFT issuers found for name: "${name.name}" on chainId: ${chain.chainId} when isWrapped: ${isWrapped}`, ); } else if (issuers.length === 0) { throw new Error( - `No known NFT issuers found for name: "${name.name}" on chainId: ${chain.chainId} when isWrapped: ${isWrapped}` + `No known NFT issuers found for name: "${name.name}" on chainId: ${chain.chainId} when isWrapped: ${isWrapped}`, ); } else { return issuers[0]; } -} \ No newline at end of file +} diff --git a/packages/ens-utils/src/registrar.ts b/packages/ens-utils/src/registrar.ts index cefb1a7ed..6fc71c68a 100644 --- a/packages/ens-utils/src/registrar.ts +++ b/packages/ens-utils/src/registrar.ts @@ -176,12 +176,12 @@ export interface RenewalPriceQuote extends AbstractRegistrarPriceQuote { * A `Registrar` in NameKit aims to provide a standardized "common denominator" * interface for interacting with one of the smart contracts responsible for * issuing subdomains into a `Registry`. - * + * * ENS enables a `Registrar` to be configured at any level of the ENS domain * hierarchy. For example, ENS has a `Registrar` for the overall ENS root. * Beneath the root, ENS has a `Registrar` for the `.eth` TLD. Beneath `.eth`, * there is the `Registrar` for `uni.eth`, and so on. - * + * * NOTE: ENS enables an infinite set of possible registrar implementations. * NameKit aims for `Registrar` to support the registrar implementations that * are most popular within the ENS community, however some registrar @@ -191,7 +191,6 @@ export interface RenewalPriceQuote extends AbstractRegistrarPriceQuote { * your registrar of interest. */ export interface Registrar { - /** * @returns the name that this `Registrar` issues subnames for. */ @@ -199,7 +198,7 @@ export interface Registrar { /** * Checks if the provided `name` is a subname issued by this `Registrar`. - * + * * @param name the name to check if it is issued by this `Registrar`. * @returns the subname of `name` that is issued by this `Registrar`, or * `null` if `name` is not a subname issued by this `Registrar`. @@ -214,7 +213,7 @@ export interface Registrar { /** * Gets the subname of `name` that is issued by this `Registrar`. - * + * * @param name the name to get the issued subname of that was issued by this * `Registrar`. * @returns the subname of `name` that is issued by this `Registrar`. @@ -226,10 +225,10 @@ export interface Registrar { /** * Gets the `ContractRef` for where the registrar being modeled by this * `Registrar` is found onchain. - * + * * NOTE: The returned `ContractRef` may not be the only contract with the * ability to serve as a subname registrar for `getName()`. - * + * * @returns the requested `ContractRef`. */ getContractRef(): ContractRef; @@ -237,12 +236,22 @@ export interface Registrar { /** * Gets the `Registry` where this `Registrar` records subdomain * registrations. - * + * * @returns the `Registry` where this `Registrar` records subdomain * registrations. */ getRegistry(): Registry; + /** + * Gets the `NFTIssuer` (if any) that provides `NFTRef` for subdomain + * registrations. + * + * @returns the `NFTIssuer` that provides `NFTRef` for subdomain + * registrations, or `null` if this `Registrar` does not issue + * `NFTRef` for subdomain registrations. + */ + getNFTIssuer(): NFTIssuer | null; + canRegister( name: ENSName, atTimestamp: Timestamp, @@ -281,7 +290,9 @@ export interface Registrar { */ export class RegistrarUnsupportedNameError extends Error { public constructor(message: string, unsupportedName: ENSName) { - super(`RegistrarUnsupportedNameError for name "${unsupportedName.name}": ${message}`); + super( + `RegistrarUnsupportedNameError for name "${unsupportedName.name}": ${message}`, + ); this.name = "RegistrarUnsupportedNameError"; Error.captureStackTrace(this, this.constructor); } From 54ab2d38ceef0ad0d6de8ef1ab16551469158541 Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Tue, 13 Aug 2024 22:37:11 +0200 Subject: [PATCH 27/27] Fix error --- packages/ens-utils/src/registrar.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ens-utils/src/registrar.test.ts b/packages/ens-utils/src/registrar.test.ts index eb242ad08..017d00a62 100644 --- a/packages/ens-utils/src/registrar.test.ts +++ b/packages/ens-utils/src/registrar.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; -import { scaleAnnualPrice } from "./registrar"; -import { buildPrice } from "./price"; +import { buildPrice, scaleAnnualPrice } from "./price"; import { Currency } from "./currency"; import { scaleDuration, SECONDS_PER_YEAR } from "./time";