{formError}
}Examples:
diff --git a/apps/ensadmin/src/lib/interpret-name-from-user-input.test.ts b/apps/ensadmin/src/lib/interpret-name-from-user-input.test.ts new file mode 100644 index 0000000000..0b8d6788f0 --- /dev/null +++ b/apps/ensadmin/src/lib/interpret-name-from-user-input.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; + +import { + interpretNameFromUserInput, + NameInterpretationOutcomeResult, +} from "./interpret-name-from-user-input"; + +describe("interpretNameFromUserInput", () => { + describe("Empty outcome", () => { + it("returns Empty for empty string", () => { + const result = interpretNameFromUserInput(""); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Empty); + expect(result.interpretation).toBe(""); + }); + + it("returns Empty for whitespace-only string", () => { + const result = interpretNameFromUserInput(" "); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Empty); + expect(result.interpretation).toBe(""); + }); + + it("returns Empty for tab/newline whitespace", () => { + const result = interpretNameFromUserInput("\t\n"); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Empty); + expect(result.interpretation).toBe(""); + }); + }); + + describe("Normalized outcome", () => { + it("returns Normalized for already-normalized name", () => { + const result = interpretNameFromUserInput("vitalik.eth"); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Normalized); + expect(result.interpretation).toBe("vitalik.eth"); + }); + + it("normalizes uppercase to lowercase", () => { + const result = interpretNameFromUserInput("VITALIK.ETH"); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Normalized); + expect(result.interpretation).toBe("vitalik.eth"); + }); + + it("normalizes mixed case", () => { + const result = interpretNameFromUserInput("LiGhTWaLkEr.EtH"); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Normalized); + expect(result.interpretation).toBe("lightwalker.eth"); + }); + + it("preserves inputName in result", () => { + const result = interpretNameFromUserInput("VITALIK.ETH"); + expect(result.inputName).toBe("VITALIK.ETH"); + }); + + it("handles single-label name", () => { + const result = interpretNameFromUserInput("eth"); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Normalized); + expect(result.interpretation).toBe("eth"); + }); + }); + + describe("Reencoded outcome", () => { + it("returns Reencoded for encoded labelhash input", () => { + const result = interpretNameFromUserInput( + "[e4310bf4547cb18b16b5348881d24a66d61fa94a013e5636b730b86ee64a3923].eth", + ); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Reencoded); + expect(result.interpretation).toBe( + "[e4310bf4547cb18b16b5348881d24a66d61fa94a013e5636b730b86ee64a3923].eth", + ); + }); + + it("lowercases encoded labelhash hex", () => { + const result = interpretNameFromUserInput( + "[E4310BF4547CB18B16B5348881D24A66D61FA94A013E5636B730B86EE64A3923].eth", + ); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Reencoded); + expect(result.interpretation).toBe( + "[e4310bf4547cb18b16b5348881d24a66d61fa94a013e5636b730b86ee64a3923].eth", + ); + }); + + it("handles multiple encoded labelhash labels", () => { + const result = interpretNameFromUserInput( + "[e4310bf4547cb18b16b5348881d24a66d61fa94a013e5636b730b86ee64a3923].[4f5b812789fc606be1b3b16908db13fc7a9adf7ca72641f84d75b47069d3d7f0]", + ); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Reencoded); + }); + }); + + describe("Encoded outcome", () => { + it("returns Encoded for invalid characters", () => { + const result = interpretNameFromUserInput("abc|123.eth"); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Encoded); + // The unnormalizable label gets encoded as a labelhash + expect(result.interpretation).toMatch(/^\[.{64}\]\.eth$/); + }); + + it("returns Encoded for empty labels (consecutive dots)", () => { + const result = interpretNameFromUserInput("abc..123"); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Encoded); + if (result.outcome === NameInterpretationOutcomeResult.Encoded) { + expect(result.hadEmptyLabels).toBe(true); + } + }); + + it("returns Encoded for single dot", () => { + const result = interpretNameFromUserInput("."); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Encoded); + if (result.outcome === NameInterpretationOutcomeResult.Encoded) { + expect(result.hadEmptyLabels).toBe(true); + } + }); + + it("returns Encoded for space (non-trimmed, non-empty)", () => { + // " " trims to "" so this is Empty, but "a b" has a space inside a label + const result = interpretNameFromUserInput("a b.eth"); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Encoded); + }); + + it("preserves inputName in Encoded result", () => { + const result = interpretNameFromUserInput("abc|123.eth"); + expect(result.inputName).toBe("abc|123.eth"); + }); + + it("hadEmptyLabels is false when no empty labels", () => { + const result = interpretNameFromUserInput("abc|123.eth"); + if (result.outcome === NameInterpretationOutcomeResult.Encoded) { + expect(result.hadEmptyLabels).toBe(false); + } + }); + }); + + describe("mixed label scenarios", () => { + it("normalizable + encoded labelhash = Reencoded", () => { + const result = interpretNameFromUserInput( + "VITALIK.[e4310bf4547cb18b16b5348881d24a66d61fa94a013e5636b730b86ee64a3923]", + ); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Reencoded); + expect(result.interpretation.startsWith("vitalik.")).toBe(true); + }); + + it("unnormalizable takes priority over reencoded", () => { + const result = interpretNameFromUserInput( + "abc|123.[e4310bf4547cb18b16b5348881d24a66d61fa94a013e5636b730b86ee64a3923]", + ); + expect(result.outcome).toBe(NameInterpretationOutcomeResult.Encoded); + }); + + it("always produces a valid interpretation string", () => { + const inputs = [ + "vitalik.eth", + "VITALIK.ETH", + "abc|123.eth", + "abc..123", + ".", + "[e4310bf4547cb18b16b5348881d24a66d61fa94a013e5636b730b86ee64a3923].eth", + ]; + for (const input of inputs) { + const result = interpretNameFromUserInput(input); + expect(typeof result.interpretation).toBe("string"); + expect(result.interpretation.length).toBeGreaterThan(0); + } + }); + }); +}); diff --git a/apps/ensadmin/src/lib/interpret-name-from-user-input.ts b/apps/ensadmin/src/lib/interpret-name-from-user-input.ts new file mode 100644 index 0000000000..bd4678979d --- /dev/null +++ b/apps/ensadmin/src/lib/interpret-name-from-user-input.ts @@ -0,0 +1,113 @@ +import { normalize } from "viem/ens"; + +import { + encodedLabelToLabelhash, + encodeLabelHash, + type InterpretedName, + type LiteralLabel, + labelhashLiteralLabel, + type Name, + type NormalizedName, +} from "@ensnode/ensnode-sdk"; + +export const NameInterpretationOutcomeResult = { + /** The input was empty (or whitespace-only). */ + Empty: "Empty", + /** All labels were normalized successfully.*/ + Normalized: "Normalized", + /** One or moer labels were already formatted as encoded labelhashes. None were unnormalizable. */ + Reencoded: "Reencoded", + /** One or more labels were unnormalizable and were encoded as labelhashes. */ + Encoded: "Encoded", +} as const; + +export type NameInterpretationOutcomeResult = + (typeof NameInterpretationOutcomeResult)[keyof typeof NameInterpretationOutcomeResult]; + +export interface NameInterpretationOutcomeEmpty { + outcome: typeof NameInterpretationOutcomeResult.Empty; + inputName: Name; + interpretation: InterpretedName; +} + +export interface NameInterpretationOutcomeNormalized { + outcome: typeof NameInterpretationOutcomeResult.Normalized; + inputName: Name; + interpretation: NormalizedName; +} + +export interface NameInterpretationOutcomeReencoded { + outcome: typeof NameInterpretationOutcomeResult.Reencoded; + inputName: Name; + interpretation: InterpretedName; +} + +export interface NameInterpretationOutcomeEncoded { + outcome: typeof NameInterpretationOutcomeResult.Encoded; + inputName: Name; + hadEmptyLabels: boolean; + interpretation: InterpretedName; +} + +export type NameInterpretationOutcome = + | NameInterpretationOutcomeEmpty + | NameInterpretationOutcomeNormalized + | NameInterpretationOutcomeReencoded + | NameInterpretationOutcomeEncoded; + +export function interpretNameFromUserInput(inputName: Name): NameInterpretationOutcome { + if (inputName.trim() === "") { + return { + outcome: NameInterpretationOutcomeResult.Empty, + inputName, + interpretation: "" as InterpretedName, + }; + } + + let hadEmptyLabels = false; + let hadReencodedLabels = false; + let hadUnnormalizableLabels = false; + + const interpretedLabels = inputName.split(".").map((label) => { + if (label === "") { + hadEmptyLabels = true; + hadUnnormalizableLabels = true; + return encodeLabelHash(labelhashLiteralLabel(label as LiteralLabel)); + } + + try { + return normalize(label); + } catch { + if (encodedLabelToLabelhash(label) !== null) { + hadReencodedLabels = true; + return label.toLowerCase(); + } else { + hadUnnormalizableLabels = true; + return encodeLabelHash(labelhashLiteralLabel(label as LiteralLabel)); + } + } + }); + + const interpretation = interpretedLabels.join("."); + + if (hadUnnormalizableLabels) { + return { + outcome: NameInterpretationOutcomeResult.Encoded, + inputName, + hadEmptyLabels, + interpretation: interpretation as InterpretedName, + }; + } else if (hadReencodedLabels) { + return { + outcome: NameInterpretationOutcomeResult.Reencoded, + inputName, + interpretation: interpretation as InterpretedName, + }; + } else { + return { + outcome: NameInterpretationOutcomeResult.Normalized, + inputName, + interpretation: interpretation as NormalizedName, + }; + } +} diff --git a/packages/ensnode-sdk/src/ens/names.ts b/packages/ensnode-sdk/src/ens/names.ts index e4f7a5348e..7f2721e3ce 100644 --- a/packages/ensnode-sdk/src/ens/names.ts +++ b/packages/ensnode-sdk/src/ens/names.ts @@ -1,7 +1,7 @@ import { ens_beautify } from "@adraffy/ens-normalize"; import { isNormalizedLabel } from "./is-normalized"; -import type { Label, Name, NormalizedName } from "./types"; +import type { InterpretedName, Label, Name, NormalizedName } from "./types"; /** * Name for the ENS Root @@ -76,3 +76,27 @@ export const beautifyName = (name: Name): Name => { }); return beautifiedLabels.join("."); }; + +/** + * Formats an InterpretedName for display in UI strings. + * + * - Normalized labels are beautified (via ens_beautify). + * - Non-normalized labels (encoded labelhashes) are lowercased for consistent display. + * + * NOTE: This function only takes an InterpretedName, not a raw Name. + * The return type is Name (not InterpretedName) because beautification + * may produce labels that are not normalized. + * + * @param interpretedName - The InterpretedName to format for display. + * @returns The formatted name. + */ +export const formatInterpretedNameForDisplay = (interpretedName: InterpretedName): Name => { + const displayLabels = interpretedName.split(".").map((label: Label) => { + if (isNormalizedLabel(label)) { + return ens_beautify(label); + } else { + return label.toLowerCase(); + } + }); + return displayLabels.join("."); +}; diff --git a/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.test.ts b/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.test.ts deleted file mode 100644 index 24a4233087..0000000000 --- a/packages/ensnode-sdk/src/shared/interpretation/interpreted-names-and-labels.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - encodeLabelHash, - type InterpretedLabel, - type InterpretedName, - type LiteralLabel, - type Name, -} from "../../ens"; -import { labelhashLiteralLabel } from "../labelhash"; -import { - constructSubInterpretedName, - interpretedLabelsToInterpretedName, - literalLabelsToInterpretedName, - literalLabelToInterpretedLabel, - parsePartialInterpretedName, -} from "./interpreted-names-and-labels"; - -const ENCODED_LABELHASH_LABEL = /^\[[\da-f]{64}\]$/; - -const NORMALIZED_LABELS = [ - "vitalik", - "example", - "test", - "eth", - "base", - "π₯", - "testπ", - "cafΓ©", - "sub", - "a".repeat(512), // Long normalized -] as LiteralLabel[]; - -const UNNORMALIZED_LABELS = [ - "", // Empty string - "Vitalik", // Uppercase - "Example", // Uppercase - "TEST", // Uppercase - "ETH", // Uppercase - "test\0", // Null character - "vitalik\0", // Null character - "\0", // Only null character - "example.\0", // Null character in middle - "test[", // Not normalizable bracket - "test]", // Not normalizable bracket - "test.", // Contains dot - ".eth", // Starts with dot - "sub.example", // Contains dot - "test\u0000", // Unicode null - "test\uFEFF", // Zero-width no-break space - "test\u200B", // Zero-width space - "test\u202E", // RTL override - "A".repeat(300), // Long non-normalized -] as LiteralLabel[]; - -const EXAMPLE_ENCODED_LABEL_HASH = encodeLabelHash( - labelhashLiteralLabel("example" as LiteralLabel), -); - -describe("interpretation", () => { - describe("interpretLiteralLabel", () => { - it("should return normalized labels unchanged", () => { - NORMALIZED_LABELS.forEach((label) => - expect(literalLabelToInterpretedLabel(label)).toBe(label), - ); - }); - - it("should encode non-normalized encodable labels as labelhashes", () => { - UNNORMALIZED_LABELS.forEach((label) => - expect(literalLabelToInterpretedLabel(label)).toMatch(ENCODED_LABELHASH_LABEL), - ); - }); - }); - - describe("interpretLiteralLabelsIntoInterpretedName", () => { - it("correctly interprets labels with period", () => { - expect(literalLabelsToInterpretedName(["a.b", "c"] as LiteralLabel[])).toEqual( - `${encodeLabelHash(labelhashLiteralLabel("a.b" as LiteralLabel))}.c`, - ); - }); - - it("correctly interprets labels with NULL", () => { - expect(literalLabelsToInterpretedName(["\0", "c"] as LiteralLabel[])).toEqual( - `${encodeLabelHash(labelhashLiteralLabel("\0" as LiteralLabel))}.c`, - ); - }); - - it("correctly interprets encoded-labelhash-looking-strings", () => { - const literalLabelThatLooksLikeALabelHash = encodeLabelHash( - labelhashLiteralLabel("test" as LiteralLabel), - ) as LiteralLabel; - - expect( - literalLabelsToInterpretedName([ - literalLabelThatLooksLikeALabelHash, - "c", - ] as LiteralLabel[]), - ).toEqual(`${encodeLabelHash(labelhashLiteralLabel(literalLabelThatLooksLikeALabelHash))}.c`); - }); - - it("correctly interprets an empty array of labels", () => { - expect(literalLabelsToInterpretedName([] as LiteralLabel[])).toEqual(""); - }); - }); - - describe("interpretedLabelsToInterpretedName", () => { - it("correctly interprets an empty array of labels", () => { - expect(interpretedLabelsToInterpretedName([] as InterpretedLabel[])).toEqual(""); - }); - - it("correctly interprets a single label", () => { - expect(interpretedLabelsToInterpretedName(["a"] as InterpretedLabel[])).toEqual("a"); - }); - - it("correctly interprets a multiple labels, including encoded labelhashes", () => { - const literalLabel = "unnormalized.label" as LiteralLabel; - const interpretedLabelThatLooksLikeALabelHash = literalLabelToInterpretedLabel(literalLabel); - - expect( - interpretedLabelsToInterpretedName([ - "a", - "b", - "c", - interpretedLabelThatLooksLikeALabelHash, - ] as InterpretedLabel[]), - ).toEqual(`a.b.c.${interpretedLabelThatLooksLikeALabelHash}`); - }); - }); - - describe("parsePartialInterpretedName", () => { - it.each([ - // empty input - ["", [], ""], - // partial only (no concrete labels) - ["t", [], "t"], - ["test", [], "test"], - ["exam", [], "exam"], - ["π₯", [], "π₯"], - // concrete TLD with empty partial - ["eth.", ["eth"], ""], - ["base.", ["base"], ""], - // concrete TLD with partial SLD - ["test.eth", ["test"], "eth"], - ["example.eth", ["example"], "eth"], - ["demo.eth", ["demo"], "eth"], - ["parent.eth", ["parent"], "eth"], - ["bridge.eth", ["bridge"], "eth"], - ["examp.eth", ["examp"], "eth"], - // concrete SLD with empty partial - ["sub.parent.eth.", ["sub", "parent", "eth"], ""], - // concrete SLD with partial 3LD - ["sub2.parent.eth", ["sub2", "parent"], "eth"], - ["linked.parent.eth", ["linked", "parent"], "eth"], - // deeper nesting - ["sub1.sub2.parent.eth", ["sub1", "sub2", "parent"], "eth"], - ["wallet.sub1.sub2.parent.eth", ["wallet", "sub1", "sub2", "parent"], "eth"], - ["wallet.linked.parent.eth", ["wallet", "linked", "parent"], "eth"], - // partial at various depths - ["wal.sub1.sub2.parent.eth", ["wal", "sub1", "sub2", "parent"], "eth"], - ["w.sub1.sub2.parent.eth", ["w", "sub1", "sub2", "parent"], "eth"], - // with encoded labelhashes in concrete - [`${EXAMPLE_ENCODED_LABEL_HASH}.eth`, [EXAMPLE_ENCODED_LABEL_HASH], "eth"], - // with encoded labelhash in partial - [ - `example.${EXAMPLE_ENCODED_LABEL_HASH.slice(0, 20)}`, - ["example"], - EXAMPLE_ENCODED_LABEL_HASH.slice(0, 20), - ], - ] as [Name, string[], string][])( - "parsePartialInterpretedName(%j) β { concrete: %j, partial: %j }", - (input, expectedConcrete, expectedPartial) => { - expect(parsePartialInterpretedName(input)).toEqual({ - concrete: expectedConcrete, - partial: expectedPartial, - }); - }, - ); - - it.each([ - "Test.eth", // uppercase in concrete - "EXAMPLE.eth", // uppercase in concrete - "test\0.eth", // null in concrete - "sub.Parent.eth", // uppercase in middle - ] as Name[])("throws for invalid concrete label: %j", (input) => { - expect(() => parsePartialInterpretedName(input)).toThrow(); - }); - }); - - describe("constructSubInterpretedName", () => { - it.each([ - // label only (no parent) - ["eth", undefined, "eth"], - ["eth", "", "eth"], - ["test", undefined, "test"], - ["vitalik", undefined, "vitalik"], - // label + parent - ["test", "eth", "test.eth"], - ["vitalik", "eth", "vitalik.eth"], - ["sub", "parent.eth", "sub.parent.eth"], - ["wallet", "sub.parent.eth", "wallet.sub.parent.eth"], - // with encoded labelhash as label - [EXAMPLE_ENCODED_LABEL_HASH, "eth", `${EXAMPLE_ENCODED_LABEL_HASH}.eth`], - [EXAMPLE_ENCODED_LABEL_HASH, undefined, EXAMPLE_ENCODED_LABEL_HASH], - // with encoded labelhash in parent - ["sub", `${EXAMPLE_ENCODED_LABEL_HASH}.eth`, `sub.${EXAMPLE_ENCODED_LABEL_HASH}.eth`], - // emoji labels - ["π₯", "eth", "π₯.eth"], - ["wallet", "π₯.eth", "wallet.π₯.eth"], - ] as [InterpretedLabel, InterpretedName | undefined, InterpretedName][])( - "constructSubInterpretedName(%j, %j) β %j", - (label, parent, expected) => { - expect(constructSubInterpretedName(label, parent)).toEqual(expected); - }, - ); - }); -}); diff --git a/packages/namehash-ui/src/components/identity/Name.tsx b/packages/namehash-ui/src/components/identity/Name.tsx index 69e5e55c97..2e4f7b1b66 100644 --- a/packages/namehash-ui/src/components/identity/Name.tsx +++ b/packages/namehash-ui/src/components/identity/Name.tsx @@ -1,5 +1,5 @@ import type { Name } from "@ensnode/ensnode-sdk"; -import { beautifyName } from "@ensnode/ensnode-sdk"; +import { beautifyName, isInterpretedName } from "@ensnode/ensnode-sdk"; interface NameDisplayProps { name: Name; @@ -9,10 +9,16 @@ interface NameDisplayProps { /** * Displays an ENS name in beautified form. * - * @param name - The name to display in beautified form. + * If the provided name is not a valid InterpretedName, displays + * "(invalid name)" instead. * + * @param name - The name to display. */ export function NameDisplay({ name, className = "nhui:font-medium" }: NameDisplayProps) { + if (!isInterpretedName(name)) { + return (invalid name); + } + const beautifiedName = beautifyName(name); return {beautifiedName}; } diff --git a/packages/namehash-ui/src/utils/ensManager.ts b/packages/namehash-ui/src/utils/ensManager.ts index 462965e090..fc3783ef4a 100644 --- a/packages/namehash-ui/src/utils/ensManager.ts +++ b/packages/namehash-ui/src/utils/ensManager.ts @@ -1,7 +1,7 @@ import type { Address } from "viem"; import type { ENSNamespaceId } from "@ensnode/datasources"; -import { ENSNamespaceIds, type Name } from "@ensnode/ensnode-sdk"; +import { ENSNamespaceIds, isNormalizedName, type Name } from "@ensnode/ensnode-sdk"; /** * Get the ENS Manager App URL for the provided namespace. @@ -28,10 +28,13 @@ export function getEnsManagerUrl(namespaceId: ENSNamespaceId): URL | null { /** * Builds the URL of the external ENS Manager App Profile page for a given name and ENS Namespace. * - * @returns URL to the Profile page in the external ENS Manager App for a given name and ENS Namespace, - * or null if this URL is not known + * Returns null if the name is not normalized or the namespace has no known ENS Manager App. + * + * @returns URL to the Profile page in the external ENS Manager App, or null */ export function getEnsManagerNameDetailsUrl(name: Name, namespaceId: ENSNamespaceId): URL | null { + if (!isNormalizedName(name)) return null; + const baseUrl = getEnsManagerUrl(namespaceId); if (!baseUrl) return null;