diff --git a/.changeset/spicy-gifts-say.md b/.changeset/spicy-gifts-say.md new file mode 100644 index 0000000000..22949a9551 --- /dev/null +++ b/.changeset/spicy-gifts-say.md @@ -0,0 +1,5 @@ +--- +"ensadmin": patch +--- + +Add input validation and normalization to the Explore Names form and name detail page. The form uses `interpretNameFromUserInput` to normalize valid inputs before navigating, and shows inline errors for invalid or unsupported names. The detail page validates query params with `isNormalizedName`/`isInterpretedName` and shows appropriate error states. Empty name params show the form instead of a broken detail page. diff --git a/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx b/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx index d838481197..0a9321a988 100644 --- a/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx +++ b/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx @@ -19,7 +19,6 @@ export default function Page() { const searchParams = useSearchParams(); const { retainCurrentRawConnectionUrlParam } = useRawConnectionUrlParam(); const exploreNamesBaseHref = retainCurrentRawConnectionUrlParam("/name"); - const name = (searchParams.get("name")?.trim() || null) as Name | null; return ( diff --git a/apps/ensadmin/src/app/name/_components/NameDetailPageContent.tsx b/apps/ensadmin/src/app/name/_components/NameDetailPageContent.tsx index b0b1340ab4..34fd422453 100644 --- a/apps/ensadmin/src/app/name/_components/NameDetailPageContent.tsx +++ b/apps/ensadmin/src/app/name/_components/NameDetailPageContent.tsx @@ -1,7 +1,7 @@ "use client"; import { ASSUME_IMMUTABLE_QUERY, useRecords } from "@ensnode/ensnode-react"; -import { type Name, type ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; +import { type NormalizedName, type ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; import { Card, CardContent } from "@/components/ui/card"; import { useActiveNamespace } from "@/hooks/active/use-active-namespace"; @@ -39,7 +39,7 @@ const AllRequestedTextRecords = [ ]; interface NameDetailPageContentProps { - name: Name; + name: NormalizedName; } export function NameDetailPageContent({ name }: NameDetailPageContentProps) { diff --git a/apps/ensadmin/src/app/name/_components/NameErrors.tsx b/apps/ensadmin/src/app/name/_components/NameErrors.tsx new file mode 100644 index 0000000000..682e599a9c --- /dev/null +++ b/apps/ensadmin/src/app/name/_components/NameErrors.tsx @@ -0,0 +1,20 @@ +import { ErrorInfo } from "@/components/error-info"; + +export function UnnormalizedNameError() { + return ( +
+ +
+ ); +} + +export function InterpretedNameUnsupportedError() { + return ( +
+ +
+ ); +} diff --git a/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx b/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx index 2cfbb24ac1..67469216cf 100644 --- a/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx +++ b/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx @@ -2,14 +2,14 @@ import { EnsAvatar, NameDisplay } from "@namehash/namehash-ui"; -import type { ENSNamespaceId, Name } from "@ensnode/ensnode-sdk"; +import type { ENSNamespaceId, NormalizedName } from "@ensnode/ensnode-sdk"; import { ExternalLinkWithIcon } from "@/components/link"; import { Card, CardContent } from "@/components/ui/card"; import { beautifyUrl } from "@/lib/beautify-url"; interface ProfileHeaderProps { - name: Name; + name: NormalizedName; namespaceId: ENSNamespaceId; headerImage?: string | null; websiteUrl?: string | null; diff --git a/apps/ensadmin/src/app/name/page.tsx b/apps/ensadmin/src/app/name/page.tsx index 56ab72d495..b9b0392f54 100644 --- a/apps/ensadmin/src/app/name/page.tsx +++ b/apps/ensadmin/src/app/name/page.tsx @@ -7,8 +7,11 @@ import { type ChangeEvent, useMemo, useState } from "react"; import { ENSNamespaceIds } from "@ensnode/datasources"; import { getNamespaceSpecificValue, + isInterpretedName, + isNormalizedName, type Name, type NamespaceSpecificValue, + type NormalizedName, } from "@ensnode/ensnode-sdk"; import { getNameDetailsRelativePath, NameLink } from "@/components/name-links"; @@ -17,10 +20,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { useActiveNamespace } from "@/hooks/active/use-active-namespace"; import { useRawConnectionUrlParam } from "@/hooks/use-connection-url-param"; +import { + interpretNameFromUserInput, + NameInterpretationOutcomeResult, +} from "@/lib/interpret-name-from-user-input"; import { NameDetailPageContent } from "./_components/NameDetailPageContent"; +import { InterpretedNameUnsupportedError, UnnormalizedNameError } from "./_components/NameErrors"; -const EXAMPLE_NAMES: NamespaceSpecificValue = { +const EXAMPLE_NAMES: NamespaceSpecificValue = { default: [ "vitalik.eth", "gregskril.eth", @@ -34,7 +42,7 @@ const EXAMPLE_NAMES: NamespaceSpecificValue = { "lens.xyz", "brantly.eth", "lightwalker.eth", - ], + ] as NormalizedName[], [ENSNamespaceIds.Sepolia]: [ "gregskril.eth", "vitalik.eth", @@ -42,7 +50,7 @@ const EXAMPLE_NAMES: NamespaceSpecificValue = { "recordstest.eth", "arrondesean.eth", "decode.eth", - ], + ] as NormalizedName[], [ENSNamespaceIds.EnsTestEnv]: [ "alias.eth", "changerole.eth", @@ -56,14 +64,15 @@ const EXAMPLE_NAMES: NamespaceSpecificValue = { "sub2.parent.eth", "test.eth", "wallet.linked.parent.eth", - ], + ] as NormalizedName[], }; export default function ExploreNamesPage() { const router = useRouter(); const searchParams = useSearchParams(); - const nameFromQuery = searchParams.get("name"); + const nameFromQuery = searchParams.get("name") as Name | null; const [rawInputName, setRawInputName] = useState(""); + const [formError, setFormError] = useState(null); const namespace = useActiveNamespace(); const exampleNames = useMemo( @@ -75,23 +84,49 @@ export default function ExploreNamesPage() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - - // TODO: Input validation and normalization. - // see: https://github.com/namehash/ensnode/issues/1140 - - const href = retainCurrentRawConnectionUrlParam(getNameDetailsRelativePath(rawInputName)); - - router.push(href); + setFormError(null); + + const result = interpretNameFromUserInput(rawInputName); + + switch (result.outcome) { + case NameInterpretationOutcomeResult.Empty: + break; + case NameInterpretationOutcomeResult.Normalized: { + const href = retainCurrentRawConnectionUrlParam( + getNameDetailsRelativePath(result.interpretation), + ); + router.push(href); + break; + } + case NameInterpretationOutcomeResult.Reencoded: + setFormError( + "The provided input contains encoded labelhashes. Support for resolving names with encoded labelhashes is in progress and coming soon.", + ); + break; + case NameInterpretationOutcomeResult.Encoded: + setFormError("The provided input is not a valid ENS name."); + break; + } }; const handleRawInputNameChange = (e: ChangeEvent) => { e.preventDefault(); - + setFormError(null); setRawInputName(e.target.value); }; - if (nameFromQuery) { - return ; + // Detail page: validate name from query params using only validation checks (no normalization). + // see: https://github.com/namehash/ensnode/issues/1140 + if (nameFromQuery !== null && nameFromQuery !== "") { + if (isNormalizedName(nameFromQuery)) { + return ; + } + + if (isInterpretedName(nameFromQuery)) { + return ; + } + + return ; } return ( @@ -115,12 +150,13 @@ export default function ExploreNamesPage() { /> + {formError &&

{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;