Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spicy-gifts-say.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 0 additions & 1 deletion apps/ensadmin/src/app/@breadcrumbs/name/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -39,7 +39,7 @@ const AllRequestedTextRecords = [
];

interface NameDetailPageContentProps {
name: Name;
name: NormalizedName;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original issue stated:

Refine typing of "downstream" name variables, ex: in ProfileHeader

and most recently:

As much as possible, we need to stop passing Name values around anywhere because they are a motherfucker of problems

}

export function NameDetailPageContent({ name }: NameDetailPageContentProps) {
Expand Down
20 changes: 20 additions & 0 deletions apps/ensadmin/src/app/name/_components/NameErrors.tsx
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm aware this is a new file, and not sure where this lives long term.

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ErrorInfo } from "@/components/error-info";

export function UnnormalizedNameError() {
return (
<section className="p-6">
<ErrorInfo title="Invalid Name" description="The provided name is not a valid ENS name." />
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UnnormalizedNameError is shown for any name that is neither a NormalizedName nor an InterpretedName, which includes names that are valid and normalizable but simply not normalized (e.g. VITALIK.ETH). In that case, the description "not a valid ENS name" is inaccurate/misleading. Consider distinguishing "valid but not normalized" (e.g., normalize(name) succeeds but differs) from "invalid" (normalization throws) and adjusting the message accordingly.

Suggested change
<ErrorInfo title="Invalid Name" description="The provided name is not a valid ENS name." />
<ErrorInfo
title="Unnormalized Name"
description="The provided name is not in normalized ENS form. Please use a normalized ENS name (for example, lowercase and properly formatted)."
/>

Copilot uses AI. Check for mistakes.
</section>
);
}

export function InterpretedNameUnsupportedError() {
return (
<section className="p-6">
<ErrorInfo
title="Encoded Labelhash Detected"
description="The provided name contains encoded labelhashes. Support for resolving names with encoded labelhashes is in progress and coming soon."
/>
</section>
);
}
Comment on lines +11 to +20
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider displaying the interpreted name in the error message.

Since InterpretedNameUnsupportedError is shown for valid InterpretedName values (with encoded labelhashes), you could accept a name: InterpretedName prop and display it using formatInterpretedNameForDisplay for better user context. This aligns with past review feedback suggesting <InterpretedNameUnsupportedError name={...} />.

💡 Optional enhancement
+import type { InterpretedName } from "@ensnode/ensnode-sdk";
+import { formatInterpretedNameForDisplay } from "@/lib/format-interpreted-name-for-display";
 import { ErrorInfo } from "@/components/error-info";

-export function InterpretedNameUnsupportedError() {
+export function InterpretedNameUnsupportedError({ name }: { name: InterpretedName }) {
   return (
     <section className="p-6">
       <ErrorInfo
         title="Encoded Labelhash Detected"
-        description="The provided name contains encoded labelhashes. Support for resolving names with encoded labelhashes is in progress and coming soon."
+        description={`The name "${formatInterpretedNameForDisplay(name)}" contains encoded labelhashes. Support for resolving names with encoded labelhashes is in progress and coming soon.`}
       />
     </section>
   );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ensadmin/src/app/name/_components/NameErrors.tsx` around lines 11 - 20,
Update the InterpretedNameUnsupportedError component to accept a prop name of
type InterpretedName and show the interpreted name in the error description
using formatInterpretedNameForDisplay; specifically, change the
InterpretedNameUnsupportedError signature to take { name: InterpretedName },
call formatInterpretedNameForDisplay(name) and include that string in the
ErrorInfo description (or add a separate field) so the rendered message shows
the actual interpreted name for context.

4 changes: 2 additions & 2 deletions apps/ensadmin/src/app/name/_components/ProfileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was changed because of the comments made about the downstream handling, and:

As much as possible, we need to stop passing Name values around anywhere because they are a motherfucker of problems

NameDetailPageContent only ever receives a validated normalized name from the detail page check (isNormalizedName(nameFromQuery)), so the prop type was tightened to reflect that guarantee

namespaceId: ENSNamespaceId;
headerImage?: string | null;
websiteUrl?: string | null;
Expand Down
68 changes: 52 additions & 16 deletions apps/ensadmin/src/app/name/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
coderabbitai[bot] marked this conversation as resolved.

import { getNameDetailsRelativePath, NameLink } from "@/components/name-links";
Expand All @@ -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<string[]> = {
const EXAMPLE_NAMES: NamespaceSpecificValue<NormalizedName[]> = {
default: [
"vitalik.eth",
"gregskril.eth",
Expand All @@ -34,15 +42,15 @@ const EXAMPLE_NAMES: NamespaceSpecificValue<string[]> = {
"lens.xyz",
"brantly.eth",
"lightwalker.eth",
],
] as NormalizedName[],
[ENSNamespaceIds.Sepolia]: [
"gregskril.eth",
"vitalik.eth",
"myens.eth",
"recordstest.eth",
"arrondesean.eth",
"decode.eth",
],
] as NormalizedName[],
[ENSNamespaceIds.EnsTestEnv]: [
"alias.eth",
"changerole.eth",
Expand All @@ -56,14 +64,15 @@ const EXAMPLE_NAMES: NamespaceSpecificValue<string[]> = {
"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;
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nameFromQuery is validated without trimming, so URLs like /name?name=vitalik.eth%20 (or whitespace-only values) will be treated as invalid and show an error. In this same app, the breadcrumbs page trims the query param before using it. Consider applying .trim() (and treating the empty result as null) before the isNormalizedName / isInterpretedName checks.

Suggested change
const nameFromQuery = searchParams.get("name") as Name | null;
const rawNameFromQuery = searchParams.get("name");
const nameFromQuery =
rawNameFromQuery && rawNameFromQuery.trim() !== ""
? (rawNameFromQuery.trim() as Name)
: null;

Copilot uses AI. Check for mistakes.
const [rawInputName, setRawInputName] = useState<Name>("");
const [formError, setFormError] = useState<string | null>(null);

const namespace = useActiveNamespace();
const exampleNames = useMemo(
Expand All @@ -75,23 +84,49 @@ export default function ExploreNamesPage() {

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
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);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to do more work here to clean this up, and move it.


switch (result.outcome) {
case NameInterpretationOutcomeResult.Empty:
break;
case NameInterpretationOutcomeResult.Normalized: {
const href = retainCurrentRawConnectionUrlParam(
Comment on lines +91 to +95
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whitespace-only input is treated as Empty (no-op) and the submit button is disabled based on rawInputName.trim(). That means an input like " " can't be submitted via the button and won't surface an "Invalid Name" error, which conflicts with issue #1140's acceptance criteria (it explicitly lists " " as an Invalid Name case). Consider disabling only for the truly empty string, and returning an invalid-name outcome (with an inline error) for whitespace-only input.

Copilot uses AI. Check for mistakes.
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<HTMLInputElement>) => {
e.preventDefault();

setFormError(null);
setRawInputName(e.target.value);
};

if (nameFromQuery) {
return <NameDetailPageContent name={nameFromQuery} />;
// Detail page: validate name from query params using only validation checks (no normalization).
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Detail page: validate name from query params using only validation checks (no normalization).

// see: https://github.com/namehash/ensnode/issues/1140
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// see: https://github.com/namehash/ensnode/issues/1140

Done, I can remove these TODos now.

if (nameFromQuery !== null && nameFromQuery !== "") {
if (isNormalizedName(nameFromQuery)) {
return <NameDetailPageContent name={nameFromQuery} />;
}

if (isInterpretedName(nameFromQuery)) {
return <InterpretedNameUnsupportedError />;
}

return <UnnormalizedNameError />;
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detail-page query-param validation currently treats a valid-but-unnormalized name (e.g. "VITALIK.ETH") the same as an invalid name and renders UnnormalizedNameError (with a "not a valid ENS name" message). This doesn’t match the intended behavior in #1140/#1059, where unnormalized-but-normalizable names should show an explicit "not ENS normalized" error distinct from truly invalid input. Consider adding a separate branch to detect “normalizable but not normalized” (e.g. via normalize(nameFromQuery) in a try/catch) and render a dedicated error state/message for that case.

Suggested change
return <UnnormalizedNameError />;
// Distinguish between names that are normalizable-but-unnormalized and
// inputs that are truly invalid.
const interpretationResult = interpretNameFromUserInput(nameFromQuery);
if (interpretationResult.outcome === NameInterpretationOutcomeResult.Normalized) {
// The name can be normalized but isn't in normalized form yet.
// Show a dedicated "not ENS normalized" error state.
return <UnnormalizedNameError />;
}
// For all other outcomes, treat the input as invalid and show a
// "not a valid ENS name" message.
return (
<section className="flex flex-col gap-6 p-6">
<Card className="w-full">
<CardHeader className="sm:pb-4 max-sm:p-3">
<CardTitle className="text-2xl">Explore ENS Names</CardTitle>
</CardHeader>
<CardContent className="max-sm:px-3 max-sm:pb-3">
<p className="text-sm text-red-600">
The provided input is not a valid ENS name.
</p>
</CardContent>
</Card>
</section>
);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@vercel vercel Bot Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Query parameter validation doesn't distinguish between unnormalized-but-normalizable names and truly invalid names, both showing the same generic "Invalid Name" error

Fix on Vercel

}

return (
Expand All @@ -115,12 +150,13 @@ export default function ExploreNamesPage() {
/>
<Button
type="submit"
disabled={rawInputName.length === 0}
disabled={rawInputName.trim().length === 0}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lightwalker-eth I added trim() here because I was seeing the View Profile button become active, and I think it's better it remains disabled. Empty characters are validated again but the disabled button is only checking for length.

Image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@notrab In my mind we make "Empty" a special class of the interpretation outcome and then build logic such as disabling a "View Profile" button based on the "Empty" class of interpretation outcomes.

className="max-sm:self-stretch"
>
View Profile
</Button>
</fieldset>
{formError && <p className="text-sm text-red-600">{formError}</p>}
</form>
<div className="flex flex-col gap-2 justify-center">
<p className="text-sm font-medium leading-none">Examples:</p>
Expand Down
164 changes: 164 additions & 0 deletions apps/ensadmin/src/lib/interpret-name-from-user-input.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
});
Loading
Loading