This repository was archived by the owner on May 13, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 2
feat: include domain logics into ens-utils
#341
Open
FrancoAguzzi
wants to merge
10
commits into
main
Choose a base branch
from
francoaguzzi/sc-25546/include-domain-logics-into-ens-utils
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 3 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
90a13c5
feat: [SC-25546] Add domain related logics to ens-utils (Registration…
FrancoAguzzi 302ec4e
feat: [SC-25546] Include @types/node into ens-utils
FrancoAguzzi c2fb824
feat: [SC-25546] Include changeset in this PR since it updates ens-ut…
FrancoAguzzi 55b5446
feat: [SC-25546] Delete unneeded changeset
FrancoAguzzi cd5e39a
feat: [SC-25546] Update UserOwnershipOfDomain enum definition
FrancoAguzzi 71fa009
feat: [SC-25546] Move functions and delete registration.ts
FrancoAguzzi 013a54f
feat: [SC-25546] Add unit tests scenarios description for ethregistra…
FrancoAguzzi abfcb40
fix: [SC-25546] Optimize comments in ethregistrar.ts
FrancoAguzzi f118a91
Merge remote-tracking branch 'origin/main' into francoaguzzi/sc-25546…
FrancoAguzzi 1263f48
feat: [SC-25399] Remove expiryTimestamp field from Registration
FrancoAguzzi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@namehash/ens-utils": minor | ||
| --- | ||
|
|
||
| Add domain related logics to ens-utils (Registration, DomainName, DomainCard, etc.) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@namehash/ens-utils": major | ||
| --- | ||
|
|
||
| Add domain related logics to ens-utils (Registration, DomainName, DomainCard, etc.) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = { | ||
|
FrancoAguzzi marked this conversation as resolved.
Outdated
|
||
| 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; | ||
|
FrancoAguzzi marked this conversation as resolved.
Outdated
|
||
| registration: Registration; | ||
| /** Stringified JSON object with debug information about the name generator */ | ||
| nameGeneratorMetadata: string | null; | ||
|
FrancoAguzzi marked this conversation as resolved.
Outdated
|
||
| /** Whether the domain is on watchlist */ | ||
| onWatchlist: boolean; | ||
|
FrancoAguzzi marked this conversation as resolved.
Outdated
|
||
| ownerAddress: `0x${string}` | null; | ||
|
FrancoAguzzi marked this conversation as resolved.
Outdated
|
||
| 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( | ||
|
FrancoAguzzi marked this conversation as resolved.
Outdated
|
||
| 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( | ||
|
FrancoAguzzi marked this conversation as resolved.
Outdated
|
||
| 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 { | ||
|
FrancoAguzzi marked this conversation as resolved.
Outdated
|
||
| 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 = ( | ||
|
FrancoAguzzi marked this conversation as resolved.
|
||
| domain: DomainCard | null, | ||
|
FrancoAguzzi marked this conversation as resolved.
|
||
| currentUserAddress: Address | null, | ||
|
FrancoAguzzi marked this conversation as resolved.
|
||
| ): 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}`), | ||
| }; | ||
| } | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.