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/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/packages/ens-utils/src/domain.ts b/packages/ens-utils/src/domain.ts new file mode 100644 index 000000000..8aee68335 --- /dev/null +++ b/packages/ens-utils/src/domain.ts @@ -0,0 +1,151 @@ +import { NFTRef } from "./nft"; +import { ENSName } from "./ensname"; +import { Address, isAddressEqual } from "./address"; +import { keccak256, labelhash as labelHash } from "viem"; +import { Registration } from "./ethregistrar"; + +export interface DomainCard { + 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; + registration: Registration; + ownerAddress: Address | null; + managerAddress: Address | null; + /** Former owner address is only set when the domain is in Grace Period */ + formerOwnerAddress: Address | null; + /** Former manager address is only set when the domain is in Grace Period */ + formerManagerAddress: Address | null; +} + +/* Defines the ownership of a domain for a given address */ +export const UserOwnershipOfDomain = { + /* NoOwner: If domain has no owner */ + NoOwner: "NoOwner", + + /* NotOwner: If domain has an owner but user is not the owner */ + NotOwner: "NotOwner", + + /* 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. 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 = ( + domain: DomainCard | null, + 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 (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 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 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", () => {}); + }); +}); diff --git a/packages/ens-utils/src/ethregistrar.ts b/packages/ens-utils/src/ethregistrar.ts index c63a5d82e..6a63b66c8 100644 --- a/packages/ens-utils/src/ethregistrar.ts +++ b/packages/ens-utils/src/ethregistrar.ts @@ -2,6 +2,7 @@ import { ENSName, ETH_TLD, charCount, + getDomainLabelFromENSName, MIN_ETH_REGISTRABLE_LABEL_LENGTH, } from "./ensname"; import { NFTRef, TokenId, buildNFTRef, buildTokenId } from "./nft"; @@ -9,6 +10,24 @@ import { namehash, labelhash } from "viem/ens"; import { buildAddress } from "./address"; import { ChainId, MAINNET } from "./chain"; import { ContractRef, buildContractRef } from "./contract"; +import { + Duration, + SECONDS_PER_DAY, + Timestamp, + addSeconds, + buildDuration, + formatTimestampAsDistanceToNow, + now, +} from "./time"; +import { + Price, + addPrices, + approxScalePrice, + formattedPrice, + multiplyPriceByNumber, + subtractPrices, +} from "./price"; +import { Currency } from "./currency"; export interface Registrar { contract: ContractRef; @@ -155,3 +174,418 @@ 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; + +// 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; +} + +/** + * 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, + withPrefix: true, + }); + const pricePerYearDescription = `${domainPrice} / year`; + + const premiumEndsIn = premiumPeriodEndsIn(registration)?.relativeTimestamp; + + if (premiumEndsIn) { + const premiumEndMessage = premiumEndsIn + ? ` Temporary premium ends ${premiumEndsIn}.` + : null; + + return { + pricePerYearDescription, + descriptiveTextBeginning: + "Recently released." + + premiumEndMessage + + " Discounts continuously until dropping to ", + descriptiveTextEnd: ".", + }; + } else { + 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 + ? { + pricePerYearDescription, + descriptiveTextBeginning: `${domainLabelLength}-character names are `, + descriptiveTextEnd: " to register.", + } + : null; + } + } +}; + +// PREMIUM PERIOD TEXT DESCRIPTION ⬇️ + +/* 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. Null if the domain is not in its premium period + * (to be, it should be expired and recently released). + */ +export const premiumPeriodEndsIn = ( + registration: Registration, +): PremiumPeriodEndsIn | null => { + const isExpired = + registration.primaryStatus === PrimaryRegistrationStatus.Expired; + const wasRecentlyReleased = + registration.secondaryStatus === + SecondaryRegistrationStatus.RecentlyReleased; + + /* + 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 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.expirationTimestamp) return null; + + const releasedEpoch = addSeconds( + registration.expirationTimestamp, + GRACE_PERIOD, + ); + const temporaryPremiumEndTimestamp = addSeconds( + releasedEpoch, + TEMPORARY_PREMIUM_PERIOD, + ); + + return { + relativeTimestamp: formatTimestampAsDistanceToNow( + temporaryPremiumEndTimestamp, + ), + timestamp: temporaryPremiumEndTimestamp, + }; +}; + +// 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`. + */ +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), +); + +/** + * @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, +): 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; +} + +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. + 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); +}; + +// REGISTRATION STATUS ⬇️ + +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; + + primaryStatus: PrimaryRegistrationStatus; + secondaryStatus: SecondaryRegistrationStatus | null; +}; + +export const getDomainRegistration = ( + /* + When null, a domain is considered to be not registered. + */ + expirationTimestamp: Timestamp | null, +): Registration => { + if (!expirationTimestamp) { + return { + secondaryStatus: null, + expirationTimestamp: null, + registrationTimestamp: null, + primaryStatus: PrimaryRegistrationStatus.NeverRegistered, + }; + } + + const primaryStatus = getPrimaryRegistrationStatus(expirationTimestamp); + const secondaryStatus = getSecondaryRegistrationStatus(expirationTimestamp); + return { + primaryStatus, + secondaryStatus, + expirationTimestamp, + registrationTimestamp: null, + }; +}; + +const getPrimaryRegistrationStatus = ( + expirationTimestamp: Timestamp, +): PrimaryRegistrationStatus => { + const nowTime = now(); + return nowTime.time < expirationTimestamp.time + ? PrimaryRegistrationStatus.Active + : PrimaryRegistrationStatus.Expired; +}; + +const getSecondaryRegistrationStatus = ( + expirationTimestamp: Timestamp, +): SecondaryRegistrationStatus | null => { + const nowTime = now(); + + if (nowTime.time < expirationTimestamp.time) { + return nowTime.time > expirationTimestamp.time - GRACE_PERIOD.seconds + ? SecondaryRegistrationStatus.ExpiringSoon + : null; + } else { + if ( + expirationTimestamp.time + + GRACE_PERIOD.seconds + + TEMPORARY_PREMIUM_PERIOD.seconds < + nowTime.time + ) + return SecondaryRegistrationStatus.FullyReleased; + else if (expirationTimestamp.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 87a8895d0..4c3d00499 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 "./transaction"; +export * from "./price"; diff --git a/packages/ens-utils/src/price.ts b/packages/ens-utils/src/price.ts index 75ac307d2..16683fae3 100644 --- a/packages/ens-utils/src/price.ts +++ b/packages/ens-utils/src/price.ts @@ -1,8 +1,11 @@ -import { Currency, PriceCurrencyFormat, parseStringToCurrency } from "./currency"; +import { + Currency, + parseStringToCurrency, + PriceCurrencyFormat, +} from "./currency"; import { approxScaleBigInt, stringToBigInt } 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 +18,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 +30,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 +71,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 +119,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 +127,7 @@ export const formattedPrice = ({ formattedAmount = prefix.padEnd( Number(PriceCurrencyFormat[price.currency].DisplayDecimals) + prefix.length, - "0" + "0", ); } @@ -168,10 +137,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 +166,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 +174,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`, ); } @@ -225,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/pnpm-lock.yaml b/pnpm-lock.yaml index 818a75145..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 @@ -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: {} @@ -5228,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 @@ -6001,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 @@ -6013,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 @@ -8256,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 @@ -10077,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 @@ -10098,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==} @@ -10141,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: @@ -10154,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): @@ -10167,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: @@ -10219,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==} @@ -11468,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 @@ -12059,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'} @@ -12324,6 +12347,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} @@ -12375,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 @@ -12411,6 +12455,42 @@ packages: dependencies: '@types/node': 20.12.7 esbuild: 0.19.12 + postcss: 8.4.41 + rollup: 4.17.2 + optionalDependencies: + 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: @@ -12473,6 +12553,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}