From 4bf234fcc726ac0418a0d4be6be922fd392fff57 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 26 Mar 2026 07:51:33 +0000 Subject: [PATCH 01/15] first pass --- apps/ensadmin/src/app/@actions/name/page.tsx | 9 ++-- .../src/app/@breadcrumbs/name/page.tsx | 6 +-- .../_components/NameDetailPageContent.tsx | 4 +- .../src/app/name/_components/NameErrors.tsx | 46 +++++++++++++++++++ .../app/name/_components/ProfileHeader.tsx | 4 +- apps/ensadmin/src/app/name/page.tsx | 11 +++++ 6 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 apps/ensadmin/src/app/name/_components/NameErrors.tsx diff --git a/apps/ensadmin/src/app/@actions/name/page.tsx b/apps/ensadmin/src/app/@actions/name/page.tsx index ac765a4ad4..c2abb109f1 100644 --- a/apps/ensadmin/src/app/@actions/name/page.tsx +++ b/apps/ensadmin/src/app/@actions/name/page.tsx @@ -3,7 +3,7 @@ import { getEnsManagerNameDetailsUrl } from "@namehash/namehash-ui"; import { useSearchParams } from "next/navigation"; -import type { Name } from "@ensnode/ensnode-sdk"; +import { isNormalizedName, type Name } from "@ensnode/ensnode-sdk"; import { ExternalLinkWithIcon } from "@/components/link"; import { Button } from "@/components/ui/button"; @@ -13,11 +13,14 @@ export default function ActionsNamePage() { const searchParams = useSearchParams(); const nameParam = searchParams.get("name"); - const name = nameParam ? (decodeURIComponent(nameParam) as Name) : null; + const name = nameParam ? (nameParam as Name) : null; const { data: namespace } = useNamespace(); - const ensAppProfileUrl = name && namespace ? getEnsManagerNameDetailsUrl(name, namespace) : null; + const ensAppProfileUrl = + name && isNormalizedName(name) && namespace + ? getEnsManagerNameDetailsUrl(name, namespace) + : null; if (!ensAppProfileUrl) return null; diff --git a/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx b/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx index a7177affdf..be42aeb7a4 100644 --- a/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx +++ b/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx @@ -3,7 +3,7 @@ import { NameDisplay } from "@namehash/namehash-ui"; import { useSearchParams } from "next/navigation"; -import type { Name } from "@ensnode/ensnode-sdk"; +import { isInterpretedName, type Name } from "@ensnode/ensnode-sdk"; import BreadcrumbsGroup from "@/components/breadcrumbs/group"; import { @@ -20,7 +20,7 @@ export default function Page() { const { retainCurrentRawConnectionUrlParam } = useRawConnectionUrlParam(); const exploreNamesBaseHref = retainCurrentRawConnectionUrlParam("/name"); - const name = nameParam ? (decodeURIComponent(nameParam) as Name) : null; + const name = nameParam ? (nameParam as Name) : null; return ( @@ -32,7 +32,7 @@ export default function Page() { - + {isInterpretedName(name) ? : name} 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..1e8042e14f --- /dev/null +++ b/apps/ensadmin/src/app/name/_components/NameErrors.tsx @@ -0,0 +1,46 @@ +import type { Name } from "@ensnode/ensnode-sdk"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +interface NameErrorProps { + name: Name; +} + +export function InvalidNameError({ name }: NameErrorProps) { + return ( +
+ + + Invalid Name + + +

+ The provided name{" "} + {name} is not ENS + normalized. +

+
+
+
+ ); +} + +export function EncodedLabelhashUnsupportedError({ name }: NameErrorProps) { + return ( +
+ + + Encoded Labelhash Detected + + +

+ The provided name{" "} + {name} contains + encoded labelhashes. Support for resolving names with encoded labelhashes is in progress + and coming soon. +

+
+
+
+ ); +} 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..49d6e5c462 100644 --- a/apps/ensadmin/src/app/name/page.tsx +++ b/apps/ensadmin/src/app/name/page.tsx @@ -7,6 +7,8 @@ import { type ChangeEvent, useMemo, useState } from "react"; import { ENSNamespaceIds } from "@ensnode/datasources"; import { getNamespaceSpecificValue, + isInterpretedName, + isNormalizedName, type Name, type NamespaceSpecificValue, } from "@ensnode/ensnode-sdk"; @@ -19,6 +21,7 @@ import { useActiveNamespace } from "@/hooks/active/use-active-namespace"; import { useRawConnectionUrlParam } from "@/hooks/use-connection-url-param"; import { NameDetailPageContent } from "./_components/NameDetailPageContent"; +import { EncodedLabelhashUnsupportedError, InvalidNameError } from "./_components/NameErrors"; const EXAMPLE_NAMES: NamespaceSpecificValue = { default: [ @@ -91,6 +94,14 @@ export default function ExploreNamesPage() { }; if (nameFromQuery) { + if (!isInterpretedName(nameFromQuery)) { + return ; + } + + if (!isNormalizedName(nameFromQuery)) { + return ; + } + return ; } From f7893a2ab135aba09d325e12543edcaedf81899c Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 26 Mar 2026 08:14:11 +0000 Subject: [PATCH 02/15] use ErrorInfo component --- .../src/app/name/_components/NameErrors.tsx | 39 ++++++------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/apps/ensadmin/src/app/name/_components/NameErrors.tsx b/apps/ensadmin/src/app/name/_components/NameErrors.tsx index 1e8042e14f..47cec20136 100644 --- a/apps/ensadmin/src/app/name/_components/NameErrors.tsx +++ b/apps/ensadmin/src/app/name/_components/NameErrors.tsx @@ -1,6 +1,6 @@ import type { Name } from "@ensnode/ensnode-sdk"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ErrorInfo } from "@/components/error-info"; interface NameErrorProps { name: Name; @@ -8,39 +8,22 @@ interface NameErrorProps { export function InvalidNameError({ name }: NameErrorProps) { return ( -
- - - Invalid Name - - -

- The provided name{" "} - {name} is not ENS - normalized. -

-
-
+
+
); } export function EncodedLabelhashUnsupportedError({ name }: NameErrorProps) { return ( -
- - - Encoded Labelhash Detected - - -

- The provided name{" "} - {name} contains - encoded labelhashes. Support for resolving names with encoded labelhashes is in progress - and coming soon. -

-
-
+
+
); } From 569f803656b5eaf8c685f6bc2d42823080774c07 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 26 Mar 2026 08:18:20 +0000 Subject: [PATCH 03/15] docs(changeset): Validate name query param as InterpretedName and NormalizedName before rendering the name details page, displaying appropriate error states for invalid or unsupported names. --- .changeset/spicy-gifts-say.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/spicy-gifts-say.md diff --git a/.changeset/spicy-gifts-say.md b/.changeset/spicy-gifts-say.md new file mode 100644 index 0000000000..bde00532db --- /dev/null +++ b/.changeset/spicy-gifts-say.md @@ -0,0 +1,5 @@ +--- +"ensadmin": patch +--- + +Validate name query param as InterpretedName and NormalizedName before rendering the name details page, displaying appropriate error states for invalid or unsupported names. From 255190e1ec5fa4032506a2e320d5a7dfe0718557 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 26 Mar 2026 08:25:00 +0000 Subject: [PATCH 04/15] handle null name --- apps/ensadmin/src/app/name/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensadmin/src/app/name/page.tsx b/apps/ensadmin/src/app/name/page.tsx index 49d6e5c462..c729180495 100644 --- a/apps/ensadmin/src/app/name/page.tsx +++ b/apps/ensadmin/src/app/name/page.tsx @@ -93,7 +93,7 @@ export default function ExploreNamesPage() { setRawInputName(e.target.value); }; - if (nameFromQuery) { + if (nameFromQuery !== null) { if (!isInterpretedName(nameFromQuery)) { return ; } From 0a17eea7f0b49185af0b4d5affc82c9c3ac1e2c1 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 26 Mar 2026 08:26:13 +0000 Subject: [PATCH 05/15] update the error message --- apps/ensadmin/src/app/name/_components/NameErrors.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensadmin/src/app/name/_components/NameErrors.tsx b/apps/ensadmin/src/app/name/_components/NameErrors.tsx index 47cec20136..586fa71cc5 100644 --- a/apps/ensadmin/src/app/name/_components/NameErrors.tsx +++ b/apps/ensadmin/src/app/name/_components/NameErrors.tsx @@ -11,7 +11,7 @@ export function InvalidNameError({ name }: NameErrorProps) {
); From 0ab03315893669ee471fee7d3a7159dd1f07526a Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 27 Mar 2026 08:44:37 +0000 Subject: [PATCH 06/15] form validation --- apps/ensadmin/src/app/name/page.tsx | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/ensadmin/src/app/name/page.tsx b/apps/ensadmin/src/app/name/page.tsx index c729180495..e066166b16 100644 --- a/apps/ensadmin/src/app/name/page.tsx +++ b/apps/ensadmin/src/app/name/page.tsx @@ -3,6 +3,7 @@ import { NameDisplay } from "@namehash/namehash-ui"; import { useRouter, useSearchParams } from "next/navigation"; import { type ChangeEvent, useMemo, useState } from "react"; +import { normalize } from "viem/ens"; import { ENSNamespaceIds } from "@ensnode/datasources"; import { @@ -67,6 +68,7 @@ export default function ExploreNamesPage() { const searchParams = useSearchParams(); const nameFromQuery = searchParams.get("name"); const [rawInputName, setRawInputName] = useState(""); + const [formError, setFormError] = useState(null); const namespace = useActiveNamespace(); const exampleNames = useMemo( @@ -78,18 +80,26 @@ 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); + + try { + const normalizedName = normalize(rawInputName); + const href = retainCurrentRawConnectionUrlParam(getNameDetailsRelativePath(normalizedName)); + router.push(href); + } catch { + if (isInterpretedName(rawInputName)) { + setFormError( + `The name "${rawInputName}" contains encoded labelhashes. Support for resolving names with encoded labelhashes is in progress and coming soon.`, + ); + } else { + setFormError(`The name "${rawInputName}" is not a valid ENS name.`); + } + } }; const handleRawInputNameChange = (e: ChangeEvent) => { e.preventDefault(); - + setFormError(null); setRawInputName(e.target.value); }; @@ -132,6 +142,7 @@ export default function ExploreNamesPage() { View Profile + {formError &&

{formError}

}

Examples:

From 14e162fb47ba984538534950d99aacc17827d9b3 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sat, 28 Mar 2026 08:57:19 +0000 Subject: [PATCH 07/15] fix url state and empty params --- .changeset/spicy-gifts-say.md | 2 +- apps/ensadmin/src/app/@actions/name/page.tsx | 4 +--- .../@breadcrumbs/mock/config-info/page.tsx | 6 +++-- .../mock/display-identity/page.tsx | 6 +++-- .../@breadcrumbs/mock/indexing-stats/page.tsx | 6 +++-- .../mock/registrar-actions/page.tsx | 6 +++-- .../@breadcrumbs/mock/relative-time/page.tsx | 6 +++-- .../src/app/@breadcrumbs/name/page.tsx | 9 ++++--- apps/ensadmin/src/app/name/page.tsx | 24 +++++++++++++------ 9 files changed, 43 insertions(+), 26 deletions(-) diff --git a/.changeset/spicy-gifts-say.md b/.changeset/spicy-gifts-say.md index bde00532db..98770203b3 100644 --- a/.changeset/spicy-gifts-say.md +++ b/.changeset/spicy-gifts-say.md @@ -2,4 +2,4 @@ "ensadmin": patch --- -Validate name query param as InterpretedName and NormalizedName before rendering the name details page, displaying appropriate error states for invalid or unsupported names. +Add input validation and normalization to the Explore Names form and name detail page. The form normalizes valid inputs (e.g. "VITALIK.ETH" β†’ "vitalik.eth") before navigating, and shows inline errors for invalid or unsupported names. Direct URL visits with unnormalized but normalizable names are redirected to the normalized form. Empty name params show the form instead of a broken detail page. Breadcrumb links now use next/link for client-side navigation. diff --git a/apps/ensadmin/src/app/@actions/name/page.tsx b/apps/ensadmin/src/app/@actions/name/page.tsx index c2abb109f1..da0787e0a3 100644 --- a/apps/ensadmin/src/app/@actions/name/page.tsx +++ b/apps/ensadmin/src/app/@actions/name/page.tsx @@ -11,9 +11,7 @@ import { useNamespace } from "@/hooks/async/use-namespace"; export default function ActionsNamePage() { const searchParams = useSearchParams(); - const nameParam = searchParams.get("name"); - - const name = nameParam ? (nameParam as Name) : null; + const name = searchParams.get("name") as Name | null; const { data: namespace } = useNamespace(); diff --git a/apps/ensadmin/src/app/@breadcrumbs/mock/config-info/page.tsx b/apps/ensadmin/src/app/@breadcrumbs/mock/config-info/page.tsx index 7f508fd196..6aab814dad 100644 --- a/apps/ensadmin/src/app/@breadcrumbs/mock/config-info/page.tsx +++ b/apps/ensadmin/src/app/@breadcrumbs/mock/config-info/page.tsx @@ -1,5 +1,7 @@ "use client"; +import Link from "next/link"; + import { BreadcrumbItem, BreadcrumbLink, @@ -14,8 +16,8 @@ export default function Page() { return ( <> - - UI Mocks + + UI Mocks diff --git a/apps/ensadmin/src/app/@breadcrumbs/mock/display-identity/page.tsx b/apps/ensadmin/src/app/@breadcrumbs/mock/display-identity/page.tsx index 5a63e3ef44..93a50e23b2 100644 --- a/apps/ensadmin/src/app/@breadcrumbs/mock/display-identity/page.tsx +++ b/apps/ensadmin/src/app/@breadcrumbs/mock/display-identity/page.tsx @@ -1,5 +1,7 @@ "use client"; +import Link from "next/link"; + import { BreadcrumbItem, BreadcrumbLink, @@ -14,8 +16,8 @@ export default function Page() { return ( <> - - UI Mocks + + UI Mocks diff --git a/apps/ensadmin/src/app/@breadcrumbs/mock/indexing-stats/page.tsx b/apps/ensadmin/src/app/@breadcrumbs/mock/indexing-stats/page.tsx index 8f9dd043bd..8453ee0f0c 100644 --- a/apps/ensadmin/src/app/@breadcrumbs/mock/indexing-stats/page.tsx +++ b/apps/ensadmin/src/app/@breadcrumbs/mock/indexing-stats/page.tsx @@ -1,5 +1,7 @@ "use client"; +import Link from "next/link"; + import { BreadcrumbItem, BreadcrumbLink, @@ -14,8 +16,8 @@ export default function Page() { return ( <> - - UI Mocks + + UI Mocks diff --git a/apps/ensadmin/src/app/@breadcrumbs/mock/registrar-actions/page.tsx b/apps/ensadmin/src/app/@breadcrumbs/mock/registrar-actions/page.tsx index 4bb8d9fe0d..23ecdbf33b 100644 --- a/apps/ensadmin/src/app/@breadcrumbs/mock/registrar-actions/page.tsx +++ b/apps/ensadmin/src/app/@breadcrumbs/mock/registrar-actions/page.tsx @@ -1,5 +1,7 @@ "use client"; +import Link from "next/link"; + import { BreadcrumbItem, BreadcrumbLink, @@ -14,8 +16,8 @@ export default function Page() { return ( <> - - UI Mocks + + UI Mocks diff --git a/apps/ensadmin/src/app/@breadcrumbs/mock/relative-time/page.tsx b/apps/ensadmin/src/app/@breadcrumbs/mock/relative-time/page.tsx index 199d3a1183..2f2f632cb1 100644 --- a/apps/ensadmin/src/app/@breadcrumbs/mock/relative-time/page.tsx +++ b/apps/ensadmin/src/app/@breadcrumbs/mock/relative-time/page.tsx @@ -1,5 +1,7 @@ "use client"; +import Link from "next/link"; + import { BreadcrumbItem, BreadcrumbLink, @@ -13,8 +15,8 @@ export default function Page() { const uiMocksBaseHref = retainCurrentRawConnectionUrlParam("/mock"); return ( <> - - UI Mocks + + UI Mocks diff --git a/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx b/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx index be42aeb7a4..63d9a6a0e9 100644 --- a/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx +++ b/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx @@ -1,6 +1,7 @@ "use client"; import { NameDisplay } from "@namehash/namehash-ui"; +import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { isInterpretedName, type Name } from "@ensnode/ensnode-sdk"; @@ -16,18 +17,16 @@ import { useRawConnectionUrlParam } from "@/hooks/use-connection-url-param"; export default function Page() { const searchParams = useSearchParams(); - const nameParam = searchParams.get("name"); + const name = searchParams.get("name") as Name | null; const { retainCurrentRawConnectionUrlParam } = useRawConnectionUrlParam(); const exploreNamesBaseHref = retainCurrentRawConnectionUrlParam("/name"); - const name = nameParam ? (nameParam as Name) : null; - return ( {name ? ( <> - - Names + + Names diff --git a/apps/ensadmin/src/app/name/page.tsx b/apps/ensadmin/src/app/name/page.tsx index e066166b16..e27a2bba1a 100644 --- a/apps/ensadmin/src/app/name/page.tsx +++ b/apps/ensadmin/src/app/name/page.tsx @@ -66,7 +66,7 @@ const EXAMPLE_NAMES: NamespaceSpecificValue = { 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); @@ -103,13 +103,23 @@ export default function ExploreNamesPage() { setRawInputName(e.target.value); }; - if (nameFromQuery !== null) { - if (!isInterpretedName(nameFromQuery)) { - return ; - } - + if (nameFromQuery !== null && nameFromQuery !== "") { + // If the name is normalizable but not yet normalized, redirect to the normalized form. if (!isNormalizedName(nameFromQuery)) { - return ; + try { + const normalizedName = normalize(nameFromQuery); + const href = retainCurrentRawConnectionUrlParam(getNameDetailsRelativePath(normalizedName)); + router.replace(href); + return null; + } catch { + // normalize() threw β€” fall through to error handling below + } + + if (isInterpretedName(nameFromQuery)) { + return ; + } + + return ; } return ; From 72e310e5359cb0dd76aceaebd339f5c195fb55a0 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sat, 28 Mar 2026 08:58:50 +0000 Subject: [PATCH 08/15] Update apps/ensadmin/src/app/name/page.tsx Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- apps/ensadmin/src/app/name/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensadmin/src/app/name/page.tsx b/apps/ensadmin/src/app/name/page.tsx index e27a2bba1a..f2a2af4727 100644 --- a/apps/ensadmin/src/app/name/page.tsx +++ b/apps/ensadmin/src/app/name/page.tsx @@ -24,7 +24,7 @@ import { useRawConnectionUrlParam } from "@/hooks/use-connection-url-param"; import { NameDetailPageContent } from "./_components/NameDetailPageContent"; import { EncodedLabelhashUnsupportedError, InvalidNameError } from "./_components/NameErrors"; -const EXAMPLE_NAMES: NamespaceSpecificValue = { +const EXAMPLE_NAMES: NamespaceSpecificValue = { default: [ "vitalik.eth", "gregskril.eth", From a2052943c83f3f78dbea7007b8bf192219081ce8 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sat, 28 Mar 2026 09:04:32 +0000 Subject: [PATCH 09/15] tidy normalize once logic --- apps/ensadmin/src/app/name/page.tsx | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/apps/ensadmin/src/app/name/page.tsx b/apps/ensadmin/src/app/name/page.tsx index f2a2af4727..84d4f5b530 100644 --- a/apps/ensadmin/src/app/name/page.tsx +++ b/apps/ensadmin/src/app/name/page.tsx @@ -9,9 +9,9 @@ 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"; @@ -38,7 +38,7 @@ const EXAMPLE_NAMES: NamespaceSpecificValue = { "lens.xyz", "brantly.eth", "lightwalker.eth", - ], + ] as NormalizedName[], [ENSNamespaceIds.Sepolia]: [ "gregskril.eth", "vitalik.eth", @@ -46,7 +46,7 @@ const EXAMPLE_NAMES: NamespaceSpecificValue = { "recordstest.eth", "arrondesean.eth", "decode.eth", - ], + ] as NormalizedName[], [ENSNamespaceIds.EnsTestEnv]: [ "alias.eth", "changerole.eth", @@ -60,7 +60,7 @@ const EXAMPLE_NAMES: NamespaceSpecificValue = { "sub2.parent.eth", "test.eth", "wallet.linked.parent.eth", - ], + ] as NormalizedName[], }; export default function ExploreNamesPage() { @@ -104,25 +104,24 @@ export default function ExploreNamesPage() { }; if (nameFromQuery !== null && nameFromQuery !== "") { - // If the name is normalizable but not yet normalized, redirect to the normalized form. - if (!isNormalizedName(nameFromQuery)) { - try { - const normalizedName = normalize(nameFromQuery); + try { + const normalizedName = normalize(nameFromQuery); + + // If the name is normalizable but not yet normalized, redirect to the normalized form. + if (normalizedName !== nameFromQuery) { const href = retainCurrentRawConnectionUrlParam(getNameDetailsRelativePath(normalizedName)); router.replace(href); return null; - } catch { - // normalize() threw β€” fall through to error handling below } + return ; + } catch { if (isInterpretedName(nameFromQuery)) { return ; } return ; } - - return ; } return ( From 1fd70f1b0e0b36e78cf8cd8d69ce350e48b1b4a5 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sat, 28 Mar 2026 09:07:33 +0000 Subject: [PATCH 10/15] update normalization logic for react params --- apps/ensadmin/src/app/name/page.tsx | 61 ++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/apps/ensadmin/src/app/name/page.tsx b/apps/ensadmin/src/app/name/page.tsx index 84d4f5b530..6e38892f67 100644 --- a/apps/ensadmin/src/app/name/page.tsx +++ b/apps/ensadmin/src/app/name/page.tsx @@ -2,7 +2,7 @@ import { NameDisplay } from "@namehash/namehash-ui"; import { useRouter, useSearchParams } from "next/navigation"; -import { type ChangeEvent, useMemo, useState } from "react"; +import { type ChangeEvent, useEffect, useMemo, useState } from "react"; import { normalize } from "viem/ens"; import { ENSNamespaceIds } from "@ensnode/datasources"; @@ -78,6 +78,39 @@ export default function ExploreNamesPage() { const { retainCurrentRawConnectionUrlParam } = useRawConnectionUrlParam(); + // Compute normalization result for the name query param. + const nameQueryResult = useMemo(() => { + if (nameFromQuery === null || nameFromQuery === "") return null; + + try { + const normalizedName = normalize(nameFromQuery) as NormalizedName; + + if (normalizedName !== nameFromQuery) { + return { + status: "needs-redirect" as const, + redirectHref: retainCurrentRawConnectionUrlParam( + getNameDetailsRelativePath(normalizedName), + ), + }; + } + + return { status: "valid" as const, normalizedName }; + } catch { + if (isInterpretedName(nameFromQuery)) { + return { status: "encoded-labelhash" as const, name: nameFromQuery }; + } + + return { status: "invalid" as const, name: nameFromQuery }; + } + }, [nameFromQuery, retainCurrentRawConnectionUrlParam]); + + // Redirect to the normalized form when needed. + useEffect(() => { + if (nameQueryResult?.status === "needs-redirect") { + router.replace(nameQueryResult.redirectHref); + } + }, [nameQueryResult, router]); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setFormError(null); @@ -103,24 +136,16 @@ export default function ExploreNamesPage() { setRawInputName(e.target.value); }; - if (nameFromQuery !== null && nameFromQuery !== "") { - try { - const normalizedName = normalize(nameFromQuery); - - // If the name is normalizable but not yet normalized, redirect to the normalized form. - if (normalizedName !== nameFromQuery) { - const href = retainCurrentRawConnectionUrlParam(getNameDetailsRelativePath(normalizedName)); - router.replace(href); + if (nameQueryResult) { + switch (nameQueryResult.status) { + case "needs-redirect": return null; - } - - return ; - } catch { - if (isInterpretedName(nameFromQuery)) { - return ; - } - - return ; + case "valid": + return ; + case "encoded-labelhash": + return ; + case "invalid": + return ; } } From 2ff21a598f71133774e46d8103c047fad3e4ac51 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 29 Mar 2026 14:40:55 +0100 Subject: [PATCH 11/15] startover --- .changeset/spicy-gifts-say.md | 2 +- apps/ensadmin/src/app/@actions/name/page.tsx | 7 +- .../src/app/@breadcrumbs/name/page.tsx | 4 +- .../src/app/name/_components/NameErrors.tsx | 17 +- apps/ensadmin/src/app/name/page.tsx | 94 ++++------ .../format-interpreted-name-for-display.ts | 35 ++++ .../interpret-name-from-user-input.test.ts | 164 ++++++++++++++++++ .../src/lib/interpret-name-from-user-input.ts | 113 ++++++++++++ .../src/components/identity/Name.tsx | 10 +- packages/namehash-ui/src/utils/ensManager.ts | 9 +- 10 files changed, 372 insertions(+), 83 deletions(-) create mode 100644 apps/ensadmin/src/lib/format-interpreted-name-for-display.ts create mode 100644 apps/ensadmin/src/lib/interpret-name-from-user-input.test.ts create mode 100644 apps/ensadmin/src/lib/interpret-name-from-user-input.ts diff --git a/.changeset/spicy-gifts-say.md b/.changeset/spicy-gifts-say.md index 98770203b3..e271e8053a 100644 --- a/.changeset/spicy-gifts-say.md +++ b/.changeset/spicy-gifts-say.md @@ -2,4 +2,4 @@ "ensadmin": patch --- -Add input validation and normalization to the Explore Names form and name detail page. The form normalizes valid inputs (e.g. "VITALIK.ETH" β†’ "vitalik.eth") before navigating, and shows inline errors for invalid or unsupported names. Direct URL visits with unnormalized but normalizable names are redirected to the normalized form. Empty name params show the form instead of a broken detail page. Breadcrumb links now use next/link for client-side navigation. +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. Breadcrumb links now use next/link for client-side navigation. diff --git a/apps/ensadmin/src/app/@actions/name/page.tsx b/apps/ensadmin/src/app/@actions/name/page.tsx index da0787e0a3..c329802894 100644 --- a/apps/ensadmin/src/app/@actions/name/page.tsx +++ b/apps/ensadmin/src/app/@actions/name/page.tsx @@ -3,7 +3,7 @@ import { getEnsManagerNameDetailsUrl } from "@namehash/namehash-ui"; import { useSearchParams } from "next/navigation"; -import { isNormalizedName, type Name } from "@ensnode/ensnode-sdk"; +import type { Name } from "@ensnode/ensnode-sdk"; import { ExternalLinkWithIcon } from "@/components/link"; import { Button } from "@/components/ui/button"; @@ -15,10 +15,7 @@ export default function ActionsNamePage() { const { data: namespace } = useNamespace(); - const ensAppProfileUrl = - name && isNormalizedName(name) && namespace - ? getEnsManagerNameDetailsUrl(name, namespace) - : null; + const ensAppProfileUrl = name && namespace ? getEnsManagerNameDetailsUrl(name, namespace) : null; if (!ensAppProfileUrl) return null; diff --git a/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx b/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx index 63d9a6a0e9..e8813550c7 100644 --- a/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx +++ b/apps/ensadmin/src/app/@breadcrumbs/name/page.tsx @@ -4,7 +4,7 @@ import { NameDisplay } from "@namehash/namehash-ui"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import { isInterpretedName, type Name } from "@ensnode/ensnode-sdk"; +import type { Name } from "@ensnode/ensnode-sdk"; import BreadcrumbsGroup from "@/components/breadcrumbs/group"; import { @@ -31,7 +31,7 @@ export default function Page() { - {isInterpretedName(name) ? : name} + diff --git a/apps/ensadmin/src/app/name/_components/NameErrors.tsx b/apps/ensadmin/src/app/name/_components/NameErrors.tsx index 586fa71cc5..682e599a9c 100644 --- a/apps/ensadmin/src/app/name/_components/NameErrors.tsx +++ b/apps/ensadmin/src/app/name/_components/NameErrors.tsx @@ -1,28 +1,19 @@ -import type { Name } from "@ensnode/ensnode-sdk"; - import { ErrorInfo } from "@/components/error-info"; -interface NameErrorProps { - name: Name; -} - -export function InvalidNameError({ name }: NameErrorProps) { +export function UnnormalizedNameError() { return (
- +
); } -export function EncodedLabelhashUnsupportedError({ name }: NameErrorProps) { +export function InterpretedNameUnsupportedError() { return (
); diff --git a/apps/ensadmin/src/app/name/page.tsx b/apps/ensadmin/src/app/name/page.tsx index 6e38892f67..b9b0392f54 100644 --- a/apps/ensadmin/src/app/name/page.tsx +++ b/apps/ensadmin/src/app/name/page.tsx @@ -2,13 +2,13 @@ import { NameDisplay } from "@namehash/namehash-ui"; import { useRouter, useSearchParams } from "next/navigation"; -import { type ChangeEvent, useEffect, useMemo, useState } from "react"; -import { normalize } from "viem/ens"; +import { type ChangeEvent, useMemo, useState } from "react"; import { ENSNamespaceIds } from "@ensnode/datasources"; import { getNamespaceSpecificValue, isInterpretedName, + isNormalizedName, type Name, type NamespaceSpecificValue, type NormalizedName, @@ -20,9 +20,13 @@ 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 { EncodedLabelhashUnsupportedError, InvalidNameError } from "./_components/NameErrors"; +import { InterpretedNameUnsupportedError, UnnormalizedNameError } from "./_components/NameErrors"; const EXAMPLE_NAMES: NamespaceSpecificValue = { default: [ @@ -78,55 +82,30 @@ export default function ExploreNamesPage() { const { retainCurrentRawConnectionUrlParam } = useRawConnectionUrlParam(); - // Compute normalization result for the name query param. - const nameQueryResult = useMemo(() => { - if (nameFromQuery === null || nameFromQuery === "") return null; - - try { - const normalizedName = normalize(nameFromQuery) as NormalizedName; - - if (normalizedName !== nameFromQuery) { - return { - status: "needs-redirect" as const, - redirectHref: retainCurrentRawConnectionUrlParam( - getNameDetailsRelativePath(normalizedName), - ), - }; - } - - return { status: "valid" as const, normalizedName }; - } catch { - if (isInterpretedName(nameFromQuery)) { - return { status: "encoded-labelhash" as const, name: nameFromQuery }; - } - - return { status: "invalid" as const, name: nameFromQuery }; - } - }, [nameFromQuery, retainCurrentRawConnectionUrlParam]); - - // Redirect to the normalized form when needed. - useEffect(() => { - if (nameQueryResult?.status === "needs-redirect") { - router.replace(nameQueryResult.redirectHref); - } - }, [nameQueryResult, router]); - const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setFormError(null); - try { - const normalizedName = normalize(rawInputName); - const href = retainCurrentRawConnectionUrlParam(getNameDetailsRelativePath(normalizedName)); - router.push(href); - } catch { - if (isInterpretedName(rawInputName)) { - setFormError( - `The name "${rawInputName}" contains encoded labelhashes. Support for resolving names with encoded labelhashes is in progress and coming soon.`, + const result = interpretNameFromUserInput(rawInputName); + + switch (result.outcome) { + case NameInterpretationOutcomeResult.Empty: + break; + case NameInterpretationOutcomeResult.Normalized: { + const href = retainCurrentRawConnectionUrlParam( + getNameDetailsRelativePath(result.interpretation), ); - } else { - setFormError(`The name "${rawInputName}" is not a valid ENS name.`); + 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; } }; @@ -136,17 +115,18 @@ export default function ExploreNamesPage() { setRawInputName(e.target.value); }; - if (nameQueryResult) { - switch (nameQueryResult.status) { - case "needs-redirect": - return null; - case "valid": - return ; - case "encoded-labelhash": - return ; - case "invalid": - 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 ( @@ -170,7 +150,7 @@ export default function ExploreNamesPage() { />