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.) diff --git a/.changeset/two-rockets-sneeze.md b/.changeset/two-rockets-sneeze.md new file mode 100644 index 000000000..c5032acc3 --- /dev/null +++ b/.changeset/two-rockets-sneeze.md @@ -0,0 +1,6 @@ +--- +"@namehash/namekit-react": minor +"@namehash/ens-utils": minor +--- + +Update tsconfig.json diff --git a/apps/namehashlabs.org/public/status-updates/01-08-2024.pdf b/apps/namehashlabs.org/public/status-updates/01-08-2024.pdf new file mode 100644 index 000000000..f64c3dabd Binary files /dev/null and b/apps/namehashlabs.org/public/status-updates/01-08-2024.pdf differ diff --git a/packages/ens-utils/src/domain.ts b/packages/ens-utils/src/domain.ts index 8aee68335..1f88b04a3 100644 --- a/packages/ens-utils/src/domain.ts +++ b/packages/ens-utils/src/domain.ts @@ -1,8 +1,7 @@ import { NFTRef } from "./nft"; import { ENSName } from "./ensname"; import { Address, isAddressEqual } from "./address"; -import { keccak256, labelhash as labelHash } from "viem"; -import { Registration } from "./ethregistrar"; +import { Registration } from "./registrar"; export interface DomainCard { name: ENSName; @@ -25,127 +24,71 @@ 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]; /** - * Returns the ownership status of a domain in comparison to the current user's address - * @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 `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 + * is no current user signed in. + * @returns The appropriate `UserOwnershipOfDomain` value given the provided + * `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) { - return UserOwnershipOfDomain.FormerOwner; - } - - const isOwner = - ownerAddress && isAddressEqual(currentUserAddress, ownerAddress); + if (!domain.ownerAddress && !domain.formerOwnerAddress) { + return UserOwnershipOfDomain.NoOwner; + } - if (isOwner) { + if (currentUserAddress) { + if ( + domain.ownerAddress && + isAddressEqual(domain.ownerAddress, currentUserAddress) + ) { return UserOwnershipOfDomain.ActiveOwner; } - } - if (!ownerAddress) { - return UserOwnershipOfDomain.NoOwner; + 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; -}; diff --git a/packages/ens-utils/src/ensname.ts b/packages/ens-utils/src/ensname.ts index 27e4ad14a..ee1ac6ca9 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"; @@ -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 @@ -358,3 +343,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; +}; diff --git a/packages/ens-utils/src/ethregistrar.test.ts b/packages/ens-utils/src/ethregistrar.test.ts index 107d96db7..e79b06fca 100644 --- a/packages/ens-utils/src/ethregistrar.test.ts +++ b/packages/ens-utils/src/ethregistrar.test.ts @@ -1,79 +1,14 @@ -import { describe, it, expect } 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 { - Registrar, - UNWRAPPED_MAINNET_ETH_REGISTRAR, - WRAPPED_MAINNET_ETH_REGISTRAR, - buildNFTRefFromENSName, + MAINNET_ETH_BASE_REGISTRAR_IMPLEMENTATION, + MAINNET_NAMEWRAPPER, } from "./ethregistrar"; -import { ENSName, buildENSName } from "./ensname"; -import { MAINNET, SEPOLIA } from "./chain"; -import { buildNFTRef } from "./nft"; // 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. @@ -84,7 +19,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. @@ -150,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 f08d10ddf..4f447d82d 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -3,322 +3,490 @@ import { MIN_ETH_REGISTRABLE_LABEL_LENGTH, ETH_TLD, charCount, - getDomainLabelFromENSName, + buildENSName, } from "./ensname"; -import { NFTRef, TokenId, buildNFTRef, buildTokenId } from "./nft"; -import { namehash, labelhash } from "viem/ens"; -import { buildAddress } from "./address"; -import { ChainId, MAINNET } from "./chain"; -import { ContractRef, buildContractRef } from "./contract"; +import { buildContractRef, ContractRef } from "./contract"; import { Duration, SECONDS_PER_DAY, Timestamp, addSeconds, buildDuration, + buildTimePeriod, formatTimestampAsDistanceToNow, now, + scaleDuration, } from "./time"; import { Price, addPrices, approxScalePrice, + buildPrice, formattedPrice, - multiplyPriceByNumber, subtractPrices, } from "./price"; import { Currency } from "./currency"; +import { + PrimaryRegistrationStatus, + RegistrarChargeType, + RegistrarUnsupportedNameError, + Registration, + SecondaryRegistrationStatus, + RegistrarCharge, + RegistrarTemporaryFee, + RegistrarBaseFee, + RegistrarSpecialNameFee, + RegistrationPriceQuote, + RegistrarAction, + RenewalPriceQuote, + Registrar, +} from "./registrar"; +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"; -export interface Registrar { - contract: ContractRef; +/** + * 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 + * 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`. + * @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); + this.nftIssuer = getNFTIssuerForChain(chain, useNameWrapper); + } - getTokenId(name: ENSName, isWrapped: boolean): TokenId; - isClaimable(name: ENSName, isWrapped: boolean): boolean; -} + public getName = (): ENSName => { + return EthRegistrar.Name; + }; -// known registrars -export const WRAPPED_MAINNET_ETH_REGISTRAR_CONTRACT = buildContractRef( - MAINNET, - buildAddress("0xD4416b13d2b3a9aBae7AcD5D6C2BbDBE25686401"), -); -export const UNWRAPPED_MAINNET_ETH_REGISTRAR_CONTRACT = buildContractRef( - MAINNET, - buildAddress("0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85"), -); + 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; -export class NameWrapper implements Registrar { - constructor(public contract: ContractRef) { - this.contract = contract; - } + // last label must be "eth" + if (name.labels[1] !== ETH_TLD) return null; - 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}`, + // 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 getContractRef(): ContractRef { + return this.registrar; + } + + public getRegistry(): Registry { + return this.registry; } - isClaimable(name: ENSName, isWrapped: boolean): boolean { - if (!isWrapped) return false; + public getNFTIssuer(): NFTIssuer { + return this.nftIssuer; + } + + 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 (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 (existingRegistration) { + const releaseTimestamp = getDomainReleaseTimestamp(existingRegistration); + + 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. - return true; - } -} + if (!isValidRegistrationDuration(duration)) return false; -export class ClassicETHRegistrarController implements Registrar { - constructor(public contract: ContractRef) { - this.contract = contract; + return true; } - getTokenId(name: ENSName, isWrapped: boolean): TokenId { - if (!this.isClaimable(name, isWrapped)) { + 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( - `Unwrapped tokenId for name: "${name.name}" is not claimable by registrar: ${this.contract.address} on chainId: ${this.contract.chain.chainId}`, + `Cannot build registration 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; + const rentalPeriod = buildTimePeriod( + atTimestamp, + addSeconds(atTimestamp, duration), + ); - // must have exactly 2 labels to be a direct subname of ".eth" - if (name.labels.length !== 2) return false; + let charges = this.getDurationCharges(name, duration); - // last label must be "eth" - if (name.labels[1] !== ETH_TLD) return false; + let temporaryPremium: RegistrarTemporaryFee | null = null; - // first label must be of sufficient length - return charCount(name.labels[0]) >= MIN_ETH_REGISTRABLE_LABEL_LENGTH; + 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, + }; } -} -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); + 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; + } -export const KNOWN_REGISTRARS = [ - WRAPPED_MAINNET_ETH_REGISTRAR, - UNWRAPPED_MAINNET_ETH_REGISTRAR, -]; + 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; + } -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), - ); -} + const releaseTimestamp = getDomainReleaseTimestamp(existingRegistration); -/** - * 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}`, + 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 < MIN_RENEWAL_DURATION.seconds) 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( + `Cannot build renewal price quote for name: "${name.name}".`, + ); + } + + if (!existingRegistration.expirationTimestamp) { + throw new Error(`Invariant violation`); // TODO: refine message... just making the type system happy. + } + + // TODO: review for possible off-by-1 errors + const newExpiration = addSeconds( + existingRegistration.expirationTimestamp, + duration, ); - } else if (registrars.length === 0) { - throw new Error( - `No known registrars found for name: "${name.name}" on chainId: ${chain.chainId} when isWrapped: ${isWrapped}`, + const rentalPeriod = buildTimePeriod( + existingRegistration.expirationTimestamp, + newExpiration, ); - } 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); -} + const charges = this.getDurationCharges(name, duration); + const totalPrice = addPrices(charges.map((charge) => charge.price)); -export function buildNFTRefFromENSName( - name: ENSName, - chain: ChainId, - isWrapped: boolean, -): NFTRef { - const registrar = getKnownRegistrar(name, chain, isWrapped); - const token = registrar.getTokenId(name, isWrapped); + return { + action: RegistrarAction.Renew, + rentalPeriod, + charges, + totalPrice, + }; + } - return buildNFTRef(registrar.contract, token); -} + protected getAnnualCharges(name: ENSName): RegistrarCharge[] { + let baseRate: Price; + let hasSpecialNameFee: boolean; -export const GRACE_PERIOD: Readonly = buildDuration( - 90n * SECONDS_PER_DAY.seconds, -); -export const TEMPORARY_PREMIUM_DAYS = 21n; + const subname = this.getValidatedSubname(name); + const subnameLength = charCount(subname.name); -export const TEMPORARY_PREMIUM_PERIOD: Readonly = buildDuration( - TEMPORARY_PREMIUM_DAYS * SECONDS_PER_DAY.seconds, -); + 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 DOMAIN_HAS_SPECIAL_PRICE_IF_LENGTH_EQUAL_OR_LESS_THAN = 5; - -// 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; -} + if (!hasSpecialNameFee) { + const baseFee: RegistrarBaseFee = { + type: RegistrarChargeType.BaseFee, + price: baseRate, + }; -/** - * 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, + return [baseFee]; + } + + 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 ensNameLabel = getDomainLabelFromENSName(ensName); + }); - if (ensNameLabel === null) return null; + return scaledCharges; + }; - const domainLabelLength = charCount(ensNameLabel); + protected getTemporaryPremiumCharge = ( + name: ENSName, + existingRegistration: Registration, + atTimestamp: Timestamp, + ): RegistrarTemporaryFee | null => { + if (!existingRegistration.expirationTimestamp) return null; - return domainLabelLength < - DOMAIN_HAS_SPECIAL_PRICE_IF_LENGTH_EQUAL_OR_LESS_THAN - ? { - pricePerYearDescription, - descriptiveTextBeginning: `${domainLabelLength}-character names are `, - descriptiveTextEnd: " to register.", - } - : null; - } - } -}; + 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), + ); + + const priceStr = formattedPrice({ + price: standardAnnualPrice, + withPrefix: true, + }); + + const premiumEndsIn = formatTimestampAsDistanceToNow(premiumPeriod.end); + + // 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 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 temporaryFee; + }; } /** - * 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). + * The minimum days a .eth name can be registered for. + * + * This value is enforced by EthRegistrarController contracts. */ -export const premiumPeriodEndsIn = ( - registration: Registration, -): PremiumPeriodEndsIn | null => { - const isExpired = - registration.primaryStatus === PrimaryRegistrationStatus.Expired; - const wasRecentlyReleased = - registration.secondaryStatus === - SecondaryRegistrationStatus.RecentlyReleased; +export const MIN_REGISTRATION_PERIOD_DAYS = 28n; - /* - A domain will only have a premium price if it has Expired and it was Recently Released - */ - if (!isExpired || !wasRecentlyReleased) return null; +/** + * 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, +); - /* - 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; +/** + * 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); - const releasedEpoch = addSeconds(registration.expiryTimestamp, GRACE_PERIOD); - const temporaryPremiumEndTimestamp = addSeconds( - releasedEpoch, - TEMPORARY_PREMIUM_PERIOD, - ); +/** + * 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 MAX_EXPIRING_SOON_PERIOD_DAYS = 90n; - return { - relativeTimestamp: formatTimestampAsDistanceToNow( - temporaryPremiumEndTimestamp, - ), - timestamp: temporaryPremiumEndTimestamp, - }; +/** + * 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, +); + +/** + * 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; + +/** + * 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 ⬇️ @@ -327,7 +495,7 @@ export const premiumPeriodEndsIn = ( * 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, }; @@ -349,28 +517,31 @@ 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), ); /** + * @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 = @@ -381,122 +552,28 @@ 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, - }; - } - - return offsetDecayedPrice; -} - -export const registrationCurrentTemporaryPremium = ( - registration: Registration, -): Price | null => { - if (registration.expirationTimestamp) { - return temporaryPremiumPriceAtTimestamp( - now(), - registration.expirationTimestamp, - ); - } else { + /* the temporary premium can never be less than $0.00 */ return null; } -}; - -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, - }, -}; - -/* - 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 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 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); + return offsetDecayedPrice; }; -// REGISTRATION STATUSES ⬇️ - -export enum PrimaryRegistrationStatus { - Active = "Active", - Expired = "Expired", - NeverRegistered = "NeverRegistered", -} - -export enum SecondaryRegistrationStatus { - ExpiringSoon = "ExpiringSoon", - FullyReleased = "FullyReleased", - GracePeriod = "GracePeriod", - RecentlyReleased = "RecentlyReleased", -} +/** + * $5.00 USD + */ +const DEFAULT_BASE_PRICE: Readonly = buildPrice(500n, Currency.Usd); -export type Registration = { - // Below timestamps are counted in seconds - registrationTimestamp: Timestamp | null; - expirationTimestamp: Timestamp | null; - expiryTimestamp: Timestamp | null; +/** + * $640.00 USD + */ +const THREE_CHAR_BASE_PRICE: Readonly = buildPrice(64000n, Currency.Usd); - primaryStatus: PrimaryRegistrationStatus; - secondaryStatus: SecondaryRegistrationStatus | null; -}; +/** + * $160.00 USD + */ +const FOUR_CHAR_BASE_PRICE: Readonly = buildPrice(16000n, Currency.Usd); export const getDomainRegistration = ( /* @@ -557,35 +634,160 @@ const getSecondaryRegistrationStatus = ( } }; -// EXPIRATION STATUS ⬇️ +// RELEASE STATUS ⬇️ /** - * Returns the expiration timestamp of a domain + * Returns the release timestamp of a domain, which is 90 days after expiration when the Grace Period ends * @param domainRegistration Registration object from domain * @returns Timestamp | null */ -export function domainExpirationTimestamp( +export function getDomainReleaseTimestamp( domainRegistration: Registration, ): Timestamp | null { - if (domainRegistration.expirationTimestamp) { - return domainRegistration.expirationTimestamp; + if (!domainRegistration.expirationTimestamp) return null; + + return addSeconds(domainRegistration.expirationTimestamp, GRACE_PERIOD); +} + +const MAINNET_WRAPPING_ETH_REGISTRAR_CONTROLLER_CONTRACT = buildContractRef( + MAINNET, + buildAddress("0x253553366Da8546fC250F225fe3d25d0C782303b"), +); + +const MAINNET_CLASSIC_ETH_REGISTRAR_CONTROLLER_CONTRACT = buildContractRef( + MAINNET, + buildAddress("0x283af0b28c62c092c9727f1ee09c02ca627eb7f5"), +); + +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 getRegistryForChain = (chain: ChainId): Registry => { + switch (chain.chainId) { + case MAINNET.chainId: + return MAINNET_ENS_REGISTRY; + default: + throw new Error(`Unsupported chainId: ${chain.chainId}`); + } +}; + +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; } - return null; } -// RELEASE STATUS ⬇️ +export class NameWrapper implements NFTIssuer { + protected readonly contract: ContractRef; -/** - * Returns the release timestamp of a domain, which is 90 days after expiration when the Grace Period ends - * @param domainRegistration Registration object from domain - * @returns Timestamp | null - */ -export function domainReleaseTimestamp( - domainRegistration: Registration, -): Timestamp | null { - const expirationTimestamp = domainExpirationTimestamp(domainRegistration); - if (expirationTimestamp === null) return null; + public constructor(contract: ContractRef) { + this.contract = contract; + } - const releaseTimestamp = addSeconds(expirationTimestamp, GRACE_PERIOD); - return releaseTimestamp; + 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/namekitregistrar.ts b/packages/ens-utils/src/namekitregistrar.ts new file mode 100644 index 000000000..e3b990d80 --- /dev/null +++ b/packages/ens-utils/src/namekitregistrar.ts @@ -0,0 +1,98 @@ +import { buildAddress } from "./address"; +import { ChainId, 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 customize a pricing policy that adds a registrar service + * fee to the "base" .eth registration rate. + */ +export class NameKitRegistrar extends EthRegistrar { + + /** + * 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[] { + 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 NameKitRegistrar service fee.", + }; + + return [...baseCharges, serviceFee]; + } +} + +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, + MAINNET_EXAMPLE_NAMEKIT_REGISTRAR_CONTRACT, +); diff --git a/packages/ens-utils/src/nft.test.ts b/packages/ens-utils/src/nft.test.ts index 0f6d1ce2c..0ff09ac97 100644 --- a/packages/ens-utils/src/nft.test.ts +++ b/packages/ens-utils/src/nft.test.ts @@ -1,17 +1,15 @@ import { describe, it, expect } from "vitest"; - import { - buildNFTRef, - convertNFTRefToString, - buildNFTReferenceFromString, - buildTokenId, + buildNFTRef, + convertNFTRefToString, + buildNFTReferenceFromString, + buildTokenId, } from "./nft"; import { MAINNET } from "./chain"; import { buildContractRef } from "./contract"; import { buildAddress } from "./address"; describe("buildTokenId() function", () => { - it("Non-integer tokenId", () => { const tokenId = "x"; @@ -30,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, }); }); @@ -49,64 +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 few params", () => { - expect(() => buildNFTReferenceFromString(":")).toThrow(); - }); - - it("too many params", () => { - expect(() => buildNFTReferenceFromString(":::")).toThrow(); - }); - - it("valid params", () => { - const result = buildNFTReferenceFromString("1:0x1234567890123456789012345678901234567890:1234567890123456789012345678901234567890"); + it("too many params", () => { + expect(() => buildNFTReferenceFromString(":::")).toThrow(); + }); - const chain = MAINNET; - const contractAddress = buildAddress("0x1234567890123456789012345678901234567890"); - const contract = buildContractRef(chain, contractAddress); - const token = buildTokenId(1234567890123456789012345678901234567890n); - const nft = buildNFTRef(contract, token); + it("valid params", () => { + const result = buildNFTReferenceFromString( + "1:0x1234567890123456789012345678901234567890:1234567890123456789012345678901234567890", + ); - expect(result).toStrictEqual(nft); - }); + const chain = MAINNET; + const contractAddress = buildAddress( + "0x1234567890123456789012345678901234567890", + ); + const contract = buildContractRef(chain, contractAddress); + const token = buildTokenId(1234567890123456789012345678901234567890n); + const nft = buildNFTRef(contract, token); -}); \ No newline at end of file + expect(result).toStrictEqual(nft); + }); +}); diff --git a/packages/ens-utils/src/nft.ts b/packages/ens-utils/src/nft.ts index cd14d47f6..18f8094c1 100644 --- a/packages/ens-utils/src/nft.ts +++ b/packages/ens-utils/src/nft.ts @@ -1,62 +1,67 @@ import { buildAddress } from "./address"; -import { buildChainId } from "./chain"; +import { buildChainId, ChainId } from "./chain"; import { ContractRef, buildContractRef } from "./contract"; +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; - } - - if (tokenId < 0) { - throw new Error(`Invalid token ID: ${maybeTokenId}. All token ID values must be non-negative.`); +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.`, + ); } - - if (tokenId > MAX_UINT256) { - throw new Error(`Invalid token ID: ${maybeTokenId}. All token ID values must be representable as a uint256 value.`); - } - - return { - tokenId - }; -} + } else { + tokenId = maybeTokenId; + } + + 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.`, + ); + } + + 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. @@ -64,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}`; }; /** @@ -91,19 +90,88 @@ 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); -} \ No newline at end of file +export interface NFTIssuer { + getContractRef(): ContractRef; + getTokenId(name: ENSName, isWrapped: boolean): TokenId; + isClaimable(name: ENSName, isWrapped: boolean): boolean; +} + +/** + * NOTE: Need to add `NFTIssuer` objects to `KNOWN_NFT_ISSUERS` as they are + * defined in order to use + */ +export const KNOWN_NFT_ISSUERS: NFTIssuer[] = []; + +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.getContractRef(), 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.getContractRef().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]; + } +} diff --git a/packages/ens-utils/src/price.ts b/packages/ens-utils/src/price.ts index 16683fae3..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 @@ -18,6 +19,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) / @@ -221,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.test.ts b/packages/ens-utils/src/registrar.test.ts new file mode 100644 index 000000000..017d00a62 --- /dev/null +++ b/packages/ens-utils/src/registrar.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { buildPrice, scaleAnnualPrice } from "./price"; +import { Currency } from "./currency"; +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 annualPrice = buildPrice(100n, Currency.Usd); + const years = 0.5; + const duration = scaleDuration(SECONDS_PER_YEAR, years); + const result = scaleAnnualPrice(annualPrice, duration); + + 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 annualPrice = buildPrice(100n, Currency.Usd); + const years = 1.5; + const duration = scaleDuration(SECONDS_PER_YEAR, years); + const result = scaleAnnualPrice(annualPrice, duration); + + const expectedResult = buildPrice(150n, Currency.Usd); + + expect(result).toEqual(expectedResult); + }); + + it("should scale a one year price to a two years price", () => { + const annualPrice = buildPrice(100n, Currency.Usd); + const years = 2n; + const duration = scaleDuration(SECONDS_PER_YEAR, years); + const result = scaleAnnualPrice(annualPrice, duration); + + const expectedResult = buildPrice(200n, Currency.Usd); + + expect(result).toEqual(expectedResult); + }); + + it("should scale a one year price to a five years price", () => { + const annualPrice = buildPrice(100n, Currency.Usd); + const years = 5n; + const duration = scaleDuration(SECONDS_PER_YEAR, years); + const result = scaleAnnualPrice(annualPrice, duration); + + const expectedResult = buildPrice(500n, Currency.Usd); + + expect(result).toEqual(expectedResult); + }); + + 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); + const result = scaleAnnualPrice(annualPrice, duration); + + const expectedResult = buildPrice(100n, Currency.Usd); + + expect(result).toEqual(expectedResult); + }); +}); diff --git a/packages/ens-utils/src/registrar.ts b/packages/ens-utils/src/registrar.ts new file mode 100644 index 000000000..6fc71c68a --- /dev/null +++ b/packages/ens-utils/src/registrar.ts @@ -0,0 +1,299 @@ +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 ⬇️ + +// 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; +} + +export interface RegistrarBaseFee extends AbstractRegistrarCharge { + type: typeof RegistrarChargeType.BaseFee; + price: Price; + reason?: undefined; + validity?: undefined; +} + +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 interface RegistrationPriceQuote extends AbstractRegistrarPriceQuote { + action: typeof RegistrarAction.Register; +} + +export interface RenewalPriceQuote extends AbstractRegistrarPriceQuote { + action: typeof RegistrarAction.Renew; +} + +/** + * 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 + * 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; + + /** + * 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; + + /** + * 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 `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, + 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; +} + +/** + * 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 issuable by `Registrar`. + * @param label the `ENSName` value that is NOT issuable by `Registrar`. + */ +export class RegistrarUnsupportedNameError extends Error { + public constructor(message: string, unsupportedName: ENSName) { + super( + `RegistrarUnsupportedNameError for name "${unsupportedName.name}": ${message}`, + ); + this.name = "RegistrarUnsupportedNameError"; + Error.captureStackTrace(this, this.constructor); + } +} 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; + } +} diff --git a/packages/ens-utils/tsconfig.json b/packages/ens-utils/tsconfig.json index 63e6ab7e2..0d448fe85 100644 --- a/packages/ens-utils/tsconfig.json +++ b/packages/ens-utils/tsconfig.json @@ -10,6 +10,8 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "./src", }, "exclude": ["node_modules"] } diff --git a/packages/namekit-react/tsconfig.json b/packages/namekit-react/tsconfig.json index cfe80d1a9..4b0d135bc 100644 --- a/packages/namekit-react/tsconfig.json +++ b/packages/namekit-react/tsconfig.json @@ -2,8 +2,11 @@ "compilerOptions": { "strict": true, "jsx": "react", - "module": "esnext", - "moduleResolution": "node", + "module": "ES2020", + "outDir": "./dist", + "rootDir": "./src", + "moduleResolution": "bundler", + "target": "ES2020", "resolveJsonModule": true, "isolatedModules": true, "esModuleInterop": true,