From 90a13c5181f06acc7439859efa9ed16b569c4cf3 Mon Sep 17 00:00:00 2001 From: FrancoAguzzi Date: Tue, 30 Jul 2024 15:19:29 -0300 Subject: [PATCH 1/9] feat: [SC-25546] Add domain related logics to ens-utils (Registration, DomainName, DomainCard, etc.) --- packages/ens-utils/src/domain.ts | 340 +++++++++++++++++++++++++ packages/ens-utils/src/ethregistrar.ts | 307 +++++++++++++++++++++- packages/ens-utils/src/index.ts | 5 +- packages/ens-utils/src/price.ts | 64 ++--- packages/ens-utils/src/registration.ts | 86 +++++++ 5 files changed, 752 insertions(+), 50 deletions(-) create mode 100644 packages/ens-utils/src/domain.ts create mode 100644 packages/ens-utils/src/registration.ts diff --git a/packages/ens-utils/src/domain.ts b/packages/ens-utils/src/domain.ts new file mode 100644 index 000000000..bf19352b3 --- /dev/null +++ b/packages/ens-utils/src/domain.ts @@ -0,0 +1,340 @@ +import { Registration } from "./registration"; +import { ENSName, MIN_ETH_REGISTRABLE_LABEL_LENGTH } from "./ensname"; +import { Timestamp, addSeconds } from "./time"; +import { NFTRef } from "./nft"; +import { GRACE_PERIOD } from "./ethregistrar"; +import { Address, buildAddress, isAddressEqual } from "./address"; +import { hexToBigInt, keccak256, labelhash as labelHash, namehash } from "viem"; +import { ens_beautify, ens_normalize } from "@adraffy/ens-normalize"; + +/** + * Object containing properties necessary for domain name processing. + * It is computed out of the user input, URL query parameter or database row data. + */ +export type DomainName = { + /** Unique identifier of a domain */ + namehash: string; + /** Domain slug to be used for URLs. It has a format of [labelhash].eth when the domain name is unknown or unnormalized */ + slug: string; + /** Beautified domain name string, to be rendered in user interface */ + displayName: string; + /** Normalized version of the name. Similar to `slug`, but it is null when the domain name is unknown or unnormalized */ + normalizedName: string | null; + /** The label of the name. It can either be string like `vitalik` or `[0x123]` */ + labelName: string; + /** keccak256 hash of the label */ + labelHash: string; + unwrappedTokenId: bigint; + wrappedTokenId: bigint; +}; + +export type DomainCard = { + name: ENSName; + + /** + * A reference to the NFT associated with `name`. + * + * null if and only if one or more of the following are true: + * 1. name is not normalized + * 2. name is not currently minted (name is on primary market, not secondary market) and the name is not currently expired in grace period + * 3. we don't know a strategy to generate a NFTRef for the name on the specified chain (ex: name is associated with an unknown registrar) + */ + nft: NFTRef | null; + parsedName: DomainName; + registration: Registration; + /** Stringified JSON object with debug information about the name generator */ + nameGeneratorMetadata: string | null; + /** Whether the domain is on watchlist */ + onWatchlist: boolean; + ownerAddress: `0x${string}` | null; + managerAddress: `0x${string}` | null; + /** Former owner address is only set when the domain is in Grace Period */ + formerOwnerAddress: `0x${string}` | null; + /** Former manager address is only set when the domain is in Grace Period */ + formerManagerAddress: `0x${string}` | null; +}; + +/** + * 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; +} + +/** + * 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; + + const releaseTimestamp = addSeconds(expirationTimestamp, GRACE_PERIOD); + return releaseTimestamp; +} + +/* + Below enum options differ based on domain's owner + and on its secondary marketplace status: + If domain has no owner: noOwner; + If domain has an owner but user is not the owner: notOwner; + If user is owner of the domain and domain is in Grace Period: formerOwner; + If user is owner of the domain and domain is not in Grace Period: activeOwner; +*/ +export enum UserOwnershipOfDomain { + noOwner = "noOwner", + notOwner = "notOwner", + formerOwner = "formerOwner", + activeOwner = "activeOwner", +} + +/** + * Returns the ownership status of a domain in comparison to the current user's address + * @param domain Domain that is being checked + * @param currentUserAddress Address of the current user. + * @returns UserOwnershipOfDomain + */ +export const getCurrentUserOwnership = ( + domain: DomainCard | null, + currentUserAddress: Address | null, +): UserOwnershipOfDomain => { + const formerDomainOwnerAddress = + domain && domain.formerOwnerAddress + ? buildAddress(domain.formerOwnerAddress) + : null; + const ownerAddress = + domain && domain.ownerAddress ? buildAddress(domain.ownerAddress) : null; + + if (currentUserAddress && formerDomainOwnerAddress) { + const isFormerOwner = + formerDomainOwnerAddress && + isAddressEqual(formerDomainOwnerAddress, currentUserAddress); + + if (isFormerOwner) { + return UserOwnershipOfDomain.formerOwner; + } + + const isOwner = + ownerAddress && isAddressEqual(currentUserAddress, ownerAddress); + + if (isOwner) { + return UserOwnershipOfDomain.activeOwner; + } + } + + if (!ownerAddress) { + return UserOwnershipOfDomain.noOwner; + } + + 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 getPrefixes = (input: string): string[] => { + const prefixes: string[] = []; + + for (let i = 1; i <= input.length; i++) { + prefixes.push(input.slice(0, i)); + } + + return prefixes; +}; + +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; +}; + +/** + * Parse and heal input string to a DomainName. + * @param input User input or slug. + * @return Object containing properties necessary for DomainName for any supported name. + * @throws {ParseNameError}, when input is unsupported or cannot be healed. + */ +export const getDomainName = (input = ""): DomainName => { + const cleanedInput = input.replace(/ /g, ""); + + if (cleanedInput.length === 0) { + throw new ParseNameError("Empty name", ParseNameErrorCode.Empty, null); + } + + const inputLabels = cleanedInput.split("."); + + let curatedLabels: string[] = []; + + if (inputLabels.length < 2) { + curatedLabels = [...inputLabels, DEFAULT_TLD]; + } else { + curatedLabels = inputLabels; + } + + // auto-fill top level domain + if ( + getPrefixes(DEFAULT_TLD).some( + (prefix) => curatedLabels[curatedLabels.length - 1] === prefix, + ) || + curatedLabels[curatedLabels.length - 1] === "" + ) { + curatedLabels = [...curatedLabels.slice(0, -1), DEFAULT_TLD]; + } + + if (curatedLabels[curatedLabels.length - 1] !== DEFAULT_TLD) { + throw new ParseNameError( + "Unsupported top level name", + ParseNameErrorCode.UnsupportedTLD, + null, + ); + } + + if (curatedLabels.length > 2) { + throw new ParseNameError( + "Unsupported subdomain", + ParseNameErrorCode.UnsupportedSubdomain, + null, + ); + } + + const firstCuratedLabel = curatedLabels[0].toLowerCase(); + + // handle undiscovered name format, like [0x00...].eth + if (firstCuratedLabel.startsWith("[") && firstCuratedLabel.endsWith("]")) { + if (hasMissingNameFormat(firstCuratedLabel)) { + const searchedName = curatedLabels.join(".").toLowerCase(); + const namehash = namehashFromMissingName(searchedName); + const labelHash = "0x" + firstCuratedLabel.slice(1, -1); + + return { + namehash, + slug: searchedName, + displayName: searchedName, + normalizedName: null, + labelName: firstCuratedLabel, + labelHash, + + // Below values are guaranteed to be 0x strings + unwrappedTokenId: hexToBigInt(labelHash as `0x${string}`), + wrappedTokenId: hexToBigInt(namehash as `0x${string}`), + }; + } else { + throw new ParseNameError( + "Invalid labelhash", + ParseNameErrorCode.MalformedLabelHash, + null, + ); + } + } else { + const searchedName = curatedLabels.join("."); + + let normalizedName = null; + try { + normalizedName = ens_normalize(searchedName); + } catch (e) { + throw new ParseNameError( + "Invalid ENS name", + ParseNameErrorCode.MalformedName, + null, + ); + } + + const normalizedLabel = normalizedName.split(".")[0]; + if (normalizedLabel.length < MIN_ETH_REGISTRABLE_LABEL_LENGTH) { + throw new ParseNameError( + "Name is too short", + ParseNameErrorCode.TooShort, + { + normalizedName, + displayName: ens_beautify(normalizedName), + }, + ); + } + + const nh = namehash(normalizedName); + const labelHash = labelhash(normalizedLabel); + const displayName = ens_beautify(normalizedName); + + return { + namehash: nh, + slug: normalizedName, + displayName, + normalizedName, + labelName: normalizedLabel, + labelHash, + + // Below values are guaranteed to be 0x strings + unwrappedTokenId: hexToBigInt(labelHash as `0x${string}`), + wrappedTokenId: hexToBigInt(nh as `0x${string}`), + }; + } +}; diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index c63a5d82e..a981ca155 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -1,14 +1,38 @@ import { ENSName, + MIN_ETH_REGISTRABLE_LABEL_LENGTH, ETH_TLD, charCount, - MIN_ETH_REGISTRABLE_LABEL_LENGTH, } 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 { + PrimaryRegistrationStatus, + Registration, + SecondaryRegistrationStatus, +} from "./registration"; +import { + Duration, + SECONDS_PER_DAY, + Timestamp, + addSeconds, + buildDuration, + formatTimestampAsDistanceToNow, + now, +} from "./time"; +import { + Price, + addPrices, + approxScalePrice, + formattedPrice, + multiplyPriceByNumber, + subtractPrices, +} from "./price"; +import { DomainName } from "./domain"; +import { Currency } from "./currency"; export interface Registrar { contract: ContractRef; @@ -155,3 +179,284 @@ export function buildNFTRefFromENSName( return buildNFTRef(registrar.contract, token); } + +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, +); + +export const DOMAIN_HAS_SPECIAL_PRICE_IF_LENGTH_EQUAL_OR_LESS_THAN = 5; + +/* + 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; +} + +export const getPriceDescription = ( + registration: Registration, + parsedName: DomainName, +): 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(parsedName); + + if (!domainBasePrice) return null; + else { + const domainPrice = formattedPrice({ + price: domainBasePrice, + withPrefix: true, + }); + const pricePerYearDescription = `${domainPrice} / year`; + + const premiumEndsIn = premiumPeriodEndsIn(registration)?.relativeTimestamp; + + if (premiumEndsIn) { + const premiumEndMessage = premiumEndsIn + ? ` Temporary premium ends ${premiumEndsIn}.` + : null; + const basePriceMessage = domainBasePrice + ? " Discounts continuously until dropping to " + : null; + + return { + pricePerYearDescription, + descriptiveTextBeginning: + "Recently released." + premiumEndMessage + basePriceMessage, + descriptiveTextEnd: ".", + }; + } else { + const domainLabelLength = parsedName.labelName.length; + + return domainLabelLength < + DOMAIN_HAS_SPECIAL_PRICE_IF_LENGTH_EQUAL_OR_LESS_THAN + ? { + pricePerYearDescription, + descriptiveTextBeginning: `${domainLabelLength}-character names are `, + descriptiveTextEnd: " to register.", + } + : null; + } + } +}; + +export const nameCurrentTemporaryPremium = ( + registration: Registration, +): Price | null => { + if (registration.expirationTimestamp) { + return temporaryPremiumPriceAtTimestamp( + now(), + registration.expirationTimestamp, + ); + } else { + return null; + } +}; + +/* Interface for premium period end details */ +export interface PremiumPeriodEndsIn { + relativeTimestamp: string; + timestamp: Timestamp; +} + +/** + * 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 + */ +export const premiumPeriodEndsIn = ( + registration: Registration, +): PremiumPeriodEndsIn | null => { + const isExpired = + registration.primaryStatus === PrimaryRegistrationStatus.Expired; + const wasRecentlyReleased = + registration.secondaryStatus === + SecondaryRegistrationStatus.RecentlyReleased; + + /* + A domain will only have a premium price if it has Expired and it was Recently Released + */ + if (!isExpired || !wasRecentlyReleased) return null; + + /* + 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; + + const releasedEpoch = addSeconds(registration.expiryTimestamp, GRACE_PERIOD); + const temporaryPremiumEndTimestamp = addSeconds( + releasedEpoch, + TEMPORARY_PREMIUM_PERIOD, + ); + console.log({ + relativeTimestamp: formatTimestampAsDistanceToNow( + temporaryPremiumEndTimestamp, + ), + timestamp: temporaryPremiumEndTimestamp, + }); + return { + relativeTimestamp: formatTimestampAsDistanceToNow( + temporaryPremiumEndTimestamp, + ), + timestamp: temporaryPremiumEndTimestamp, + }; +}; + +/** + * 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 = { + value: 10000000000n /* $100,000,000.00 (100 million USD) */, + currency: Currency.Usd, +}; + +/** + * The recently released temporary premium drops exponentially by 50% each day. + */ +const PREMIUM_DECAY = 0.5; + +/** + * Goal: + * The temporary premium should drop to $0.00 after exactly `PREMIUM_DAYS` days have passed. + * + * Challenge: + * If we decay `PREMIUM_START` by a rate of `PREMIUM_DECAY` each day over the course of + * `PREMIUM_DAYS` days we don't get $0.00 USD. Instead, we get this `PREMIUM_OFFSET` value + * ($47.68 USD). + * + * Solution: + * Subtract this value from the decayed temporary premium to get the actual temporary premium. + */ +export const PREMIUM_OFFSET = approxScalePrice( + PREMIUM_START_PRICE, + PREMIUM_DECAY ** Number(TEMPORARY_PREMIUM_DAYS), +); + +export function temporaryPremiumPriceAtTimestamp( + atTimestamp: Timestamp, + expirationTimestamp: Timestamp, +): Price { + const releasedTimestamp = addSeconds(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, + }; + } + + const fractionalDaysSinceRelease = + Number(secondsSinceRelease) / Number(SECONDS_PER_DAY.seconds); + + const decayFactor = PREMIUM_DECAY ** fractionalDaysSinceRelease; + + 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; +} + +/* + 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 = ( + parsedName: DomainName, + registerForYears = DEFAULT_REGISTRATION_YEARS, + registration: Registration | null = null, + additionalFee: Price | null = null, +): Price | null => { + if (!parsedName.normalizedName) return null; + + const defaultPrice: Readonly = { + value: 500n, + currency: Currency.Usd, + }; + const shortNamePremium: Record> = { + [MIN_ETH_REGISTRABLE_LABEL_LENGTH]: { + value: 64000n, + currency: Currency.Usd, + }, + 4: { + value: 16000n, + currency: Currency.Usd, + }, + }; + const basePrice = shortNamePremium[parsedName.labelName.length] + ? shortNamePremium[parsedName.labelName.length] + : defaultPrice; + + const namePriceForYears = multiplyPriceByNumber( + basePrice, + Number(registerForYears), + ); + + const namehashPrice = additionalFee + ? addPrices([additionalFee, namePriceForYears]) + : namePriceForYears; + + if (registration) { + const premiumPrice = nameCurrentTemporaryPremium(registration); + + return premiumPrice + ? addPrices([premiumPrice, namehashPrice]) + : namehashPrice; + } + + return namehashPrice; +}; + +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 = ( + domainName: DomainName, + registerForYears = DEFAULT_REGISTRATION_YEARS, +) => { + return AvailableNamePriceUSD(domainName, registerForYears); +}; diff --git a/packages/ens-utils/src/index.ts b/packages/ens-utils/src/index.ts index 87a8895d0..670bfbd86 100644 --- a/packages/ens-utils/src/index.ts +++ b/packages/ens-utils/src/index.ts @@ -8,6 +8,7 @@ export * from "./hashutils"; export * from "./nameparser"; export * from "./nft"; export * from "./number"; -export * from "./price"; export * from "./time"; -export * from "./transaction"; \ No newline at end of file +export * from "./domain"; +export * from "./registration"; +export * from "./price"; diff --git a/packages/ens-utils/src/price.ts b/packages/ens-utils/src/price.ts index 75ac307d2..6f7d8ddb1 100644 --- a/packages/ens-utils/src/price.ts +++ b/packages/ens-utils/src/price.ts @@ -1,8 +1,7 @@ -import { Currency, PriceCurrencyFormat, parseStringToCurrency } from "./currency"; -import { approxScaleBigInt, stringToBigInt } from "./number"; +import { Currency, PriceCurrencyFormat } from "./currency"; +import { approxScaleBigInt } from "./number"; export interface Price { - // TODO: consider adding a constraint where value is never negative /** * The value of the price. This is a BigInt to avoid floating point math issues when working with prices. @@ -15,40 +14,6 @@ export interface Price { currency: Currency; } -// An ExchangeRates object maps different currencies to their rate in USD, -// which is a number value. One example of an ExchangeRates object would be: -// { ETH: 1737.16, DAI: 0.99999703, USDC: 1, WETH: 1737.16, USD: 1 } -export interface ExchangeRates extends Partial> {} - -/** - * Builds a Price object. - * @param value the value of the price. This is a BigInt to avoid floating point math issues when working with prices. - * For example, a price of 1.23 USD would be represented as 123n with a currency of USD. - * Note that the value is always in the smallest unit of the currency (e.g. cents for USD, wei for ETH). - * See the CurrencyConfig for the related currency for the number of decimals to use when converting the value to a human-readable format. - * @param currency - * @returns - */ -export const buildPrice = (value: bigint | string, currency: Currency | string): Price => { - - let priceValue : bigint; - let priceCurrency : Currency; - - if (typeof value === "string") { - priceValue = stringToBigInt(value) - } else { - priceValue = value; - } - - if (typeof currency === "string") { - priceCurrency = parseStringToCurrency(currency); - } else { - priceCurrency = currency; - } - - return { value: priceValue, currency: priceCurrency }; -} - export const priceAsNumber = (price: Price): number => { return ( Number(price.value) / @@ -61,12 +26,12 @@ export const numberAsPrice = (number: number, currency: Currency): Price => { // Fix the number's displayed decimals (e.g. from 0.00001 to 0.00001) const numberWithCorrectCurrencyDecimals = Number( - number.toFixed(currencyDecimals) + number.toFixed(currencyDecimals), ); // Remove the decimals from the number (e.g. from 0.00001 to 1) const numberWithoutDecimals = Number( - numberWithCorrectCurrencyDecimals * 10 ** currencyDecimals + numberWithCorrectCurrencyDecimals * 10 ** currencyDecimals, ).toFixed(0); /* @@ -102,7 +67,7 @@ export const addPrices = (prices: Array): Price => { export const subtractPrices = (price1: Price, price2: Price): Price => { if (price1.currency !== price2.currency) { throw new Error( - `Cannot subtract price of currency ${price1.currency} to price of currency ${price2.currency}` + `Cannot subtract price of currency ${price1.currency} to price of currency ${price2.currency}`, ); } else { return { @@ -150,7 +115,7 @@ export const formattedPrice = ({ ) { // If formatted number is 0.0 but real 'value' is not 0, then we show the Underflow price formattedAmount = String( - PriceCurrencyFormat[price.currency].MinDisplayValue + PriceCurrencyFormat[price.currency].MinDisplayValue, ); } else if (wouldDisplayAsZero && price.value == 0n) { // But if the real 'value' is really 0, then we show 0.00 (in the correct number of Display Decimals) @@ -158,7 +123,7 @@ export const formattedPrice = ({ formattedAmount = prefix.padEnd( Number(PriceCurrencyFormat[price.currency].DisplayDecimals) + prefix.length, - "0" + "0", ); } @@ -168,10 +133,10 @@ export const formattedPrice = ({ formattedAmount = displayNumber.toLocaleString("en-US", { minimumFractionDigits: Number( - PriceCurrencyFormat[price.currency].DisplayDecimals + PriceCurrencyFormat[price.currency].DisplayDecimals, ), maximumFractionDigits: Number( - PriceCurrencyFormat[price.currency].DisplayDecimals + PriceCurrencyFormat[price.currency].DisplayDecimals, ), }); @@ -197,7 +162,7 @@ export const formattedPrice = ({ export const approxScalePrice = ( price: Price, scaleFactor: number, - digitsOfPrecision = 20n + digitsOfPrecision = 20n, ): Price => { return { value: approxScaleBigInt(price.value, scaleFactor, digitsOfPrecision), @@ -205,16 +170,21 @@ export const approxScalePrice = ( }; }; +// An ExchangeRates object maps different currencies to their rate in USD, +// which is a number value. One example of an ExchangeRates object would be: +// { ETH: 1737.16, DAI: 0.99999703, USDC: 1, WETH: 1737.16, USD: 1 } +export interface ExchangeRates extends Partial> {} + export const convertCurrencyWithRates = ( fromPrice: Price, toCurrency: Currency, - exchangeRates: ExchangeRates + exchangeRates: ExchangeRates, ): Price => { if (typeof exchangeRates[toCurrency] === "undefined") { throw new Error(`Exchange rate for currency ${toCurrency} not found`); } else if (typeof exchangeRates[fromPrice.currency] === "undefined") { throw new Error( - `Exchange rate for currency ${fromPrice.currency} not found` + `Exchange rate for currency ${fromPrice.currency} not found`, ); } diff --git a/packages/ens-utils/src/registration.ts b/packages/ens-utils/src/registration.ts new file mode 100644 index 000000000..165309700 --- /dev/null +++ b/packages/ens-utils/src/registration.ts @@ -0,0 +1,86 @@ +import { GRACE_PERIOD, TEMPORARY_PREMIUM_PERIOD } from "./ethregistrar"; +import { now, Timestamp } from "./time"; + +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. + */ + expiryTimestamp: Timestamp | null, +): Registration => { + if (!expiryTimestamp) { + return { + primaryStatus: PrimaryRegistrationStatus.NeverRegistered, + secondaryStatus: null, + registrationTimestamp: null, + expirationTimestamp: null, + expiryTimestamp: null, + }; + } + + const primaryStatus = getPrimaryRegistrationStatus(expiryTimestamp); + const secondaryStatus = getSecondaryRegistrationStatus(expiryTimestamp); + return { + expiryTimestamp, + primaryStatus, + secondaryStatus, + registrationTimestamp: null, + expirationTimestamp: expiryTimestamp, + }; +}; + +/* REGISTRATION STATUS ⬇️ */ + +const getPrimaryRegistrationStatus = ( + expiryTimestamp: Timestamp, +): PrimaryRegistrationStatus => { + const nowTime = now(); + return nowTime.time < expiryTimestamp.time + ? PrimaryRegistrationStatus.Active + : PrimaryRegistrationStatus.Expired; +}; + +const getSecondaryRegistrationStatus = ( + expiryTimestamp: Timestamp, +): SecondaryRegistrationStatus | null => { + const nowTime = now(); + + if (nowTime.time < expiryTimestamp.time) { + return nowTime.time > expiryTimestamp.time - GRACE_PERIOD.seconds + ? SecondaryRegistrationStatus.ExpiringSoon + : null; + } else { + if ( + expiryTimestamp.time + + GRACE_PERIOD.seconds + + TEMPORARY_PREMIUM_PERIOD.seconds < + nowTime.time + ) + return SecondaryRegistrationStatus.FullyReleased; + else if (expiryTimestamp.time + GRACE_PERIOD.seconds > nowTime.time) + return SecondaryRegistrationStatus.GracePeriod; + else return SecondaryRegistrationStatus.RecentlyReleased; + } +}; From 302ec4e87133f50f05c2d12034c2d281d62489aa Mon Sep 17 00:00:00 2001 From: FrancoAguzzi Date: Tue, 30 Jul 2024 15:21:18 -0300 Subject: [PATCH 2/9] feat: [SC-25546] Include @types/node into ens-utils --- packages/ens-utils/package.json | 1 + pnpm-lock.yaml | 120 +++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/packages/ens-utils/package.json b/packages/ens-utils/package.json index b372bd5c3..3f7eff312 100644 --- a/packages/ens-utils/package.json +++ b/packages/ens-utils/package.json @@ -42,6 +42,7 @@ "viem": "2.9.3" }, "devDependencies": { + "@types/node": "22.0.0", "tsup": "8.0.2", "typescript": "5.3.3", "vite": "5.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 818a75145..63558318d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,6 +344,9 @@ importers: specifier: 2.9.3 version: 2.9.3(typescript@5.3.3) devDependencies: + '@types/node': + specifier: 22.0.0 + version: 22.0.0 tsup: specifier: 8.0.2 version: 8.0.2(typescript@5.3.3) @@ -352,10 +355,10 @@ importers: version: 5.3.3 vite: specifier: 5.1.7 - version: 5.1.7(@types/node@20.12.12) + version: 5.1.7(@types/node@22.0.0) vitest: specifier: 1.4.0 - version: 1.4.0(@types/node@20.12.12) + version: 1.4.0(@types/node@22.0.0) packages/ens-webfont: {} @@ -12324,6 +12327,27 @@ packages: - terser dev: true + /vite-node@1.4.0(@types/node@22.0.0): + resolution: {integrity: sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.3.4 + pathe: 1.1.2 + picocolors: 1.0.1 + vite: 5.1.7(@types/node@22.0.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + /vite-node@1.6.0(@types/node@20.12.7): resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -12417,6 +12441,42 @@ packages: fsevents: 2.3.3 dev: true + /vite@5.1.7(@types/node@22.0.0): + resolution: {integrity: sha512-sgnEEFTZYMui/sTlH1/XEnVNHMujOahPLGMxn1+5sIT45Xjng1Ec1K78jRP15dSmVgg5WBin9yO81j3o9OxofA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 22.0.0 + esbuild: 0.19.12 + postcss: 8.4.40 + rollup: 4.17.2 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /vitest@1.4.0(@types/node@20.12.12): resolution: {integrity: sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -12473,6 +12533,62 @@ packages: - terser dev: true + /vitest@1.4.0(@types/node@22.0.0): + resolution: {integrity: sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.4.0 + '@vitest/ui': 1.4.0 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@types/node': 22.0.0 + '@vitest/expect': 1.4.0 + '@vitest/runner': 1.4.0 + '@vitest/snapshot': 1.4.0 + '@vitest/spy': 1.4.0 + '@vitest/utils': 1.4.0 + acorn-walk: 8.3.2 + chai: 4.4.1 + debug: 4.3.4 + execa: 8.0.1 + local-pkg: 0.5.0 + magic-string: 0.30.10 + pathe: 1.1.2 + picocolors: 1.0.1 + std-env: 3.7.0 + strip-literal: 2.1.0 + tinybench: 2.8.0 + tinypool: 0.8.4 + vite: 5.1.7(@types/node@22.0.0) + vite-node: 1.4.0(@types/node@22.0.0) + why-is-node-running: 2.2.2 + transitivePeerDependencies: + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + /vitest@1.6.0(@types/node@20.12.7): resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} engines: {node: ^18.0.0 || >=20.0.0} From c2fb8243d575e876b6800958f5b734d1bc6ce687 Mon Sep 17 00:00:00 2001 From: FrancoAguzzi Date: Tue, 30 Jul 2024 15:23:31 -0300 Subject: [PATCH 3/9] feat: [SC-25546] Include changeset in this PR since it updates ens-utils pckg --- .changeset/neat-lamps-sell.md | 5 +++++ .changeset/silly-walls-eat.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/neat-lamps-sell.md create mode 100644 .changeset/silly-walls-eat.md diff --git a/.changeset/neat-lamps-sell.md b/.changeset/neat-lamps-sell.md new file mode 100644 index 000000000..0ce347bac --- /dev/null +++ b/.changeset/neat-lamps-sell.md @@ -0,0 +1,5 @@ +--- +"@namehash/ens-utils": minor +--- + +Add domain related logics to ens-utils (Registration, DomainName, DomainCard, etc.) diff --git a/.changeset/silly-walls-eat.md b/.changeset/silly-walls-eat.md new file mode 100644 index 000000000..5a6f5f42e --- /dev/null +++ b/.changeset/silly-walls-eat.md @@ -0,0 +1,5 @@ +--- +"@namehash/ens-utils": major +--- + +Add domain related logics to ens-utils (Registration, DomainName, DomainCard, etc.) From 55b54461673be3a0ae9dabe2571d4d78ecf27e04 Mon Sep 17 00:00:00 2001 From: FrancoAguzzi Date: Thu, 1 Aug 2024 14:14:24 -0300 Subject: [PATCH 4/9] feat: [SC-25546] Delete unneeded changeset --- .changeset/silly-walls-eat.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/silly-walls-eat.md diff --git a/.changeset/silly-walls-eat.md b/.changeset/silly-walls-eat.md deleted file mode 100644 index 5a6f5f42e..000000000 --- a/.changeset/silly-walls-eat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@namehash/ens-utils": major ---- - -Add domain related logics to ens-utils (Registration, DomainName, DomainCard, etc.) From cd5e39ac0d114353754366a97582f7fc59aeb8b6 Mon Sep 17 00:00:00 2001 From: FrancoAguzzi Date: Thu, 1 Aug 2024 14:15:11 -0300 Subject: [PATCH 5/9] feat: [SC-25546] Update UserOwnershipOfDomain enum definition --- packages/ens-utils/src/domain.ts | 251 ++++--------------------------- 1 file changed, 31 insertions(+), 220 deletions(-) diff --git a/packages/ens-utils/src/domain.ts b/packages/ens-utils/src/domain.ts index bf19352b3..8aee68335 100644 --- a/packages/ens-utils/src/domain.ts +++ b/packages/ens-utils/src/domain.ts @@ -1,34 +1,10 @@ -import { Registration } from "./registration"; -import { ENSName, MIN_ETH_REGISTRABLE_LABEL_LENGTH } from "./ensname"; -import { Timestamp, addSeconds } from "./time"; import { NFTRef } from "./nft"; -import { GRACE_PERIOD } from "./ethregistrar"; -import { Address, buildAddress, isAddressEqual } from "./address"; -import { hexToBigInt, keccak256, labelhash as labelHash, namehash } from "viem"; -import { ens_beautify, ens_normalize } from "@adraffy/ens-normalize"; +import { ENSName } from "./ensname"; +import { Address, isAddressEqual } from "./address"; +import { keccak256, labelhash as labelHash } from "viem"; +import { Registration } from "./ethregistrar"; -/** - * Object containing properties necessary for domain name processing. - * It is computed out of the user input, URL query parameter or database row data. - */ -export type DomainName = { - /** Unique identifier of a domain */ - namehash: string; - /** Domain slug to be used for URLs. It has a format of [labelhash].eth when the domain name is unknown or unnormalized */ - slug: string; - /** Beautified domain name string, to be rendered in user interface */ - displayName: string; - /** Normalized version of the name. Similar to `slug`, but it is null when the domain name is unknown or unnormalized */ - normalizedName: string | null; - /** The label of the name. It can either be string like `vitalik` or `[0x123]` */ - labelName: string; - /** keccak256 hash of the label */ - labelHash: string; - unwrappedTokenId: bigint; - wrappedTokenId: bigint; -}; - -export type DomainCard = { +export interface DomainCard { name: ENSName; /** @@ -40,68 +16,37 @@ export type DomainCard = { * 3. we don't know a strategy to generate a NFTRef for the name on the specified chain (ex: name is associated with an unknown registrar) */ nft: NFTRef | null; - parsedName: DomainName; registration: Registration; - /** Stringified JSON object with debug information about the name generator */ - nameGeneratorMetadata: string | null; - /** Whether the domain is on watchlist */ - onWatchlist: boolean; - ownerAddress: `0x${string}` | null; - managerAddress: `0x${string}` | null; + ownerAddress: Address | null; + managerAddress: Address | null; /** Former owner address is only set when the domain is in Grace Period */ - formerOwnerAddress: `0x${string}` | null; + formerOwnerAddress: Address | null; /** Former manager address is only set when the domain is in Grace Period */ - formerManagerAddress: `0x${string}` | null; -}; - -/** - * 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; + formerManagerAddress: Address | null; } -/** - * 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; +/* Defines the ownership of a domain for a given address */ +export const UserOwnershipOfDomain = { + /* NoOwner: If domain has no owner */ + NoOwner: "NoOwner", - const releaseTimestamp = addSeconds(expirationTimestamp, GRACE_PERIOD); - return releaseTimestamp; -} + /* NotOwner: If domain has an owner but user is not the owner */ + NotOwner: "NotOwner", -/* - Below enum options differ based on domain's owner - and on its secondary marketplace status: - If domain has no owner: noOwner; - If domain has an owner but user is not the owner: notOwner; - If user is owner of the domain and domain is in Grace Period: formerOwner; - If user is owner of the domain and domain is not in Grace Period: activeOwner; -*/ -export enum UserOwnershipOfDomain { - noOwner = "noOwner", - notOwner = "notOwner", - formerOwner = "formerOwner", - activeOwner = "activeOwner", -} + /* FormerOwner: If user is owner of the domain and domain is in Grace Period */ + FormerOwner: "FormerOwner", + + /* ActiveOwner: If user is owner of the domain and domain is 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 + * @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 */ export const getCurrentUserOwnership = ( @@ -109,11 +54,9 @@ export const getCurrentUserOwnership = ( currentUserAddress: Address | null, ): UserOwnershipOfDomain => { const formerDomainOwnerAddress = - domain && domain.formerOwnerAddress - ? buildAddress(domain.formerOwnerAddress) - : null; + domain && domain.formerOwnerAddress ? domain.formerOwnerAddress : null; const ownerAddress = - domain && domain.ownerAddress ? buildAddress(domain.ownerAddress) : null; + domain && domain.ownerAddress ? domain.ownerAddress : null; if (currentUserAddress && formerDomainOwnerAddress) { const isFormerOwner = @@ -121,22 +64,22 @@ export const getCurrentUserOwnership = ( isAddressEqual(formerDomainOwnerAddress, currentUserAddress); if (isFormerOwner) { - return UserOwnershipOfDomain.formerOwner; + return UserOwnershipOfDomain.FormerOwner; } const isOwner = ownerAddress && isAddressEqual(currentUserAddress, ownerAddress); if (isOwner) { - return UserOwnershipOfDomain.activeOwner; + return UserOwnershipOfDomain.ActiveOwner; } } if (!ownerAddress) { - return UserOwnershipOfDomain.noOwner; + return UserOwnershipOfDomain.NoOwner; } - return UserOwnershipOfDomain.notOwner; + return UserOwnershipOfDomain.NotOwner; }; export enum ParseNameErrorCode { @@ -181,16 +124,6 @@ export const hasMissingNameFormat = (label: string) => const labelhash = (label: string) => labelHash(label); -const getPrefixes = (input: string): string[] => { - const prefixes: string[] = []; - - for (let i = 1; i <= input.length; i++) { - prefixes.push(input.slice(0, i)); - } - - return prefixes; -}; - const keccak = (input: Buffer | string) => { let out = null; if (Buffer.isBuffer(input)) { @@ -216,125 +149,3 @@ export const namehashFromMissingName = (inputName: string): string => { } return "0x" + node; }; - -/** - * Parse and heal input string to a DomainName. - * @param input User input or slug. - * @return Object containing properties necessary for DomainName for any supported name. - * @throws {ParseNameError}, when input is unsupported or cannot be healed. - */ -export const getDomainName = (input = ""): DomainName => { - const cleanedInput = input.replace(/ /g, ""); - - if (cleanedInput.length === 0) { - throw new ParseNameError("Empty name", ParseNameErrorCode.Empty, null); - } - - const inputLabels = cleanedInput.split("."); - - let curatedLabels: string[] = []; - - if (inputLabels.length < 2) { - curatedLabels = [...inputLabels, DEFAULT_TLD]; - } else { - curatedLabels = inputLabels; - } - - // auto-fill top level domain - if ( - getPrefixes(DEFAULT_TLD).some( - (prefix) => curatedLabels[curatedLabels.length - 1] === prefix, - ) || - curatedLabels[curatedLabels.length - 1] === "" - ) { - curatedLabels = [...curatedLabels.slice(0, -1), DEFAULT_TLD]; - } - - if (curatedLabels[curatedLabels.length - 1] !== DEFAULT_TLD) { - throw new ParseNameError( - "Unsupported top level name", - ParseNameErrorCode.UnsupportedTLD, - null, - ); - } - - if (curatedLabels.length > 2) { - throw new ParseNameError( - "Unsupported subdomain", - ParseNameErrorCode.UnsupportedSubdomain, - null, - ); - } - - const firstCuratedLabel = curatedLabels[0].toLowerCase(); - - // handle undiscovered name format, like [0x00...].eth - if (firstCuratedLabel.startsWith("[") && firstCuratedLabel.endsWith("]")) { - if (hasMissingNameFormat(firstCuratedLabel)) { - const searchedName = curatedLabels.join(".").toLowerCase(); - const namehash = namehashFromMissingName(searchedName); - const labelHash = "0x" + firstCuratedLabel.slice(1, -1); - - return { - namehash, - slug: searchedName, - displayName: searchedName, - normalizedName: null, - labelName: firstCuratedLabel, - labelHash, - - // Below values are guaranteed to be 0x strings - unwrappedTokenId: hexToBigInt(labelHash as `0x${string}`), - wrappedTokenId: hexToBigInt(namehash as `0x${string}`), - }; - } else { - throw new ParseNameError( - "Invalid labelhash", - ParseNameErrorCode.MalformedLabelHash, - null, - ); - } - } else { - const searchedName = curatedLabels.join("."); - - let normalizedName = null; - try { - normalizedName = ens_normalize(searchedName); - } catch (e) { - throw new ParseNameError( - "Invalid ENS name", - ParseNameErrorCode.MalformedName, - null, - ); - } - - const normalizedLabel = normalizedName.split(".")[0]; - if (normalizedLabel.length < MIN_ETH_REGISTRABLE_LABEL_LENGTH) { - throw new ParseNameError( - "Name is too short", - ParseNameErrorCode.TooShort, - { - normalizedName, - displayName: ens_beautify(normalizedName), - }, - ); - } - - const nh = namehash(normalizedName); - const labelHash = labelhash(normalizedLabel); - const displayName = ens_beautify(normalizedName); - - return { - namehash: nh, - slug: normalizedName, - displayName, - normalizedName, - labelName: normalizedLabel, - labelHash, - - // Below values are guaranteed to be 0x strings - unwrappedTokenId: hexToBigInt(labelHash as `0x${string}`), - wrappedTokenId: hexToBigInt(nh as `0x${string}`), - }; - } -}; From 71fa0090892fa8561fab41d0d0144d57ac27af12 Mon Sep 17 00:00:00 2001 From: FrancoAguzzi Date: Thu, 1 Aug 2024 14:15:49 -0300 Subject: [PATCH 6/9] feat: [SC-25546] Move functions and delete registration.ts --- packages/ens-utils/src/ethregistrar.ts | 247 +++++++++++++++++++------ packages/ens-utils/src/index.ts | 2 +- packages/ens-utils/src/price.ts | 30 ++- packages/ens-utils/src/registration.ts | 86 --------- 4 files changed, 217 insertions(+), 148 deletions(-) delete mode 100644 packages/ens-utils/src/registration.ts diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index a981ca155..f08d10ddf 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -3,17 +3,13 @@ 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"; import { buildAddress } from "./address"; import { ChainId, MAINNET } from "./chain"; import { ContractRef, buildContractRef } from "./contract"; -import { - PrimaryRegistrationStatus, - Registration, - SecondaryRegistrationStatus, -} from "./registration"; import { Duration, SECONDS_PER_DAY, @@ -31,7 +27,6 @@ import { multiplyPriceByNumber, subtractPrices, } from "./price"; -import { DomainName } from "./domain"; import { Currency } from "./currency"; export interface Registrar { @@ -191,6 +186,8 @@ export const TEMPORARY_PREMIUM_PERIOD: Readonly = buildDuration( 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: @@ -212,9 +209,15 @@ export interface PriceDescription { descriptiveTextEnd: string; } +/** + * 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, - parsedName: DomainName, + ensName: ENSName, ): PriceDescription | null => { const isExpired = registration.primaryStatus === PrimaryRegistrationStatus.Expired; @@ -225,7 +228,7 @@ export const getPriceDescription = ( registration.primaryStatus === PrimaryRegistrationStatus.Active; if (!(isExpired && wasRecentlyReleased) && isRegistered) return null; - const domainBasePrice = AvailableNameTimelessPriceUSD(parsedName); + const domainBasePrice = AvailableNameTimelessPriceUSD(ensName); if (!domainBasePrice) return null; else { @@ -241,18 +244,21 @@ export const getPriceDescription = ( const premiumEndMessage = premiumEndsIn ? ` Temporary premium ends ${premiumEndsIn}.` : null; - const basePriceMessage = domainBasePrice - ? " Discounts continuously until dropping to " - : null; return { pricePerYearDescription, descriptiveTextBeginning: - "Recently released." + premiumEndMessage + basePriceMessage, + "Recently released." + + premiumEndMessage + + " Discounts continuously until dropping to ", descriptiveTextEnd: ".", }; } else { - const domainLabelLength = parsedName.labelName.length; + const ensNameLabel = getDomainLabelFromENSName(ensName); + + if (ensNameLabel === null) return null; + + const domainLabelLength = charCount(ensNameLabel); return domainLabelLength < DOMAIN_HAS_SPECIAL_PRICE_IF_LENGTH_EQUAL_OR_LESS_THAN @@ -266,18 +272,7 @@ export const getPriceDescription = ( } }; -export const nameCurrentTemporaryPremium = ( - registration: Registration, -): Price | null => { - if (registration.expirationTimestamp) { - return temporaryPremiumPriceAtTimestamp( - now(), - registration.expirationTimestamp, - ); - } else { - return null; - } -}; +// PREMIUM PERIOD TEXT REPORT ⬇️ /* Interface for premium period end details */ export interface PremiumPeriodEndsIn { @@ -288,7 +283,8 @@ export interface PremiumPeriodEndsIn { /** * 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 + * @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, @@ -316,12 +312,7 @@ export const premiumPeriodEndsIn = ( releasedEpoch, TEMPORARY_PREMIUM_PERIOD, ); - console.log({ - relativeTimestamp: formatTimestampAsDistanceToNow( - temporaryPremiumEndTimestamp, - ), - timestamp: temporaryPremiumEndTimestamp, - }); + return { relativeTimestamp: formatTimestampAsDistanceToNow( temporaryPremiumEndTimestamp, @@ -330,6 +321,8 @@ export const premiumPeriodEndsIn = ( }; }; +// REGISTRATION PRICE ⬇️ + /** * 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`. @@ -361,6 +354,11 @@ export const PREMIUM_OFFSET = approxScalePrice( PREMIUM_DECAY ** Number(TEMPORARY_PREMIUM_DAYS), ); +/** + * @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`. + */ export function temporaryPremiumPriceAtTimestamp( atTimestamp: Timestamp, expirationTimestamp: Timestamp, @@ -394,6 +392,34 @@ export function temporaryPremiumPriceAtTimestamp( return offsetDecayedPrice; } +export const registrationCurrentTemporaryPremium = ( + registration: Registration, +): Price | null => { + if (registration.expirationTimestamp) { + return temporaryPremiumPriceAtTimestamp( + now(), + registration.expirationTimestamp, + ); + } else { + 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. @@ -401,49 +427,35 @@ export function temporaryPremiumPriceAtTimestamp( safe to be used across the platform, and are then, the ones being exported. */ const AvailableNamePriceUSD = ( - parsedName: DomainName, + ensName: ENSName, registerForYears = DEFAULT_REGISTRATION_YEARS, registration: Registration | null = null, additionalFee: Price | null = null, ): Price | null => { - if (!parsedName.normalizedName) return null; + const ensNameLabel = getDomainLabelFromENSName(ensName); - const defaultPrice: Readonly = { - value: 500n, - currency: Currency.Usd, - }; - const shortNamePremium: Record> = { - [MIN_ETH_REGISTRABLE_LABEL_LENGTH]: { - value: 64000n, - currency: Currency.Usd, - }, - 4: { - value: 16000n, - currency: Currency.Usd, - }, - }; - const basePrice = shortNamePremium[parsedName.labelName.length] - ? shortNamePremium[parsedName.labelName.length] - : defaultPrice; + 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 namehashPrice = additionalFee + const resultPrice = additionalFee ? addPrices([additionalFee, namePriceForYears]) : namePriceForYears; if (registration) { - const premiumPrice = nameCurrentTemporaryPremium(registration); + const premiumPrice = registrationCurrentTemporaryPremium(registration); - return premiumPrice - ? addPrices([premiumPrice, namehashPrice]) - : namehashPrice; + return premiumPrice ? addPrices([premiumPrice, resultPrice]) : resultPrice; } - return namehashPrice; + return resultPrice; }; const DEFAULT_REGISTRATION_YEARS = 1; @@ -455,8 +467,125 @@ const DEFAULT_REGISTRATION_YEARS = 1; a name will cost at the end of a premium period, etc.. */ export const AvailableNameTimelessPriceUSD = ( - domainName: DomainName, + ensName: ENSName, registerForYears = DEFAULT_REGISTRATION_YEARS, ) => { - return AvailableNamePriceUSD(domainName, registerForYears); + 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. + */ + expiryTimestamp: Timestamp | null, +): Registration => { + if (!expiryTimestamp) { + return { + primaryStatus: PrimaryRegistrationStatus.NeverRegistered, + secondaryStatus: null, + registrationTimestamp: null, + expirationTimestamp: null, + expiryTimestamp: null, + }; + } + + const primaryStatus = getPrimaryRegistrationStatus(expiryTimestamp); + const secondaryStatus = getSecondaryRegistrationStatus(expiryTimestamp); + return { + expiryTimestamp, + primaryStatus, + secondaryStatus, + registrationTimestamp: null, + expirationTimestamp: expiryTimestamp, + }; +}; + +const getPrimaryRegistrationStatus = ( + expiryTimestamp: Timestamp, +): PrimaryRegistrationStatus => { + const nowTime = now(); + return nowTime.time < expiryTimestamp.time + ? PrimaryRegistrationStatus.Active + : PrimaryRegistrationStatus.Expired; +}; + +const getSecondaryRegistrationStatus = ( + expiryTimestamp: Timestamp, +): SecondaryRegistrationStatus | null => { + const nowTime = now(); + + if (nowTime.time < expiryTimestamp.time) { + return nowTime.time > expiryTimestamp.time - GRACE_PERIOD.seconds + ? SecondaryRegistrationStatus.ExpiringSoon + : null; + } else { + if ( + expiryTimestamp.time + + GRACE_PERIOD.seconds + + TEMPORARY_PREMIUM_PERIOD.seconds < + nowTime.time + ) + return SecondaryRegistrationStatus.FullyReleased; + else if (expiryTimestamp.time + GRACE_PERIOD.seconds > nowTime.time) + return SecondaryRegistrationStatus.GracePeriod; + else return SecondaryRegistrationStatus.RecentlyReleased; + } +}; + +// 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 ⬇️ + +/** + * 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; + + const releaseTimestamp = addSeconds(expirationTimestamp, GRACE_PERIOD); + return releaseTimestamp; +} diff --git a/packages/ens-utils/src/index.ts b/packages/ens-utils/src/index.ts index 670bfbd86..4c3d00499 100644 --- a/packages/ens-utils/src/index.ts +++ b/packages/ens-utils/src/index.ts @@ -10,5 +10,5 @@ export * from "./nft"; export * from "./number"; export * from "./time"; export * from "./domain"; -export * from "./registration"; +export * from "./transaction"; export * from "./price"; diff --git a/packages/ens-utils/src/price.ts b/packages/ens-utils/src/price.ts index 6f7d8ddb1..16683fae3 100644 --- a/packages/ens-utils/src/price.ts +++ b/packages/ens-utils/src/price.ts @@ -1,5 +1,9 @@ -import { Currency, PriceCurrencyFormat } from "./currency"; -import { approxScaleBigInt } from "./number"; +import { + Currency, + parseStringToCurrency, + PriceCurrencyFormat, +} from "./currency"; +import { approxScaleBigInt, stringToBigInt } from "./number"; export interface Price { // TODO: consider adding a constraint where value is never negative @@ -195,3 +199,25 @@ export const convertCurrencyWithRates = ( return exchangedValuePrice; }; + +export const buildPrice = ( + value: bigint | string, + currency: Currency | string, +): Price => { + let priceValue: bigint; + let priceCurrency: Currency; + + if (typeof value === "string") { + priceValue = stringToBigInt(value); + } else { + priceValue = value; + } + + if (typeof currency === "string") { + priceCurrency = parseStringToCurrency(currency); + } else { + priceCurrency = currency; + } + + return { value: priceValue, currency: priceCurrency }; +}; diff --git a/packages/ens-utils/src/registration.ts b/packages/ens-utils/src/registration.ts deleted file mode 100644 index 165309700..000000000 --- a/packages/ens-utils/src/registration.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { GRACE_PERIOD, TEMPORARY_PREMIUM_PERIOD } from "./ethregistrar"; -import { now, Timestamp } from "./time"; - -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. - */ - expiryTimestamp: Timestamp | null, -): Registration => { - if (!expiryTimestamp) { - return { - primaryStatus: PrimaryRegistrationStatus.NeverRegistered, - secondaryStatus: null, - registrationTimestamp: null, - expirationTimestamp: null, - expiryTimestamp: null, - }; - } - - const primaryStatus = getPrimaryRegistrationStatus(expiryTimestamp); - const secondaryStatus = getSecondaryRegistrationStatus(expiryTimestamp); - return { - expiryTimestamp, - primaryStatus, - secondaryStatus, - registrationTimestamp: null, - expirationTimestamp: expiryTimestamp, - }; -}; - -/* REGISTRATION STATUS ⬇️ */ - -const getPrimaryRegistrationStatus = ( - expiryTimestamp: Timestamp, -): PrimaryRegistrationStatus => { - const nowTime = now(); - return nowTime.time < expiryTimestamp.time - ? PrimaryRegistrationStatus.Active - : PrimaryRegistrationStatus.Expired; -}; - -const getSecondaryRegistrationStatus = ( - expiryTimestamp: Timestamp, -): SecondaryRegistrationStatus | null => { - const nowTime = now(); - - if (nowTime.time < expiryTimestamp.time) { - return nowTime.time > expiryTimestamp.time - GRACE_PERIOD.seconds - ? SecondaryRegistrationStatus.ExpiringSoon - : null; - } else { - if ( - expiryTimestamp.time + - GRACE_PERIOD.seconds + - TEMPORARY_PREMIUM_PERIOD.seconds < - nowTime.time - ) - return SecondaryRegistrationStatus.FullyReleased; - else if (expiryTimestamp.time + GRACE_PERIOD.seconds > nowTime.time) - return SecondaryRegistrationStatus.GracePeriod; - else return SecondaryRegistrationStatus.RecentlyReleased; - } -}; From 013a54f734de95108898b6ee08a28a9baf1d0584 Mon Sep 17 00:00:00 2001 From: FrancoAguzzi Date: Thu, 1 Aug 2024 14:24:44 -0300 Subject: [PATCH 7/9] feat: [SC-25546] Add unit tests scenarios description for ethregistrar.ts --- packages/ens-utils/src/ensname.ts | 27 ++- packages/ens-utils/src/ethregistrar.test.ts | 187 +++++++++++++++----- 2 files changed, 162 insertions(+), 52 deletions(-) diff --git a/packages/ens-utils/src/ensname.ts b/packages/ens-utils/src/ensname.ts index bf4b957c5..27e4ad14a 100644 --- a/packages/ens-utils/src/ensname.ts +++ b/packages/ens-utils/src/ensname.ts @@ -21,13 +21,13 @@ export const MIN_ETH_REGISTRABLE_LABEL_LENGTH = 3; */ export const Normalization = { /** `normalized`: The name or label is normalized. */ - Normalized: 'normalized', + Normalized: "normalized", /** `unnormalized`: The name or label is not normalized. */ - Unnormalized: 'unnormalized', + Unnormalized: "unnormalized", /** `unknown`: The name or label is unknown because it cannot be looked up from its hash. */ - Unknown: 'unknown', + Unknown: "unknown", } as const; export type Normalization = (typeof Normalization)[keyof typeof Normalization]; @@ -83,6 +83,21 @@ 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 @@ -266,7 +281,7 @@ export function getNamespaceRoot(name: ENSName): NamespaceRoot { * `unknown` if the decentralization status of the name is unknown. */ export function getDecentralizationStatus( - name: ENSName + name: ENSName, ): DecentralizationStatus { switch (getNamespaceRoot(name)) { case "ens": @@ -328,11 +343,11 @@ export function getRegistrationPotential(name: ENSName): RegistrationPotential { /** * Calculates the number of characters in a label. - * + * * NOTE: This length will be the same as determined by the EthRegistrarController smart contracts. * These contracts calculate length using the following code that counts Unicode characters in UTF-8 encoding. * https://github.com/ensdomains/ens-contracts/blob/staging/contracts/ethregistrar/StringUtils.sol - * + * * This length may be different than the traditional ".length" property of a string in JavaScript. * In Javascript, the ".length" property of a string returns the number of UTF-16 code units in that string. * UTF-16 represents Unicode characters with codepoints higher can fit within a 16 bit value as a "surrogate pair" diff --git a/packages/ens-utils/src/ethregistrar.test.ts b/packages/ens-utils/src/ethregistrar.test.ts index cf5ce4640..107d96db7 100644 --- a/packages/ens-utils/src/ethregistrar.test.ts +++ b/packages/ens-utils/src/ethregistrar.test.ts @@ -1,57 +1,152 @@ import { describe, it, expect } from "vitest"; -import { Registrar, UNWRAPPED_MAINNET_ETH_REGISTRAR, WRAPPED_MAINNET_ETH_REGISTRAR, buildNFTRefFromENSName } from "./ethregistrar"; +import { + Registrar, + UNWRAPPED_MAINNET_ETH_REGISTRAR, + WRAPPED_MAINNET_ETH_REGISTRAR, + buildNFTRefFromENSName, +} 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); +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("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); - }); -}); \ No newline at end of file + 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. + + PriceDescription is an object with the following properties: + - descriptiveTextBeginning references the text that is displayed at the beginning of the price description. + - descriptiveTextEnd references the text that is displayed at the end of the price description. + - 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 + constant, defining by condition the descriptiveTextBeginning, pricePerYear and descriptiveTextEnd. + + For every PriceDescription response, the domain price is get from AvailableNameTimelessPriceUSD. + + Whenever the domain was recently released (SecondaryRegistrationStatus.RecentlyReleased), + (is in TEMPORARY_PREMIUM_PERIOD), the temporary premium end date is informed. + */ + + it("should return the price description for a domain that was recently release", () => { + /* + TODO implement test scenario once a getMockedDomainCard function is available + */ + }); + it("should return the price description for a domain that was never registered", () => { + /* + TODO implement test scenario once a getMockedDomainCard function is available + */ + }); + it("should return the price description for a domain that has a valid label", () => { + /* + TODO implement test scenario once a getMockedDomainCard function is available + */ + }); + it("should not return the price description for a domain that has an invalid label", () => { + /* + TODO implement test scenario once a getMockedDomainCard function is available + */ + }); + it("should not return the price description for a domain that is already registered", () => { + /* + TODO implement test scenario once a getMockedDomainCard function is available + */ + }); + it("should not return the price description for a domain is expired and was not recently released", () => { + /* + TODO implement test scenario once a getMockedDomainCard function is available + */ + }); + it("should not return the price description for a domain is expired and was not recently released", () => { + /* + TODO implement test scenario once a getMockedDomainCard function is available + */ + }); + + describe("AvailableNameTimelessPriceUSD", () => { + /* + AvailableNameTimelessPriceUSD is a function that returns the "timeless" price for a name, + that takes no consideration of the current status of the name (e.g. temporary premium price). + */ + it("should not return the price for a domain that has an invalid label", () => {}); + it("should return a $5 price for a domain that has 5 or more label chars", () => {}); + it("should return a $160 price for a domain that has 4 label chars", () => {}); + it("should return a $640 price for a domain that has 3 label chars", () => {}); + it("should return a $5 + additionalFee price for a domain that has 5 or more label chars + an informed additionalFee", () => {}); + }); + + describe("temporaryPremiumPriceAtTimestamp", () => { + /* + AvailableNameTimelessPriceUSD is a function that returns the "timeless" price for a name, + that takes no consideration of the current status of the name (e.g. temporary premium price). + */ + it("should return $0 price for a domain that `atTimestamp` there is no temporaryPremium", () => {}); + it("should return a temporaryPremium for a domain that `atTimestamp` there is temporaryPremium", () => {}); + }); +}); From abfcb403ba0d69b8ac8c52caa159a0b425c95dce Mon Sep 17 00:00:00 2001 From: FrancoAguzzi Date: Sat, 3 Aug 2024 01:45:20 -0300 Subject: [PATCH 8/9] fix: [SC-25546] Optimize comments in ethregistrar.ts --- packages/ens-utils/src/ethregistrar.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index f08d10ddf..0cb96660b 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -272,7 +272,7 @@ export const getPriceDescription = ( } }; -// PREMIUM PERIOD TEXT REPORT ⬇️ +// PREMIUM PERIOD TEXT DESCRIPTION ⬇️ /* Interface for premium period end details */ export interface PremiumPeriodEndsIn { @@ -473,7 +473,7 @@ export const AvailableNameTimelessPriceUSD = ( return AvailableNamePriceUSD(ensName, registerForYears); }; -// REGISTRATION STATUSES ⬇️ +// REGISTRATION STATUS ⬇️ export enum PrimaryRegistrationStatus { Active = "Active", From 1263f4851a282902101a67adc4e19b42d9eff0f2 Mon Sep 17 00:00:00 2001 From: FrancoAguzzi Date: Thu, 8 Aug 2024 19:58:06 -0300 Subject: [PATCH 9/9] feat: [SC-25399] Remove expiryTimestamp field from Registration --- packages/ens-utils/src/ethregistrar.ts | 42 +++++++++--------- pnpm-lock.yaml | 60 +++++++++++++++++--------- 2 files changed, 61 insertions(+), 41 deletions(-) diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index 0cb96660b..6a63b66c8 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -1,9 +1,9 @@ import { ENSName, - MIN_ETH_REGISTRABLE_LABEL_LENGTH, ETH_TLD, charCount, getDomainLabelFromENSName, + MIN_ETH_REGISTRABLE_LABEL_LENGTH, } from "./ensname"; import { NFTRef, TokenId, buildNFTRef, buildTokenId } from "./nft"; import { namehash, labelhash } from "viem/ens"; @@ -301,13 +301,16 @@ export const premiumPeriodEndsIn = ( if (!isExpired || !wasRecentlyReleased) return null; /* - This conditional should always be true because expiryTimestamp will only be null when + This conditional should always be true because expirationTimestamp 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; + if (!registration.expirationTimestamp) return null; - const releasedEpoch = addSeconds(registration.expiryTimestamp, GRACE_PERIOD); + const releasedEpoch = addSeconds( + registration.expirationTimestamp, + GRACE_PERIOD, + ); const temporaryPremiumEndTimestamp = addSeconds( releasedEpoch, TEMPORARY_PREMIUM_PERIOD, @@ -492,7 +495,6 @@ export type Registration = { // Below timestamps are counted in seconds registrationTimestamp: Timestamp | null; expirationTimestamp: Timestamp | null; - expiryTimestamp: Timestamp | null; primaryStatus: PrimaryRegistrationStatus; secondaryStatus: SecondaryRegistrationStatus | null; @@ -502,56 +504,54 @@ export const getDomainRegistration = ( /* When null, a domain is considered to be not registered. */ - expiryTimestamp: Timestamp | null, + expirationTimestamp: Timestamp | null, ): Registration => { - if (!expiryTimestamp) { + if (!expirationTimestamp) { return { - primaryStatus: PrimaryRegistrationStatus.NeverRegistered, secondaryStatus: null, - registrationTimestamp: null, expirationTimestamp: null, - expiryTimestamp: null, + registrationTimestamp: null, + primaryStatus: PrimaryRegistrationStatus.NeverRegistered, }; } - const primaryStatus = getPrimaryRegistrationStatus(expiryTimestamp); - const secondaryStatus = getSecondaryRegistrationStatus(expiryTimestamp); + const primaryStatus = getPrimaryRegistrationStatus(expirationTimestamp); + const secondaryStatus = getSecondaryRegistrationStatus(expirationTimestamp); return { - expiryTimestamp, primaryStatus, secondaryStatus, + expirationTimestamp, registrationTimestamp: null, - expirationTimestamp: expiryTimestamp, }; }; const getPrimaryRegistrationStatus = ( - expiryTimestamp: Timestamp, + expirationTimestamp: Timestamp, ): PrimaryRegistrationStatus => { const nowTime = now(); - return nowTime.time < expiryTimestamp.time + return nowTime.time < expirationTimestamp.time ? PrimaryRegistrationStatus.Active : PrimaryRegistrationStatus.Expired; }; const getSecondaryRegistrationStatus = ( - expiryTimestamp: Timestamp, + expirationTimestamp: Timestamp, ): SecondaryRegistrationStatus | null => { const nowTime = now(); - if (nowTime.time < expiryTimestamp.time) { - return nowTime.time > expiryTimestamp.time - GRACE_PERIOD.seconds + if (nowTime.time < expirationTimestamp.time) { + return nowTime.time > expirationTimestamp.time - GRACE_PERIOD.seconds ? SecondaryRegistrationStatus.ExpiringSoon : null; } else { if ( - expiryTimestamp.time + + expirationTimestamp.time + GRACE_PERIOD.seconds + TEMPORARY_PREMIUM_PERIOD.seconds < nowTime.time ) return SecondaryRegistrationStatus.FullyReleased; - else if (expiryTimestamp.time + GRACE_PERIOD.seconds > nowTime.time) + else if (expirationTimestamp.time + GRACE_PERIOD.seconds > nowTime.time) return SecondaryRegistrationStatus.GracePeriod; else return SecondaryRegistrationStatus.RecentlyReleased; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63558318d..9e6e56cd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,7 +142,7 @@ importers: devDependencies: '@types/node': specifier: latest - version: 22.0.0 + version: 22.1.0 '@types/react': specifier: latest version: 18.3.3 @@ -151,7 +151,7 @@ importers: version: 18.3.0 autoprefixer: specifier: latest - version: 10.4.19(postcss@8.4.40) + version: 10.4.19(postcss@8.4.41) eslint: specifier: ^8.56.0 version: 8.56.0 @@ -160,7 +160,7 @@ importers: version: 14.2.3(eslint@8.56.0)(typescript@5.5.4) postcss: specifier: latest - version: 8.4.40 + version: 8.4.41 tailwind-scrollbar-hide: specifier: 1.1.7 version: 1.1.7 @@ -5231,6 +5231,12 @@ packages: undici-types: 6.11.1 dev: true + /@types/node@22.1.0: + resolution: {integrity: sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw==} + dependencies: + undici-types: 6.13.0 + dev: true + /@types/normalize-package-data@2.4.4: resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} dev: true @@ -6004,7 +6010,7 @@ packages: postcss-value-parser: 4.2.0 dev: true - /autoprefixer@10.4.19(postcss@8.4.40): + /autoprefixer@10.4.19(postcss@8.4.41): resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==} engines: {node: ^10 || ^12 || >=14} hasBin: true @@ -6016,7 +6022,7 @@ packages: fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.0.1 - postcss: 8.4.40 + postcss: 8.4.41 postcss-value-parser: 4.2.0 dev: true @@ -8259,6 +8265,7 @@ packages: /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -10080,13 +10087,13 @@ packages: resolve: 1.22.8 dev: true - /postcss-import@15.1.0(postcss@8.4.40): + /postcss-import@15.1.0(postcss@8.4.41): resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} peerDependencies: postcss: ^8.0.0 dependencies: - postcss: 8.4.40 + postcss: 8.4.41 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.8 @@ -10101,14 +10108,14 @@ packages: postcss: 8.4.39 dev: true - /postcss-js@4.0.1(postcss@8.4.40): + /postcss-js@4.0.1(postcss@8.4.41): resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} engines: {node: ^12 || ^14 || >= 16} peerDependencies: postcss: ^8.4.21 dependencies: camelcase-css: 2.0.1 - postcss: 8.4.40 + postcss: 8.4.41 /postcss-load-config@4.0.2(postcss@8.4.38): resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} @@ -10144,7 +10151,7 @@ packages: yaml: 2.4.2 dev: true - /postcss-load-config@4.0.2(postcss@8.4.40): + /postcss-load-config@4.0.2(postcss@8.4.41): resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} engines: {node: '>= 14'} peerDependencies: @@ -10157,7 +10164,7 @@ packages: optional: true dependencies: lilconfig: 3.1.1 - postcss: 8.4.40 + postcss: 8.4.41 yaml: 2.4.2 /postcss-nested@6.0.1(postcss@8.4.39): @@ -10170,13 +10177,13 @@ packages: postcss-selector-parser: 6.0.16 dev: true - /postcss-nested@6.0.1(postcss@8.4.40): + /postcss-nested@6.0.1(postcss@8.4.41): resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} engines: {node: '>=12.0'} peerDependencies: postcss: ^8.2.14 dependencies: - postcss: 8.4.40 + postcss: 8.4.41 postcss-selector-parser: 6.0.16 /postcss-selector-parser@6.0.16: @@ -10222,6 +10229,15 @@ packages: nanoid: 3.3.7 picocolors: 1.0.1 source-map-js: 1.2.0 + dev: true + + /postcss@8.4.41: + resolution: {integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.1 + source-map-js: 1.2.0 /preferred-pm@3.1.3: resolution: {integrity: sha512-MkXsENfftWSRpzCzImcp4FRsCc3y1opwB73CfCNWyzMqArju2CrlMHlqB7VexKiPEOjGMbttv1r9fSCn5S610w==} @@ -11471,11 +11487,11 @@ packages: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.0.1 - postcss: 8.4.40 - postcss-import: 15.1.0(postcss@8.4.40) - postcss-js: 4.0.1(postcss@8.4.40) - postcss-load-config: 4.0.2(postcss@8.4.40) - postcss-nested: 6.0.1(postcss@8.4.40) + postcss: 8.4.41 + postcss-import: 15.1.0(postcss@8.4.41) + postcss-js: 4.0.1(postcss@8.4.41) + postcss-load-config: 4.0.2(postcss@8.4.41) + postcss-nested: 6.0.1(postcss@8.4.41) postcss-selector-parser: 6.0.16 resolve: 1.22.8 sucrase: 3.35.0 @@ -12062,6 +12078,10 @@ packages: resolution: {integrity: sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ==} dev: true + /undici-types@6.13.0: + resolution: {integrity: sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==} + dev: true + /unicode-canonical-property-names-ecmascript@2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} engines: {node: '>=4'} @@ -12399,7 +12419,7 @@ packages: dependencies: '@types/node': 20.12.12 esbuild: 0.19.12 - postcss: 8.4.40 + postcss: 8.4.41 rollup: 4.17.2 optionalDependencies: fsevents: 2.3.3 @@ -12435,7 +12455,7 @@ packages: dependencies: '@types/node': 20.12.7 esbuild: 0.19.12 - postcss: 8.4.40 + postcss: 8.4.41 rollup: 4.17.2 optionalDependencies: fsevents: 2.3.3