diff --git a/.changeset/spicy-gifts-say.md b/.changeset/spicy-gifts-say.md new file mode 100644 index 0000000000..b6a6c356a0 --- /dev/null +++ b/.changeset/spicy-gifts-say.md @@ -0,0 +1,9 @@ +--- +"ensadmin": patch +--- + +Validate name input on the Explore Names page (`/name`). The form normalizes user input before navigating and displays inline errors for unnormalizable names or names with encoded labelhashes (resolution support is in progress). Query params on the detail page are validated against `InterpretedName`. + +Unnormalized names and names with encoded labelhashes each show a dedicated error instead of falling through to a broken detail page. + +An empty `?name=` shows the form rather than the detail page. diff --git a/apps/ensadmin/src/app/name/_components/NameDetailPageContent.tsx b/apps/ensadmin/src/app/name/_components/NameDetailPageContent.tsx index 2ed9b7ddd2..952175ae91 100644 --- a/apps/ensadmin/src/app/name/_components/NameDetailPageContent.tsx +++ b/apps/ensadmin/src/app/name/_components/NameDetailPageContent.tsx @@ -1,6 +1,6 @@ "use client"; -import type { Name } from "enssdk"; +import type { InterpretedName } from "enssdk"; import { ASSUME_IMMUTABLE_QUERY, useRecords } from "@ensnode/ensnode-react"; import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; @@ -41,7 +41,7 @@ const AllRequestedTextRecords = [ ]; interface NameDetailPageContentProps { - name: Name; + name: InterpretedName; } 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 25c7a5fa94..67e0d48090 100644 --- a/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx +++ b/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx @@ -1,7 +1,7 @@ "use client"; import { EnsAvatar, NameDisplay } from "@namehash/namehash-ui"; -import type { Name } from "enssdk"; +import type { InterpretedName } from "enssdk"; import type { ENSNamespaceId } from "@ensnode/ensnode-sdk"; @@ -10,7 +10,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { beautifyUrl } from "@/lib/beautify-url"; interface ProfileHeaderProps { - name: Name; + name: InterpretedName; 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 3536137c8b..8133ded730 100644 --- a/apps/ensadmin/src/app/name/page.tsx +++ b/apps/ensadmin/src/app/name/page.tsx @@ -1,7 +1,15 @@ "use client"; import { NameDisplay } from "@namehash/namehash-ui"; -import type { Name } from "enssdk"; +import { + asInterpretedName, + asLiteralName, + type InterpretedName, + isInterpretedName, + isNormalizedName, + literalNameToInterpretedName, + type Name, +} from "enssdk"; import { useRouter, useSearchParams } from "next/navigation"; import { type ChangeEvent, useMemo, useState } from "react"; @@ -16,8 +24,9 @@ import { useActiveEnsNodeStackInfo } from "@/hooks/active/use-active-ensnode-sta import { useRawConnectionUrlParam } from "@/hooks/use-connection-url-param"; import { NameDetailPageContent } from "./_components/NameDetailPageContent"; +import { InterpretedNameUnsupportedError, UnnormalizedNameError } from "./_components/NameErrors"; -const EXAMPLE_NAMES: NamespaceSpecificValue = { +const EXAMPLE_NAMES: NamespaceSpecificValue = { default: [ "vitalik.eth", "gregskril.eth", @@ -31,7 +40,7 @@ const EXAMPLE_NAMES: NamespaceSpecificValue = { "lens.xyz", "brantly.eth", "lightwalker.eth", - ], + ].map(asInterpretedName), [ENSNamespaceIds.Sepolia]: [ "gregskril.eth", "vitalik.eth", @@ -39,7 +48,7 @@ const EXAMPLE_NAMES: NamespaceSpecificValue = { "recordstest.eth", "arrondesean.eth", "decode.eth", - ], + ].map(asInterpretedName), [ENSNamespaceIds.EnsTestEnv]: [ "alias.eth", "changerole.eth", @@ -53,7 +62,7 @@ const EXAMPLE_NAMES: NamespaceSpecificValue = { "sub2.parent.eth", "test.eth", "wallet.linked.parent.eth", - ], + ].map(asInterpretedName), }; export default function ExploreNamesPage() { @@ -61,6 +70,7 @@ export default function ExploreNamesPage() { const searchParams = useSearchParams(); const nameFromQuery = searchParams.get("name"); const [rawInputName, setRawInputName] = useState(""); + const [formError, setFormError] = useState(null); const { namespace } = useActiveEnsNodeStackInfo().ensIndexer; const exampleNames = useMemo( @@ -72,22 +82,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)); - + setFormError(null); + + if (rawInputName.trim() === "") return; + + let interpreted: InterpretedName; + try { + // Allow encoded labelhashes through; throw on unnormalizable labels. + interpreted = literalNameToInterpretedName(asLiteralName(rawInputName), { + allowEncodedLabelHashes: true, + }); + } catch { + setFormError("The provided input is not a valid ENS name."); + return; + } + + if (!isNormalizedName(interpreted)) { + setFormError( + "The provided input contains encoded labelhashes. Support for resolving names with encoded labelhashes is in progress and coming soon.", + ); + return; + } + + const href = retainCurrentRawConnectionUrlParam(getNameDetailsRelativePath(interpreted)); router.push(href); }; const handleRawInputNameChange = (e: ChangeEvent) => { e.preventDefault(); - + setFormError(null); setRawInputName(e.target.value); }; - if (nameFromQuery) { + // 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 (!isInterpretedName(nameFromQuery)) { + return ; + } + + if (!isNormalizedName(nameFromQuery)) { + return ; + } + return ; } @@ -112,12 +149,13 @@ export default function ExploreNamesPage() { /> + {formError &&

{formError}

}

Examples:

diff --git a/packages/namehash-ui/src/components/identity/Name.tsx b/packages/namehash-ui/src/components/identity/Name.tsx index a4bec4f1a0..3128b652d5 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 "enssdk"; -import { beautifyName } from "enssdk"; +import { beautifyInterpretedName, isInterpretedName } from "enssdk"; 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) { - const beautifiedName = beautifyName(name); + if (!isInterpretedName(name)) { + return (invalid name); + } + + const beautifiedName = beautifyInterpretedName(name); return {beautifiedName}; } diff --git a/packages/namehash-ui/src/utils/ensManager.ts b/packages/namehash-ui/src/utils/ensManager.ts index 4b488e3328..85b077b577 100644 --- a/packages/namehash-ui/src/utils/ensManager.ts +++ b/packages/namehash-ui/src/utils/ensManager.ts @@ -1,4 +1,4 @@ -import type { Address, Name } from "enssdk"; +import { type Address, isNormalizedName, type Name } from "enssdk"; import type { ENSNamespaceId } from "@ensnode/datasources"; import { ENSNamespaceIds } from "@ensnode/ensnode-sdk"; @@ -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;