From 59a4c2f1f3ea9c996ceca68160ea3541a3b1dcb5 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 2 Oct 2025 13:46:08 +0100 Subject: [PATCH 01/73] feat: useAvatarUrl with manual fallback --- apps/ensadmin/src/components/ens-avatar.tsx | 14 +- packages/ensnode-react/src/hooks/index.ts | 1 + .../ensnode-react/src/hooks/useAvatarUrl.ts | 163 ++++++++++++++++++ 3 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 packages/ensnode-react/src/hooks/useAvatarUrl.ts diff --git a/apps/ensadmin/src/components/ens-avatar.tsx b/apps/ensadmin/src/components/ens-avatar.tsx index 101f21e3d..40ed84c4d 100644 --- a/apps/ensadmin/src/components/ens-avatar.tsx +++ b/apps/ensadmin/src/components/ens-avatar.tsx @@ -3,6 +3,7 @@ import { Avatar, AvatarImage } from "@/components/ui/avatar"; import { buildEnsMetadataServiceAvatarUrl } from "@/lib/namespace-utils"; import { ENSNamespaceId } from "@ensnode/datasources"; +import { useAvatarUrl } from "@ensnode/ensnode-react"; import { Name } from "@ensnode/ensnode-sdk"; import BoringAvatar from "boring-avatars"; import * as React from "react"; @@ -19,9 +20,16 @@ type ImageLoadingStatus = Parameters< export const EnsAvatar = ({ name, namespaceId, className }: EnsAvatarProps) => { const [loadingStatus, setLoadingStatus] = React.useState("idle"); - const avatarUrl = buildEnsMetadataServiceAvatarUrl(name, namespaceId); - if (avatarUrl === null) { + const { data: avatarUrl } = useAvatarUrl({ + name, + fallback: async (name) => { + const url = buildEnsMetadataServiceAvatarUrl(name, namespaceId); + return url?.toString() ?? null; + }, + }); + + if (avatarUrl === null || avatarUrl === undefined) { return ( @@ -32,7 +40,7 @@ export const EnsAvatar = ({ name, namespaceId, className }: EnsAvatarProps) => { return ( { setLoadingStatus(status); diff --git a/packages/ensnode-react/src/hooks/index.ts b/packages/ensnode-react/src/hooks/index.ts index 65af7d7c6..19955ae81 100644 --- a/packages/ensnode-react/src/hooks/index.ts +++ b/packages/ensnode-react/src/hooks/index.ts @@ -4,3 +4,4 @@ export * from "./usePrimaryName"; export * from "./usePrimaryNames"; export * from "./useENSIndexerConfig"; export * from "./useIndexingStatus"; +export * from "./useAvatarUrl"; diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts new file mode 100644 index 000000000..abb0fb4ce --- /dev/null +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -0,0 +1,163 @@ +"use client"; + +import type { Name } from "@ensnode/ensnode-sdk"; +import { useQuery } from "@tanstack/react-query"; + +import type { ConfigParameter, QueryParameter } from "../types"; +import { useENSNodeConfig } from "./useENSNodeConfig"; +import { useRecords } from "./useRecords"; + +/** + * Parameters for the useAvatarUrl hook. + * + * If `name` is null, the query will not be executed. + */ +export interface UseAvatarUrlParameters extends QueryParameter, ConfigParameter { + name: Name | null; + /** + * Optional fallback function to get avatar URL when the avatar text record + * uses a complex protocol (not http/https). + * + * This allows consumers to provide their own fallback strategy, such as + * using the ENS Metadata Service or other avatar resolution services. + * + * @param name - The ENS name to get the avatar URL for + * @returns Promise resolving to the avatar URL, or null if unavailable + */ + fallback?: (name: Name) => Promise; +} + +/** + * Normalizes a website URL by ensuring it has a valid protocol. + * + * @param url - The URL string to normalize + * @returns A URL object if the input is valid, null otherwise + * + * @example + * ```typescript + * normalizeWebsiteUrl("example.com") // Returns URL with https://example.com + * normalizeWebsiteUrl("http://example.com") // Returns URL with http://example.com + * normalizeWebsiteUrl("invalid url") // Returns null + * ``` + */ +function normalizeWebsiteUrl(url: string | null | undefined): URL | null { + if (!url) return null; + + try { + // Try to parse as-is first + try { + return new URL(url); + } catch { + // If that fails, try adding https:// prefix + return new URL(`https://${url}`); + } + } catch { + return null; + } +} + +/** + * Resolves the avatar URL for an ENS name. + * + * This hook attempts to get the avatar URL by: + * 1. Fetching the avatar text record using useRecords + * 2. Normalizing the avatar text record as a URL + * 3. Returning the URL if it uses http or https protocol + * 4. Falling back to a custom fallback function if provided for other protocols + * + * @param parameters - Configuration for the avatar URL resolution + * @returns Query result with the avatar URL, loading state, and error handling + * + * @example + * ```typescript + * import { useAvatarUrl } from "@ensnode/ensnode-react"; + * + * function ProfileAvatar() { + * const { data: avatarUrl, isLoading, error } = useAvatarUrl({ + * name: "vitalik.eth" + * }); + * + * if (isLoading) return
Loading...
; + * if (error) return
Error: {error.message}
; + * if (!avatarUrl) return
No avatar
; + * + * return Avatar; + * } + * ``` + * + * @example + * ```typescript + * // With ENS Metadata Service fallback + * import { useAvatarUrl } from "@ensnode/ensnode-react"; + * + * function ProfileAvatar() { + * const { data: avatarUrl } = useAvatarUrl({ + * name: "vitalik.eth", + * fallback: async (name) => { + * // Custom fallback logic for IPFS, NFT URIs, etc. + * return `https://metadata.ens.domains/mainnet/avatar/${name}`; + * } + * }); + * + * return avatarUrl ? Avatar : null; + * } + * ``` + */ +export function useAvatarUrl(parameters: UseAvatarUrlParameters) { + const { name, config, query: queryOptions, fallback } = parameters; + const _config = useENSNodeConfig(config); + + const canEnable = name !== null; + + // First, get the avatar text record + const recordsQuery = useRecords({ + name, + selection: { texts: ["avatar"] }, + config: _config, + query: { enabled: canEnable }, + }); + + // Then process the avatar URL + return useQuery({ + queryKey: ["avatarUrl", name, _config.client.url.href, !!fallback], + queryFn: async (): Promise => { + if (!name || !recordsQuery.data) return null; + + // Get avatar text record from useRecords result + const avatarTextRecord = recordsQuery.data.records?.texts?.avatar; + + // If no avatar text record, return null + if (!avatarTextRecord) { + return null; + } + + // Try to normalize the avatar URL + const normalizedUrl = normalizeWebsiteUrl(avatarTextRecord); + + // If normalization failed, return null + if (!normalizedUrl) { + return null; + } + + // If the URL uses http or https protocol, return it + if (normalizedUrl.protocol === "http:" || normalizedUrl.protocol === "https:") { + return normalizedUrl.toString(); + } + + // For other protocols (ipfs, data, NFT URIs, etc.), use fallback if provided + if (fallback) { + try { + return await fallback(name); + } catch { + return null; + } + } + + // No fallback available + return null; + }, + enabled: canEnable && recordsQuery.isSuccess, + retry: false, + ...queryOptions, + }); +} From dfbc7e2c137edb3b1e773eee583086ae8a83c4d1 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 2 Oct 2025 15:47:32 +0100 Subject: [PATCH 02/73] Update packages/ensnode-react/src/hooks/useAvatarUrl.ts Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index abb0fb4ce..cfd78ebce 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -19,7 +19,7 @@ export interface UseAvatarUrlParameters extends QueryParameter, C * uses a complex protocol (not http/https). * * This allows consumers to provide their own fallback strategy, such as - * using the ENS Metadata Service or other avatar resolution services. + * using the ENS Metadata Service or other avatar resolution proxy services. * * @param name - The ENS name to get the avatar URL for * @returns Promise resolving to the avatar URL, or null if unavailable From a584559ce9b27f9817fdf3d7f372f3e18c346063 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 2 Oct 2025 16:56:17 +0100 Subject: [PATCH 03/73] feat: useAvatarUrl with fallback (#1143) --- .../name/[name]/_components/ProfileHeader.tsx | 6 +-- apps/ensadmin/src/components/ens-avatar.tsx | 9 +---- .../src/components/identity/index.tsx | 2 +- .../use-ens-metadata-service-avatar-url.ts | 3 +- apps/ensadmin/src/lib/namespace-utils.ts | 33 ---------------- packages/ensnode-react/package.json | 3 +- .../ensnode-react/src/hooks/useAvatarUrl.ts | 38 +++++++++++++------ packages/ensnode-sdk/src/ens/index.ts | 1 + .../ensnode-sdk/src/ens/metadata-service.ts | 37 ++++++++++++++++++ pnpm-lock.yaml | 3 ++ 10 files changed, 74 insertions(+), 61 deletions(-) create mode 100644 packages/ensnode-sdk/src/ens/metadata-service.ts diff --git a/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx b/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx index d80797304..47cd6e8d1 100644 --- a/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx +++ b/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx @@ -68,11 +68,7 @@ export function ProfileHeader({ name, headerImage, websiteUrl }: ProfileHeaderPr
- +

diff --git a/apps/ensadmin/src/components/ens-avatar.tsx b/apps/ensadmin/src/components/ens-avatar.tsx index 40ed84c4d..aacab9881 100644 --- a/apps/ensadmin/src/components/ens-avatar.tsx +++ b/apps/ensadmin/src/components/ens-avatar.tsx @@ -1,8 +1,6 @@ "use client"; import { Avatar, AvatarImage } from "@/components/ui/avatar"; -import { buildEnsMetadataServiceAvatarUrl } from "@/lib/namespace-utils"; -import { ENSNamespaceId } from "@ensnode/datasources"; import { useAvatarUrl } from "@ensnode/ensnode-react"; import { Name } from "@ensnode/ensnode-sdk"; import BoringAvatar from "boring-avatars"; @@ -10,7 +8,6 @@ import * as React from "react"; interface EnsAvatarProps { name: Name; - namespaceId: ENSNamespaceId; className?: string; } @@ -18,15 +15,11 @@ type ImageLoadingStatus = Parameters< NonNullable["onLoadingStatusChange"]> >[0]; -export const EnsAvatar = ({ name, namespaceId, className }: EnsAvatarProps) => { +export const EnsAvatar = ({ name, className }: EnsAvatarProps) => { const [loadingStatus, setLoadingStatus] = React.useState("idle"); const { data: avatarUrl } = useAvatarUrl({ name, - fallback: async (name) => { - const url = buildEnsMetadataServiceAvatarUrl(name, namespaceId); - return url?.toString() ?? null; - }, }); if (avatarUrl === null || avatarUrl === undefined) { diff --git a/apps/ensadmin/src/components/identity/index.tsx b/apps/ensadmin/src/components/identity/index.tsx index 4b50547d9..e379bf787 100644 --- a/apps/ensadmin/src/components/identity/index.tsx +++ b/apps/ensadmin/src/components/identity/index.tsx @@ -71,7 +71,7 @@ export function Identity({ name={ensName} className="inline-flex items-center gap-2 text-blue-600 hover:underline" > - {showAvatar && } + {showAvatar && } ); diff --git a/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts b/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts index d71a9f966..879771dfe 100644 --- a/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts +++ b/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts @@ -1,10 +1,9 @@ "use client"; +import { buildEnsMetadataServiceAvatarUrl } from "@ensnode/ensnode-react"; import { Name } from "@ensnode/ensnode-sdk"; import { useQuery } from "@tanstack/react-query"; -import { buildEnsMetadataServiceAvatarUrl } from "@/lib/namespace-utils"; - import { useNamespace } from "./use-namespace"; export interface UseEnsMetadataServiceAvatarUrlParameters { diff --git a/apps/ensadmin/src/lib/namespace-utils.ts b/apps/ensadmin/src/lib/namespace-utils.ts index fd6f4bbe7..8e72c440f 100644 --- a/apps/ensadmin/src/lib/namespace-utils.ts +++ b/apps/ensadmin/src/lib/namespace-utils.ts @@ -91,39 +91,6 @@ export function getEnsManagerAppUrl(namespaceId: ENSNamespaceId): URL | null { } } -/** - * Build the avatar image URL for a name on the given ENS Namespace that (once fetched) would - * load the avatar image for the given name from the ENS Metadata Service - * (https://metadata.ens.domains/docs). - * - * The returned URL is dynamically built based on the provided ENS namespace. Not all ENS - * namespaces are supported by the ENS Metadata Service. Therefore, the returned URL may - * be null. - * - * @param {Name} name - ENS name to build the avatar image URL for - * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier - * @returns avatar image URL for the name on the given ENS Namespace, or null if the given - * ENS namespace is not supported by the ENS Metadata Service - */ -export function buildEnsMetadataServiceAvatarUrl( - name: Name, - namespaceId: ENSNamespaceId, -): URL | null { - switch (namespaceId) { - case ENSNamespaceIds.Mainnet: - return new URL(name, `https://metadata.ens.domains/mainnet/avatar/`); - case ENSNamespaceIds.Sepolia: - return new URL(name, `https://metadata.ens.domains/sepolia/avatar/`); - case ENSNamespaceIds.Holesky: - // metadata.ens.domains doesn't currently support holesky - return null; - case ENSNamespaceIds.EnsTestEnv: - // ens-test-env runs on a local chain and is not supported by metadata.ens.domains - // TODO: Above comment is not true. Details at https://github.com/namehash/ensnode/issues/1078 - return null; - } -} - /** * Builds the URL of the external ENS Manager App Profile page for a given name and ENS Namespace. * diff --git a/packages/ensnode-react/package.json b/packages/ensnode-react/package.json index 416deab5a..1cbd76f94 100644 --- a/packages/ensnode-react/package.json +++ b/packages/ensnode-react/package.json @@ -60,6 +60,7 @@ "vitest": "catalog:" }, "dependencies": { - "@ensnode/ensnode-sdk": "workspace:*" + "@ensnode/ensnode-sdk": "workspace:*", + "@ensnode/datasources": "workspace:*" } } diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index cfd78ebce..afbecacda 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -1,9 +1,10 @@ "use client"; -import type { Name } from "@ensnode/ensnode-sdk"; +import { type Name, buildEnsMetadataServiceAvatarUrl } from "@ensnode/ensnode-sdk"; import { useQuery } from "@tanstack/react-query"; import type { ConfigParameter, QueryParameter } from "../types"; +import { useENSIndexerConfig } from "./useENSIndexerConfig"; import { useENSNodeConfig } from "./useENSNodeConfig"; import { useRecords } from "./useRecords"; @@ -15,11 +16,10 @@ import { useRecords } from "./useRecords"; export interface UseAvatarUrlParameters extends QueryParameter, ConfigParameter { name: Name | null; /** - * Optional fallback function to get avatar URL when the avatar text record + * Optional custom fallback function to get avatar URL when the avatar text record * uses a complex protocol (not http/https). * - * This allows consumers to provide their own fallback strategy, such as - * using the ENS Metadata Service or other avatar resolution proxy services. + * If not provided, defaults to using the ENS Metadata Service. * * @param name - The ENS name to get the avatar URL for * @returns Promise resolving to the avatar URL, or null if unavailable @@ -63,7 +63,7 @@ function normalizeWebsiteUrl(url: string | null | undefined): URL | null { * 1. Fetching the avatar text record using useRecords * 2. Normalizing the avatar text record as a URL * 3. Returning the URL if it uses http or https protocol - * 4. Falling back to a custom fallback function if provided for other protocols + * 4. Falling back to the ENS Metadata Service (default) or custom fallback for other protocols * * @param parameters - Configuration for the avatar URL resolution * @returns Query result with the avatar URL, loading state, and error handling @@ -87,7 +87,7 @@ function normalizeWebsiteUrl(url: string | null | undefined): URL | null { * * @example * ```typescript - * // With ENS Metadata Service fallback + * // With custom fallback * import { useAvatarUrl } from "@ensnode/ensnode-react"; * * function ProfileAvatar() { @@ -95,7 +95,7 @@ function normalizeWebsiteUrl(url: string | null | undefined): URL | null { * name: "vitalik.eth", * fallback: async (name) => { * // Custom fallback logic for IPFS, NFT URIs, etc. - * return `https://metadata.ens.domains/mainnet/avatar/${name}`; + * return `https://custom-resolver.example.com/${name}`; * } * }); * @@ -117,9 +117,25 @@ export function useAvatarUrl(parameters: UseAvatarUrlParameters) { query: { enabled: canEnable }, }); + // Get namespace from config + const configQuery = useENSIndexerConfig({ config: _config }); + const namespaceId = configQuery.data?.namespace ?? null; + + // Create default fallback using ENS Metadata Service if namespaceId is available + const defaultFallback = + namespaceId !== null && namespaceId !== undefined + ? async (name: Name) => { + const url = buildEnsMetadataServiceAvatarUrl(name, namespaceId); + return url?.toString() ?? null; + } + : undefined; + + // Use custom fallback if provided, otherwise use default + const activeFallback = fallback ?? defaultFallback; + // Then process the avatar URL return useQuery({ - queryKey: ["avatarUrl", name, _config.client.url.href, !!fallback], + queryKey: ["avatarUrl", name, _config.client.url.href, namespaceId, !!fallback], queryFn: async (): Promise => { if (!name || !recordsQuery.data) return null; @@ -144,10 +160,10 @@ export function useAvatarUrl(parameters: UseAvatarUrlParameters) { return normalizedUrl.toString(); } - // For other protocols (ipfs, data, NFT URIs, etc.), use fallback if provided - if (fallback) { + // For other protocols (ipfs, data, NFT URIs, etc.), use fallback if available + if (activeFallback) { try { - return await fallback(name); + return await activeFallback(name); } catch { return null; } diff --git a/packages/ensnode-sdk/src/ens/index.ts b/packages/ensnode-sdk/src/ens/index.ts index ebf78beaa..eb69069de 100644 --- a/packages/ensnode-sdk/src/ens/index.ts +++ b/packages/ensnode-sdk/src/ens/index.ts @@ -8,3 +8,4 @@ export * from "./parse-reverse-name"; export * from "./is-normalized"; export * from "./encode-labelhash"; export * from "./dns-encoded-name"; +export * from "./metadata-service"; diff --git a/packages/ensnode-sdk/src/ens/metadata-service.ts b/packages/ensnode-sdk/src/ens/metadata-service.ts new file mode 100644 index 000000000..8830f1393 --- /dev/null +++ b/packages/ensnode-sdk/src/ens/metadata-service.ts @@ -0,0 +1,37 @@ +import type { ENSNamespaceId } from "@ensnode/datasources"; +import { ENSNamespaceIds } from "@ensnode/datasources"; + +import type { Name } from "./types"; + +/** + * Build the avatar image URL for a name on the given ENS Namespace that (once fetched) would + * load the avatar image for the given name from the ENS Metadata Service + * (https://metadata.ens.domains/docs). + * + * The returned URL is dynamically built based on the provided ENS namespace. Not all ENS + * namespaces are supported by the ENS Metadata Service. Therefore, the returned URL may + * be null. + * + * @param {Name} name - ENS name to build the avatar image URL for + * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier + * @returns avatar image URL for the name on the given ENS Namespace, or null if the given + * ENS namespace is not supported by the ENS Metadata Service + */ +export function buildEnsMetadataServiceAvatarUrl( + name: Name, + namespaceId: ENSNamespaceId, +): URL | null { + switch (namespaceId) { + case ENSNamespaceIds.Mainnet: + return new URL(name, `https://metadata.ens.domains/mainnet/avatar/`); + case ENSNamespaceIds.Sepolia: + return new URL(name, `https://metadata.ens.domains/sepolia/avatar/`); + case ENSNamespaceIds.Holesky: + // metadata.ens.domains doesn't currently support holesky + return null; + case ENSNamespaceIds.EnsTestEnv: + // ens-test-env runs on a local chain and is not supported by metadata.ens.domains + // TODO: Above comment is not true. Details at https://github.com/namehash/ensnode/issues/1078 + return null; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94745158d..0c33f3234 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -601,6 +601,9 @@ importers: packages/ensnode-react: dependencies: + '@ensnode/datasources': + specifier: workspace:* + version: link:../datasources '@ensnode/ensnode-sdk': specifier: workspace:* version: link:../ensnode-sdk From 4251e4048f82ff778f2829e193c2addf51da2fab Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 2 Oct 2025 17:08:53 +0100 Subject: [PATCH 04/73] apply feedback --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index afbecacda..95706aa42 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -8,6 +8,12 @@ import { useENSIndexerConfig } from "./useENSIndexerConfig"; import { useENSNodeConfig } from "./useENSNodeConfig"; import { useRecords } from "./useRecords"; +/** + * Type alias for avatar URLs. Avatar URLs must be URLs to an image asset + * accessible via the http or https protocol. + */ +export type AvatarUrl = URL; + /** * Parameters for the useAvatarUrl hook. * @@ -17,7 +23,7 @@ export interface UseAvatarUrlParameters extends QueryParameter, C name: Name | null; /** * Optional custom fallback function to get avatar URL when the avatar text record - * uses a complex protocol (not http/https). + * uses a non-http/https protocol. * * If not provided, defaults to using the ENS Metadata Service. * @@ -28,19 +34,20 @@ export interface UseAvatarUrlParameters extends QueryParameter, C } /** - * Normalizes a website URL by ensuring it has a valid protocol. + * Normalizes an avatar URL by ensuring it has a valid protocol. + * Avatar URLs should be URLs to an image asset accessible via the http or https protocol. * * @param url - The URL string to normalize * @returns A URL object if the input is valid, null otherwise * * @example * ```typescript - * normalizeWebsiteUrl("example.com") // Returns URL with https://example.com - * normalizeWebsiteUrl("http://example.com") // Returns URL with http://example.com - * normalizeWebsiteUrl("invalid url") // Returns null + * normalizeAvatarUrl("example.com/avatar.png") // Returns URL with https://example.com/avatar.png + * normalizeAvatarUrl("http://example.com/avatar.png") // Returns URL with http://example.com/avatar.png + * normalizeAvatarUrl("invalid url") // Returns null * ``` */ -function normalizeWebsiteUrl(url: string | null | undefined): URL | null { +function normalizeAvatarUrl(url: string | null | undefined): AvatarUrl | null { if (!url) return null; try { @@ -79,7 +86,7 @@ function normalizeWebsiteUrl(url: string | null | undefined): URL | null { * * if (isLoading) return
Loading...
; * if (error) return
Error: {error.message}
; - * if (!avatarUrl) return
No avatar
; + * if (!avatarUrl) return
No avatar url configured or avatar url configuration is invalid
; * * return Avatar; * } @@ -88,14 +95,15 @@ function normalizeWebsiteUrl(url: string | null | undefined): URL | null { * @example * ```typescript * // With custom fallback - * import { useAvatarUrl } from "@ensnode/ensnode-react"; + * import { useAvatarUrl, buildEnsMetadataServiceAvatarUrl } from "@ensnode/ensnode-react"; * * function ProfileAvatar() { * const { data: avatarUrl } = useAvatarUrl({ * name: "vitalik.eth", * fallback: async (name) => { - * // Custom fallback logic for IPFS, NFT URIs, etc. - * return `https://custom-resolver.example.com/${name}`; + * // Use the ENS Metadata Service for the current namespace + * const url = buildEnsMetadataServiceAvatarUrl(name, namespaceId); + * return url?.toString() ?? null; * } * }); * @@ -148,7 +156,7 @@ export function useAvatarUrl(parameters: UseAvatarUrlParameters) { } // Try to normalize the avatar URL - const normalizedUrl = normalizeWebsiteUrl(avatarTextRecord); + const normalizedUrl = normalizeAvatarUrl(avatarTextRecord); // If normalization failed, return null if (!normalizedUrl) { From 018f43d82684b7f9ab7b095faa14b3871b96aab4 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 5 Oct 2025 17:36:19 +0100 Subject: [PATCH 05/73] move buildUrl --- apps/ensadmin/src/lib/url-utils.ts | 19 +- .../ensnode-react/src/hooks/useAvatarUrl.ts | 10 +- packages/ensnode-sdk/src/shared/url.test.ts | 268 ++++++++++++++++++ packages/ensnode-sdk/src/shared/url.ts | 19 ++ 4 files changed, 290 insertions(+), 26 deletions(-) create mode 100644 packages/ensnode-sdk/src/shared/url.test.ts diff --git a/apps/ensadmin/src/lib/url-utils.ts b/apps/ensadmin/src/lib/url-utils.ts index 7e63c70d6..af5e02f29 100644 --- a/apps/ensadmin/src/lib/url-utils.ts +++ b/apps/ensadmin/src/lib/url-utils.ts @@ -1,21 +1,4 @@ -/** - * Builds a `URL` from the given string. - * - * If no explicit protocol found in `rawUrl` assumes an implicit - * 'https://' default protocol. - * - * @param rawUrl a string that may be in the format of a `URL`. - * @returns a `URL` object for the given `rawUrl`. - * @throws if `rawUrl` cannot be converted to a `URL`. - */ -const buildUrl = (rawUrl: string): URL => { - if (!rawUrl.includes("://")) { - // no explicit protocol found in `rawUrl`, assume implicit https:// protocol - rawUrl = `https://${rawUrl}`; - } - - return new URL(rawUrl); -}; +import { buildUrl } from "@ensnode/ensnode-sdk"; /** * Invariants: diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 95706aa42..c8ba6c0e5 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -1,6 +1,6 @@ "use client"; -import { type Name, buildEnsMetadataServiceAvatarUrl } from "@ensnode/ensnode-sdk"; +import { type Name, buildEnsMetadataServiceAvatarUrl, buildUrl } from "@ensnode/ensnode-sdk"; import { useQuery } from "@tanstack/react-query"; import type { ConfigParameter, QueryParameter } from "../types"; @@ -51,13 +51,7 @@ function normalizeAvatarUrl(url: string | null | undefined): AvatarUrl | null { if (!url) return null; try { - // Try to parse as-is first - try { - return new URL(url); - } catch { - // If that fails, try adding https:// prefix - return new URL(`https://${url}`); - } + return buildUrl(url); } catch { return null; } diff --git a/packages/ensnode-sdk/src/shared/url.test.ts b/packages/ensnode-sdk/src/shared/url.test.ts new file mode 100644 index 000000000..e1c35a882 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/url.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from "vitest"; +import { buildUrl } from "./url"; + +describe("buildUrl", () => { + describe("explicit protocol handling", () => { + it("accepts URLs with explicit HTTPS protocol", () => { + const result = buildUrl("https://example.com"); + + expect(result.protocol).toBe("https:"); + expect(result.hostname).toBe("example.com"); + expect(result.port).toBe(""); + }); + + it("accepts URLs with explicit HTTP protocol", () => { + const result = buildUrl("http://example.com"); + + expect(result.protocol).toBe("http:"); + expect(result.hostname).toBe("example.com"); + expect(result.port).toBe(""); + }); + + it("accepts URLs with other protocols", () => { + const testCases = [ + { url: "ftp://example.com", protocol: "ftp:" }, + { url: "ws://example.com", protocol: "ws:" }, + { url: "wss://example.com", protocol: "wss:" }, + ]; + + testCases.forEach(({ url, protocol }) => { + const result = buildUrl(url); + expect(result.protocol).toBe(protocol); + expect(result.hostname).toBe("example.com"); + }); + }); + + it("handles case sensitivity for protocols", () => { + const testCases = ["HTTP://example.com", "HTTPS://example.com", "Http://example.com"]; + + testCases.forEach((url) => { + const result = buildUrl(url); + expect(result.hostname).toBe("example.com"); + }); + }); + }); + + describe("implicit HTTPS protocol", () => { + it("adds implicit HTTPS protocol when no protocol is provided", () => { + const result = buildUrl("example.com"); + + expect(result.protocol).toBe("https:"); + expect(result.hostname).toBe("example.com"); + }); + + it("adds implicit HTTPS protocol for localhost", () => { + const result = buildUrl("localhost:3000"); + + expect(result.protocol).toBe("https:"); + expect(result.hostname).toBe("localhost"); + expect(result.port).toBe("3000"); + }); + + it("adds implicit HTTPS protocol for URLs with ports", () => { + const result = buildUrl("api.example.com:8080"); + + expect(result.protocol).toBe("https:"); + expect(result.hostname).toBe("api.example.com"); + expect(result.port).toBe("8080"); + }); + + it("adds implicit HTTPS protocol for URLs with paths", () => { + const result = buildUrl("example.com/path/to/resource"); + + expect(result.protocol).toBe("https:"); + expect(result.hostname).toBe("example.com"); + expect(result.pathname).toBe("/path/to/resource"); + }); + + it("adds implicit HTTPS protocol for URLs with query params", () => { + const result = buildUrl("example.com?query=value"); + + expect(result.protocol).toBe("https:"); + expect(result.hostname).toBe("example.com"); + expect(result.search).toBe("?query=value"); + }); + + it("adds implicit HTTPS protocol for URLs with hash fragments", () => { + const result = buildUrl("example.com#section"); + + expect(result.protocol).toBe("https:"); + expect(result.hostname).toBe("example.com"); + expect(result.hash).toBe("#section"); + }); + }); + + describe("URLs with ports", () => { + it("accepts URLs with explicit ports", () => { + const result = buildUrl("https://example.com:8080"); + + expect(result.protocol).toBe("https:"); + expect(result.hostname).toBe("example.com"); + expect(result.port).toBe("8080"); + }); + + it("accepts URLs with various port numbers", () => { + const testCases = [ + "https://example.com:80", + "https://example.com:443", + "https://example.com:3000", + "https://example.com:8080", + "http://example.com:80", + ]; + + testCases.forEach((url) => { + const result = buildUrl(url); + expect(result.hostname).toBe("example.com"); + }); + }); + + it("accepts URLs with trailing colon (empty port)", () => { + const result = buildUrl("https://example.com:"); + + expect(result.hostname).toBe("example.com"); + expect(result.port).toBe(""); + }); + }); + + describe("localhost URLs", () => { + it("accepts localhost URLs with explicit protocol", () => { + const testCases = ["http://localhost", "https://localhost"]; + + testCases.forEach((url) => { + const result = buildUrl(url); + expect(result.hostname).toBe("localhost"); + }); + }); + + it("accepts localhost URLs with ports", () => { + const testCases = ["http://localhost:3000", "https://localhost:8080"]; + + testCases.forEach((url) => { + const result = buildUrl(url); + expect(result.hostname).toBe("localhost"); + }); + }); + }); + + describe("URLs with paths, query params, and hash fragments", () => { + it("accepts URLs with paths", () => { + const result = buildUrl("https://example.com/path/to/resource"); + + expect(result.pathname).toBe("/path/to/resource"); + }); + + it("accepts URLs with query parameters", () => { + const result = buildUrl("https://example.com?param=value&other=test"); + + expect(result.search).toBe("?param=value&other=test"); + }); + + it("accepts URLs with hash fragments", () => { + const result = buildUrl("https://example.com#section"); + + expect(result.hash).toBe("#section"); + }); + + it("accepts URLs with all components", () => { + const result = buildUrl("https://api.example.com:8080/path?query=value#anchor"); + + expect(result.protocol).toBe("https:"); + expect(result.hostname).toBe("api.example.com"); + expect(result.port).toBe("8080"); + expect(result.pathname).toBe("/path"); + expect(result.search).toBe("?query=value"); + expect(result.hash).toBe("#anchor"); + }); + }); + + describe("IP addresses", () => { + it("accepts URLs with IPv4 addresses", () => { + const testCases = [ + "http://192.168.1.1", + "https://192.168.1.1:8080", + "http://127.0.0.1", + "https://127.0.0.1:3000", + ]; + + testCases.forEach((url) => { + const result = buildUrl(url); + expect(result.protocol).toMatch(/^https?:$/); + }); + }); + + it("adds implicit HTTPS protocol for IP addresses without protocol", () => { + const result = buildUrl("192.168.1.1:8080"); + + expect(result.protocol).toBe("https:"); + expect(result.hostname).toBe("192.168.1.1"); + expect(result.port).toBe("8080"); + }); + }); + + describe("error handling", () => { + it("throws for invalid URLs", () => { + const testCases = ["://example.com", "https://", "http://"]; + + testCases.forEach((url) => { + expect(() => buildUrl(url)).toThrow(); + }); + }); + + it("throws for empty string", () => { + expect(() => buildUrl("")).toThrow(); + }); + + it("throws for whitespace-only strings", () => { + const testCases = [" ", " ", "\t", "\n"]; + + testCases.forEach((url) => { + expect(() => buildUrl(url)).toThrow(); + }); + }); + + it("throws for malformed port numbers", () => { + const testCases = [ + "https://example.com:99999", // Port out of range + "https://example.com:-1", // Negative port + "https://example.com:abc", // Non-numeric port + ]; + + testCases.forEach((url) => { + expect(() => buildUrl(url)).toThrow(); + }); + }); + }); + + describe("edge cases", () => { + it("handles URLs with subdomains", () => { + const testCases = [ + "https://api.example.com", + "https://www.example.com", + "https://sub.domain.example.com", + ]; + + testCases.forEach((url) => { + const result = buildUrl(url); + expect(result.protocol).toBe("https:"); + }); + }); + + it("handles internationalized domain names", () => { + const testCases = ["https://测试.com", "https://пример.рф", "https://münchen.de"]; + + testCases.forEach((url) => { + const result = buildUrl(url); + expect(result.hostname).toContain("."); + }); + }); + + it("handles URLs with special characters in hostname", () => { + const testCases = ["https://test-site.com", "https://test_site.com", "https://test123.com"]; + + testCases.forEach((url) => { + const result = buildUrl(url); + expect(result.protocol).toBe("https:"); + }); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/shared/url.ts b/packages/ensnode-sdk/src/shared/url.ts index 1cb98a59f..ed9acdaf0 100644 --- a/packages/ensnode-sdk/src/shared/url.ts +++ b/packages/ensnode-sdk/src/shared/url.ts @@ -1,3 +1,22 @@ +/** + * Builds a `URL` from the given string. + * + * If no explicit protocol found in `rawUrl` assumes an implicit + * 'https://' default protocol. + * + * @param rawUrl a string that may be in the format of a `URL`. + * @returns a `URL` object for the given `rawUrl`. + * @throws if `rawUrl` cannot be converted to a `URL`. + */ +export function buildUrl(rawUrl: string): URL { + if (!rawUrl.includes("://")) { + // no explicit protocol found in `rawUrl`, assume implicit https:// protocol + rawUrl = `https://${rawUrl}`; + } + + return new URL(rawUrl); +} + export function isHttpProtocol(url: URL): boolean { return ["http:", "https:"].includes(url.protocol); } From 40192ae2c491fa3ebb033846368b19f6f704d2c6 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 5 Oct 2025 17:41:44 +0100 Subject: [PATCH 06/73] apply feedback --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index c8ba6c0e5..1da7a0b74 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -23,14 +23,14 @@ export interface UseAvatarUrlParameters extends QueryParameter, C name: Name | null; /** * Optional custom fallback function to get avatar URL when the avatar text record - * uses a non-http/https protocol. + * uses a non-http/https protocol (e.g., ipfs://, ar://, eip155:/). * * If not provided, defaults to using the ENS Metadata Service. * * @param name - The ENS name to get the avatar URL for * @returns Promise resolving to the avatar URL, or null if unavailable */ - fallback?: (name: Name) => Promise; + browserUnsupportedProtocolFallback?: (name: Name) => Promise; } /** @@ -94,7 +94,7 @@ function normalizeAvatarUrl(url: string | null | undefined): AvatarUrl | null { * function ProfileAvatar() { * const { data: avatarUrl } = useAvatarUrl({ * name: "vitalik.eth", - * fallback: async (name) => { + * browserUnsupportedProtocolFallback: async (name) => { * // Use the ENS Metadata Service for the current namespace * const url = buildEnsMetadataServiceAvatarUrl(name, namespaceId); * return url?.toString() ?? null; @@ -105,8 +105,10 @@ function normalizeAvatarUrl(url: string | null | undefined): AvatarUrl | null { * } * ``` */ -export function useAvatarUrl(parameters: UseAvatarUrlParameters) { - const { name, config, query: queryOptions, fallback } = parameters; +export function useAvatarUrl( + parameters: UseAvatarUrlParameters, +): ReturnType> { + const { name, config, query: queryOptions, browserUnsupportedProtocolFallback } = parameters; const _config = useENSNodeConfig(config); const canEnable = name !== null; @@ -133,11 +135,17 @@ export function useAvatarUrl(parameters: UseAvatarUrlParameters) { : undefined; // Use custom fallback if provided, otherwise use default - const activeFallback = fallback ?? defaultFallback; + const activeFallback = browserUnsupportedProtocolFallback ?? defaultFallback; // Then process the avatar URL return useQuery({ - queryKey: ["avatarUrl", name, _config.client.url.href, namespaceId, !!fallback], + queryKey: [ + "avatarUrl", + name, + _config.client.url.href, + namespaceId, + !!browserUnsupportedProtocolFallback, + ], queryFn: async (): Promise => { if (!name || !recordsQuery.data) return null; @@ -176,6 +184,7 @@ export function useAvatarUrl(parameters: UseAvatarUrlParameters) { }, enabled: canEnable && recordsQuery.isSuccess, retry: false, + placeholderData: null, ...queryOptions, }); } From 8cc88a023a9ed04df93106540d24bef0c1b5e55d Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 5 Oct 2025 17:46:44 +0100 Subject: [PATCH 07/73] update jsdoc for avatar --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 99 ++++++++++++++----- 1 file changed, 74 insertions(+), 25 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 1da7a0b74..f4e0fe697 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -73,16 +73,20 @@ function normalizeAvatarUrl(url: string | null | undefined): AvatarUrl | null { * ```typescript * import { useAvatarUrl } from "@ensnode/ensnode-react"; * - * function ProfileAvatar() { - * const { data: avatarUrl, isLoading, error } = useAvatarUrl({ - * name: "vitalik.eth" - * }); + * function ProfileAvatar({ name }: { name: string }) { + * const { data, isLoading } = useAvatarUrl({ name }); * - * if (isLoading) return
Loading...
; - * if (error) return
Error: {error.message}
; - * if (!avatarUrl) return
No avatar url configured or avatar url configuration is invalid
; + * const avatarUrl = data?.browserSupportedAvatarUrl; * - * return Avatar; + * return ( + *
+ * {isLoading || !avatarUrl ? ( + *
+ * ) : ( + * {`${name} + * )} + *
+ * ); * } * ``` * @@ -91,9 +95,9 @@ function normalizeAvatarUrl(url: string | null | undefined): AvatarUrl | null { * // With custom fallback * import { useAvatarUrl, buildEnsMetadataServiceAvatarUrl } from "@ensnode/ensnode-react"; * - * function ProfileAvatar() { - * const { data: avatarUrl } = useAvatarUrl({ - * name: "vitalik.eth", + * function ProfileAvatar({ name, namespaceId }: { name: string; namespaceId: string }) { + * const { data, isLoading } = useAvatarUrl({ + * name, * browserUnsupportedProtocolFallback: async (name) => { * // Use the ENS Metadata Service for the current namespace * const url = buildEnsMetadataServiceAvatarUrl(name, namespaceId); @@ -101,13 +105,23 @@ function normalizeAvatarUrl(url: string | null | undefined): AvatarUrl | null { * } * }); * - * return avatarUrl ? Avatar : null; + * const avatarUrl = data?.browserSupportedAvatarUrl; + * + * return ( + *
+ * {isLoading || !avatarUrl ? ( + *
+ * ) : ( + * {`${name} + * )} + *
+ * ); * } * ``` */ export function useAvatarUrl( parameters: UseAvatarUrlParameters, -): ReturnType> { +): ReturnType> { const { name, config, query: queryOptions, browserUnsupportedProtocolFallback } = parameters; const _config = useENSNodeConfig(config); @@ -146,45 +160,80 @@ export function useAvatarUrl( namespaceId, !!browserUnsupportedProtocolFallback, ], - queryFn: async (): Promise => { - if (!name || !recordsQuery.data) return null; + queryFn: async (): Promise => { + if (!name || !recordsQuery.data) { + return { + rawAvatarUrl: null, + browserSupportedAvatarUrl: null, + fromFallback: false, + }; + } // Get avatar text record from useRecords result - const avatarTextRecord = recordsQuery.data.records?.texts?.avatar; + const avatarTextRecord = recordsQuery.data.records?.texts?.avatar ?? null; - // If no avatar text record, return null + // If no avatar text record, return null values if (!avatarTextRecord) { - return null; + return { + rawAvatarUrl: null, + browserSupportedAvatarUrl: null, + fromFallback: false, + }; } // Try to normalize the avatar URL const normalizedUrl = normalizeAvatarUrl(avatarTextRecord); - // If normalization failed, return null + // If normalization failed, the URL is completely invalid if (!normalizedUrl) { - return null; + return { + rawAvatarUrl: avatarTextRecord, + browserSupportedAvatarUrl: null, + fromFallback: false, + }; } // If the URL uses http or https protocol, return it if (normalizedUrl.protocol === "http:" || normalizedUrl.protocol === "https:") { - return normalizedUrl.toString(); + return { + rawAvatarUrl: avatarTextRecord, + browserSupportedAvatarUrl: normalizedUrl.toString(), + fromFallback: false, + }; } // For other protocols (ipfs, data, NFT URIs, etc.), use fallback if available if (activeFallback) { try { - return await activeFallback(name); + const fallbackUrl = await activeFallback(name); + return { + rawAvatarUrl: avatarTextRecord, + browserSupportedAvatarUrl: fallbackUrl, + fromFallback: fallbackUrl !== null, + }; } catch { - return null; + return { + rawAvatarUrl: avatarTextRecord, + browserSupportedAvatarUrl: null, + fromFallback: false, + }; } } // No fallback available - return null; + return { + rawAvatarUrl: avatarTextRecord, + browserSupportedAvatarUrl: null, + fromFallback: false, + }; }, enabled: canEnable && recordsQuery.isSuccess, retry: false, - placeholderData: null, + placeholderData: { + rawAvatarUrl: null, + browserSupportedAvatarUrl: null, + fromFallback: false, + }, ...queryOptions, }); } From a233cf459fd687ca9c384d43fb38bf65d66daf94 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 5 Oct 2025 18:00:08 +0100 Subject: [PATCH 08/73] apply changes --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 50 ++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index f4e0fe697..bc5f49328 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -1,13 +1,23 @@ "use client"; import { type Name, buildEnsMetadataServiceAvatarUrl, buildUrl } from "@ensnode/ensnode-sdk"; -import { useQuery } from "@tanstack/react-query"; +import { type UseQueryResult, useQuery } from "@tanstack/react-query"; import type { ConfigParameter, QueryParameter } from "../types"; import { useENSIndexerConfig } from "./useENSIndexerConfig"; import { useENSNodeConfig } from "./useENSNodeConfig"; import { useRecords } from "./useRecords"; +/** + * The ENS avatar text record key. + */ +const AVATAR_TEXT_RECORD_KEY = "avatar" as const; + +/** + * Protocols supported by browsers for direct image rendering. + */ +const BROWSER_SUPPORTED_PROTOCOLS = ["http:", "https:"] as const; + /** * Type alias for avatar URLs. Avatar URLs must be URLs to an image asset * accessible via the http or https protocol. @@ -37,6 +47,9 @@ export interface UseAvatarUrlParameters extends QueryParameter, C * Normalizes an avatar URL by ensuring it has a valid protocol. * Avatar URLs should be URLs to an image asset accessible via the http or https protocol. * + * If the URL lacks a protocol, https:// is prepended. Non-http/https protocols (e.g., ipfs://, ar://) + * are preserved and can be handled by fallback mechanisms. + * * @param url - The URL string to normalize * @returns A URL object if the input is valid, null otherwise * @@ -44,6 +57,7 @@ export interface UseAvatarUrlParameters extends QueryParameter, C * ```typescript * normalizeAvatarUrl("example.com/avatar.png") // Returns URL with https://example.com/avatar.png * normalizeAvatarUrl("http://example.com/avatar.png") // Returns URL with http://example.com/avatar.png + * normalizeAvatarUrl("ipfs://QmHash") // Returns URL with ipfs://QmHash (requires fallback) * normalizeAvatarUrl("invalid url") // Returns null * ``` */ @@ -57,6 +71,28 @@ function normalizeAvatarUrl(url: string | null | undefined): AvatarUrl | null { } } +/** + * Result returned by the useAvatarUrl hook. + */ +export interface UseAvatarUrlResult { + /** + * The original avatar text record value from ENS, before any normalization or fallback processing. + * Null if no avatar record exists. + */ + rawAvatarUrl: string | null; + /** + * A browser-supported (http/https) avatar URL ready for use in tags. + * Populated when the avatar uses http/https protocol or when a fallback successfully resolves. + * Null if no avatar exists or resolution fails. + */ + browserSupportedAvatarUrl: string | null; + /** + * Indicates whether the browserSupportedAvatarUrl was obtained via the fallback mechanism + * (true) or directly from the avatar text record (false). + */ + fromFallback: boolean; +} + /** * Resolves the avatar URL for an ENS name. * @@ -121,16 +157,15 @@ function normalizeAvatarUrl(url: string | null | undefined): AvatarUrl | null { */ export function useAvatarUrl( parameters: UseAvatarUrlParameters, -): ReturnType> { +): UseQueryResult { const { name, config, query: queryOptions, browserUnsupportedProtocolFallback } = parameters; const _config = useENSNodeConfig(config); const canEnable = name !== null; - // First, get the avatar text record const recordsQuery = useRecords({ name, - selection: { texts: ["avatar"] }, + selection: { texts: [AVATAR_TEXT_RECORD_KEY] }, config: _config, query: { enabled: canEnable }, }); @@ -169,7 +204,6 @@ export function useAvatarUrl( }; } - // Get avatar text record from useRecords result const avatarTextRecord = recordsQuery.data.records?.texts?.avatar ?? null; // If no avatar text record, return null values @@ -194,7 +228,11 @@ export function useAvatarUrl( } // If the URL uses http or https protocol, return it - if (normalizedUrl.protocol === "http:" || normalizedUrl.protocol === "https:") { + if ( + BROWSER_SUPPORTED_PROTOCOLS.includes( + normalizedUrl.protocol as (typeof BROWSER_SUPPORTED_PROTOCOLS)[number], + ) + ) { return { rawAvatarUrl: avatarTextRecord, browserSupportedAvatarUrl: normalizedUrl.toString(), From 35bc13a0ca356df5fb0be1c68d9baef8f907f503 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 5 Oct 2025 18:04:04 +0100 Subject: [PATCH 09/73] fix: missing generics for useQuery; --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index bc5f49328..5ea07028c 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -187,7 +187,7 @@ export function useAvatarUrl( const activeFallback = browserUnsupportedProtocolFallback ?? defaultFallback; // Then process the avatar URL - return useQuery({ + return useQuery({ queryKey: [ "avatarUrl", name, From 43d89020cf81772328d4cbdd2dcfcb3e9abad1d3 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 5 Oct 2025 18:06:11 +0100 Subject: [PATCH 10/73] docs(changeset): useAvatarUrl --- .changeset/evil-numbers-shine.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/evil-numbers-shine.md diff --git a/.changeset/evil-numbers-shine.md b/.changeset/evil-numbers-shine.md new file mode 100644 index 000000000..41d222708 --- /dev/null +++ b/.changeset/evil-numbers-shine.md @@ -0,0 +1,7 @@ +--- +"@ensnode/ensnode-react": minor +"@ensnode/ensnode-sdk": minor +"ensadmin": minor +--- + +useAvatarUrl From b128d60de7d8d0e205eac70a740a03ea329364e3 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 5 Oct 2025 18:08:05 +0100 Subject: [PATCH 11/73] docs(changeset): Added useAvatarUrl hook --- .changeset/stale-dots-reply.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/stale-dots-reply.md diff --git a/.changeset/stale-dots-reply.md b/.changeset/stale-dots-reply.md new file mode 100644 index 000000000..84ee41b26 --- /dev/null +++ b/.changeset/stale-dots-reply.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-react": minor +--- + +Added useAvatarUrl hook From 41e9f11f13a720b5053727667c50428151942790 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 5 Oct 2025 18:08:38 +0100 Subject: [PATCH 12/73] docs(changeset): Added buildUrl --- .changeset/cold-donuts-look.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cold-donuts-look.md diff --git a/.changeset/cold-donuts-look.md b/.changeset/cold-donuts-look.md new file mode 100644 index 000000000..b13ced95a --- /dev/null +++ b/.changeset/cold-donuts-look.md @@ -0,0 +1,5 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +Added buildUrl From 66dc496498bf1d2da46d16665adb11f65833a3f0 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 5 Oct 2025 18:09:02 +0100 Subject: [PATCH 13/73] individual changesets --- .changeset/cold-donuts-look.md | 4 +++- .changeset/evil-numbers-shine.md | 6 +++--- .changeset/stale-dots-reply.md | 7 ++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.changeset/cold-donuts-look.md b/.changeset/cold-donuts-look.md index b13ced95a..1af66b0f2 100644 --- a/.changeset/cold-donuts-look.md +++ b/.changeset/cold-donuts-look.md @@ -2,4 +2,6 @@ "@ensnode/ensnode-sdk": minor --- -Added buildUrl +Added `buildEnsMetadataServiceAvatarUrl` function to generate ENS Metadata Service avatar URLs for supported namespaces +Added `buildUrl` utility function to normalize URLs with implicit `https://` protocol handling +Exported metadata service utilities from `ens` module diff --git a/.changeset/evil-numbers-shine.md b/.changeset/evil-numbers-shine.md index 41d222708..96472532a 100644 --- a/.changeset/evil-numbers-shine.md +++ b/.changeset/evil-numbers-shine.md @@ -1,7 +1,7 @@ --- -"@ensnode/ensnode-react": minor -"@ensnode/ensnode-sdk": minor "ensadmin": minor --- -useAvatarUrl +Refactored avatar URL handling to use centralized utilities from `ensnode-sdk` +Removed duplicate `buildEnsMetadataServiceAvatarUrl` and `buildUrl` functions in favor of SDK exports +Updated `ens-avatar` component to use new avatar URL utilities diff --git a/.changeset/stale-dots-reply.md b/.changeset/stale-dots-reply.md index 84ee41b26..7c42f44fe 100644 --- a/.changeset/stale-dots-reply.md +++ b/.changeset/stale-dots-reply.md @@ -2,4 +2,9 @@ "@ensnode/ensnode-react": minor --- -Added useAvatarUrl hook +Added `useAvatarUrl` hook for resolving ENS avatar URLs with browser-supported protocols +Added `UseAvatarUrlResult` interface for avatar URL query results +Added `UseAvatarUrlParameters` interface for hook configuration +Added `AvatarUrl` type alias for avatar URL objects +Added support for custom fallback functions when avatar uses non-http/https protocols (e.g., `ipfs://`, `ar://`) +Added automatic fallback to ENS Metadata Service for unsupported protocol From cd5f6c54a02939037ab57e362d3e7d855bb8355d Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 5 Oct 2025 18:16:24 +0100 Subject: [PATCH 14/73] export correct types for use --- apps/ensadmin/src/components/ens-avatar.tsx | 4 +++- .../use-ens-metadata-service-avatar-url.ts | 3 +-- .../ensnode-react/src/hooks/useAvatarUrl.ts | 23 +++++++++++++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/ensadmin/src/components/ens-avatar.tsx b/apps/ensadmin/src/components/ens-avatar.tsx index aacab9881..c88d81748 100644 --- a/apps/ensadmin/src/components/ens-avatar.tsx +++ b/apps/ensadmin/src/components/ens-avatar.tsx @@ -18,10 +18,12 @@ type ImageLoadingStatus = Parameters< export const EnsAvatar = ({ name, className }: EnsAvatarProps) => { const [loadingStatus, setLoadingStatus] = React.useState("idle"); - const { data: avatarUrl } = useAvatarUrl({ + const { data: avatarData } = useAvatarUrl({ name, }); + const avatarUrl = avatarData?.browserSupportedAvatarUrl; + if (avatarUrl === null || avatarUrl === undefined) { return ( diff --git a/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts b/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts index 879771dfe..6c3b28bed 100644 --- a/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts +++ b/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts @@ -1,7 +1,6 @@ "use client"; -import { buildEnsMetadataServiceAvatarUrl } from "@ensnode/ensnode-react"; -import { Name } from "@ensnode/ensnode-sdk"; +import { Name, buildEnsMetadataServiceAvatarUrl } from "@ensnode/ensnode-sdk"; import { useQuery } from "@tanstack/react-query"; import { useNamespace } from "./use-namespace"; diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 5ea07028c..cff4f1dc9 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -186,15 +186,21 @@ export function useAvatarUrl( // Use custom fallback if provided, otherwise use default const activeFallback = browserUnsupportedProtocolFallback ?? defaultFallback; - // Then process the avatar URL - return useQuery({ + // Construct query options object + const baseQueryOptions: { + queryKey: readonly unknown[]; + queryFn: () => Promise; + enabled: boolean; + retry: boolean; + placeholderData: UseAvatarUrlResult; + } = { queryKey: [ "avatarUrl", name, _config.client.url.href, namespaceId, !!browserUnsupportedProtocolFallback, - ], + ] as const, queryFn: async (): Promise => { if (!name || !recordsQuery.data) { return { @@ -271,7 +277,14 @@ export function useAvatarUrl( rawAvatarUrl: null, browserSupportedAvatarUrl: null, fromFallback: false, - }, + } as const, + }; + + const options = { + ...baseQueryOptions, ...queryOptions, - }); + enabled: canEnable && recordsQuery.isSuccess && (queryOptions?.enabled ?? true), + } as typeof baseQueryOptions; + + return useQuery(options); } From b190da0ef093eba10885233ab56020030fd868c4 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 6 Oct 2025 11:09:09 +0100 Subject: [PATCH 15/73] apply suggestion --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index cff4f1dc9..242b6a389 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -200,6 +200,7 @@ export function useAvatarUrl( _config.client.url.href, namespaceId, !!browserUnsupportedProtocolFallback, + recordsQuery.data?.records?.texts?.avatar ?? null, ] as const, queryFn: async (): Promise => { if (!name || !recordsQuery.data) { From ddc875bb5f5444d3e3436c28c5f4146ba554065c Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 6 Oct 2025 15:04:09 +0100 Subject: [PATCH 16/73] apply feedback --- apps/ensadmin/src/components/ens-avatar.tsx | 10 +++-- packages/ensnode-react/src/hooks/index.ts | 3 ++ .../ensnode-react/src/hooks/useAvatarUrl.ts | 43 ++++++++----------- .../ensnode-sdk/src/ens/metadata-service.ts | 21 +++++---- packages/ensnode-sdk/src/shared/url.ts | 6 +++ 5 files changed, 46 insertions(+), 37 deletions(-) diff --git a/apps/ensadmin/src/components/ens-avatar.tsx b/apps/ensadmin/src/components/ens-avatar.tsx index c88d81748..bbbc5ff5c 100644 --- a/apps/ensadmin/src/components/ens-avatar.tsx +++ b/apps/ensadmin/src/components/ens-avatar.tsx @@ -22,9 +22,9 @@ export const EnsAvatar = ({ name, className }: EnsAvatarProps) => { name, }); - const avatarUrl = avatarData?.browserSupportedAvatarUrl; - - if (avatarUrl === null || avatarUrl === undefined) { + // While useAvatarUrl has placeholderData, TanStack Query types data as possibly undefined + // browserSupportedAvatarUrl is BrowserSupportedAssetUrl | null when present + if (!avatarData || avatarData.browserSupportedAvatarUrl === null) { return ( @@ -32,10 +32,12 @@ export const EnsAvatar = ({ name, className }: EnsAvatarProps) => { ); } + const avatarUrl = avatarData.browserSupportedAvatarUrl; + return ( { setLoadingStatus(status); diff --git a/packages/ensnode-react/src/hooks/index.ts b/packages/ensnode-react/src/hooks/index.ts index 19955ae81..bda900fd5 100644 --- a/packages/ensnode-react/src/hooks/index.ts +++ b/packages/ensnode-react/src/hooks/index.ts @@ -5,3 +5,6 @@ export * from "./usePrimaryNames"; export * from "./useENSIndexerConfig"; export * from "./useIndexingStatus"; export * from "./useAvatarUrl"; + +// Re-export BrowserSupportedAssetUrl for convenience +export type { BrowserSupportedAssetUrl } from "@ensnode/ensnode-sdk"; diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 242b6a389..beaec0a1c 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -1,6 +1,12 @@ "use client"; -import { type Name, buildEnsMetadataServiceAvatarUrl, buildUrl } from "@ensnode/ensnode-sdk"; +import { + type BrowserSupportedAssetUrl, + type Name, + buildEnsMetadataServiceAvatarUrl, + buildUrl, + isHttpProtocol, +} from "@ensnode/ensnode-sdk"; import { type UseQueryResult, useQuery } from "@tanstack/react-query"; import type { ConfigParameter, QueryParameter } from "../types"; @@ -13,17 +19,6 @@ import { useRecords } from "./useRecords"; */ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; -/** - * Protocols supported by browsers for direct image rendering. - */ -const BROWSER_SUPPORTED_PROTOCOLS = ["http:", "https:"] as const; - -/** - * Type alias for avatar URLs. Avatar URLs must be URLs to an image asset - * accessible via the http or https protocol. - */ -export type AvatarUrl = URL; - /** * Parameters for the useAvatarUrl hook. * @@ -40,7 +35,7 @@ export interface UseAvatarUrlParameters extends QueryParameter, C * @param name - The ENS name to get the avatar URL for * @returns Promise resolving to the avatar URL, or null if unavailable */ - browserUnsupportedProtocolFallback?: (name: Name) => Promise; + browserUnsupportedProtocolFallback?: (name: Name) => Promise; } /** @@ -61,7 +56,7 @@ export interface UseAvatarUrlParameters extends QueryParameter, C * normalizeAvatarUrl("invalid url") // Returns null * ``` */ -function normalizeAvatarUrl(url: string | null | undefined): AvatarUrl | null { +function normalizeAvatarUrl(url: string | null | undefined): BrowserSupportedAssetUrl | null { if (!url) return null; try { @@ -73,19 +68,22 @@ function normalizeAvatarUrl(url: string | null | undefined): AvatarUrl | null { /** * Result returned by the useAvatarUrl hook. + * + * Invariant: If rawAvatarUrl is null, then browserSupportedAvatarUrl must also be null. */ export interface UseAvatarUrlResult { /** * The original avatar text record value from ENS, before any normalization or fallback processing. - * Null if no avatar record exists. + * Null if the avatar text record is not set for the ENS name. */ rawAvatarUrl: string | null; /** * A browser-supported (http/https) avatar URL ready for use in tags. * Populated when the avatar uses http/https protocol or when a fallback successfully resolves. - * Null if no avatar exists or resolution fails. + * Null if the avatar text record is not set, or if the avatar uses a non-http/https protocol + * and either no fallback is available or the fallback fails to resolve. */ - browserSupportedAvatarUrl: string | null; + browserSupportedAvatarUrl: BrowserSupportedAssetUrl | null; /** * Indicates whether the browserSupportedAvatarUrl was obtained via the fallback mechanism * (true) or directly from the avatar text record (false). @@ -178,8 +176,7 @@ export function useAvatarUrl( const defaultFallback = namespaceId !== null && namespaceId !== undefined ? async (name: Name) => { - const url = buildEnsMetadataServiceAvatarUrl(name, namespaceId); - return url?.toString() ?? null; + return buildEnsMetadataServiceAvatarUrl(name, namespaceId); } : undefined; @@ -235,14 +232,10 @@ export function useAvatarUrl( } // If the URL uses http or https protocol, return it - if ( - BROWSER_SUPPORTED_PROTOCOLS.includes( - normalizedUrl.protocol as (typeof BROWSER_SUPPORTED_PROTOCOLS)[number], - ) - ) { + if (isHttpProtocol(normalizedUrl)) { return { rawAvatarUrl: avatarTextRecord, - browserSupportedAvatarUrl: normalizedUrl.toString(), + browserSupportedAvatarUrl: normalizedUrl, fromFallback: false, }; } diff --git a/packages/ensnode-sdk/src/ens/metadata-service.ts b/packages/ensnode-sdk/src/ens/metadata-service.ts index 8830f1393..14e4fe997 100644 --- a/packages/ensnode-sdk/src/ens/metadata-service.ts +++ b/packages/ensnode-sdk/src/ens/metadata-service.ts @@ -1,26 +1,31 @@ import type { ENSNamespaceId } from "@ensnode/datasources"; import { ENSNamespaceIds } from "@ensnode/datasources"; +import type { BrowserSupportedAssetUrl } from "../shared/url"; import type { Name } from "./types"; /** - * Build the avatar image URL for a name on the given ENS Namespace that (once fetched) would - * load the avatar image for the given name from the ENS Metadata Service + * Builds a browser-supported avatar image URL for an ENS name using the ENS Metadata Service * (https://metadata.ens.domains/docs). * - * The returned URL is dynamically built based on the provided ENS namespace. Not all ENS - * namespaces are supported by the ENS Metadata Service. Therefore, the returned URL may - * be null. + * ENS avatar text records can specify URLs using protocols that browsers don't natively support + * for direct image rendering, such as ipfs://, ar://, or NFT URIs (eip155:). The ENS Metadata + * Service acts as a proxy to resolve these non-browser-supported protocols and serve the avatar + * images via standard HTTP/HTTPS, making them directly usable in tags and other browser + * contexts. + * + * The returned URL uses the BrowserSupportedAssetUrl type, indicating it's safe to use directly + * in browsers without additional protocol handling. * * @param {Name} name - ENS name to build the avatar image URL for * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier - * @returns avatar image URL for the name on the given ENS Namespace, or null if the given - * ENS namespace is not supported by the ENS Metadata Service + * @returns A browser-supported avatar image URL for the name on the given ENS Namespace, or null + * if the given ENS namespace is not supported by the ENS Metadata Service */ export function buildEnsMetadataServiceAvatarUrl( name: Name, namespaceId: ENSNamespaceId, -): URL | null { +): BrowserSupportedAssetUrl | null { switch (namespaceId) { case ENSNamespaceIds.Mainnet: return new URL(name, `https://metadata.ens.domains/mainnet/avatar/`); diff --git a/packages/ensnode-sdk/src/shared/url.ts b/packages/ensnode-sdk/src/shared/url.ts index ed9acdaf0..e8d94555a 100644 --- a/packages/ensnode-sdk/src/shared/url.ts +++ b/packages/ensnode-sdk/src/shared/url.ts @@ -1,3 +1,9 @@ +/** + * Type alias for URLs to assets that are supported by browsers for direct rendering. + * Assets must be accessible via the http or https protocol. + */ +export type BrowserSupportedAssetUrl = URL; + /** * Builds a `URL` from the given string. * From 8c1c4bd424f949784d23567ef6413a25bd1935d0 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 6 Oct 2025 15:06:20 +0100 Subject: [PATCH 17/73] Update packages/ensnode-react/src/hooks/useAvatarUrl.ts Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index beaec0a1c..03aa5d83b 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -30,7 +30,7 @@ export interface UseAvatarUrlParameters extends QueryParameter, C * Optional custom fallback function to get avatar URL when the avatar text record * uses a non-http/https protocol (e.g., ipfs://, ar://, eip155:/). * - * If not provided, defaults to using the ENS Metadata Service. + * If not provided, defaults to using the ENS Metadata Service as a fallback proxy for browser-supported avatar urls. * * @param name - The ENS name to get the avatar URL for * @returns Promise resolving to the avatar URL, or null if unavailable From 65bdc7f2cf8db71662c1861ad9c450e72c8d0b6f Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 6 Oct 2025 15:06:36 +0100 Subject: [PATCH 18/73] Update packages/ensnode-react/src/hooks/useAvatarUrl.ts Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 03aa5d83b..bc933ec9a 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -33,7 +33,7 @@ export interface UseAvatarUrlParameters extends QueryParameter, C * If not provided, defaults to using the ENS Metadata Service as a fallback proxy for browser-supported avatar urls. * * @param name - The ENS name to get the avatar URL for - * @returns Promise resolving to the avatar URL, or null if unavailable + * @returns Promise resolving to the browser supported avatar URL, or null if unavailable */ browserUnsupportedProtocolFallback?: (name: Name) => Promise; } From a10e774a4e8b322de11beadef06c37c4b3401b53 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 6 Oct 2025 16:15:43 +0100 Subject: [PATCH 19/73] toBrowserSupportedUrl --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 19 +++++++++--------- .../ensnode-sdk/src/ens/metadata-service.ts | 9 +++++++-- packages/ensnode-sdk/src/shared/url.ts | 20 +++++++++++++++++++ 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index bc933ec9a..7cf6bfc2b 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -172,13 +172,10 @@ export function useAvatarUrl( const configQuery = useENSIndexerConfig({ config: _config }); const namespaceId = configQuery.data?.namespace ?? null; - // Create default fallback using ENS Metadata Service if namespaceId is available - const defaultFallback = - namespaceId !== null && namespaceId !== undefined - ? async (name: Name) => { - return buildEnsMetadataServiceAvatarUrl(name, namespaceId); - } - : undefined; + // Create default fallback using ENS Metadata Service + const defaultFallback = async (name: Name) => { + return buildEnsMetadataServiceAvatarUrl(name, namespaceId!); + }; // Use custom fallback if provided, otherwise use default const activeFallback = browserUnsupportedProtocolFallback ?? defaultFallback; @@ -265,7 +262,7 @@ export function useAvatarUrl( fromFallback: false, }; }, - enabled: canEnable && recordsQuery.isSuccess, + enabled: canEnable && recordsQuery.isSuccess && configQuery.isSuccess, retry: false, placeholderData: { rawAvatarUrl: null, @@ -277,7 +274,11 @@ export function useAvatarUrl( const options = { ...baseQueryOptions, ...queryOptions, - enabled: canEnable && recordsQuery.isSuccess && (queryOptions?.enabled ?? true), + enabled: + canEnable && + recordsQuery.isSuccess && + configQuery.isSuccess && + (queryOptions?.enabled ?? true), } as typeof baseQueryOptions; return useQuery(options); diff --git a/packages/ensnode-sdk/src/ens/metadata-service.ts b/packages/ensnode-sdk/src/ens/metadata-service.ts index 14e4fe997..70785b5e9 100644 --- a/packages/ensnode-sdk/src/ens/metadata-service.ts +++ b/packages/ensnode-sdk/src/ens/metadata-service.ts @@ -2,6 +2,7 @@ import type { ENSNamespaceId } from "@ensnode/datasources"; import { ENSNamespaceIds } from "@ensnode/datasources"; import type { BrowserSupportedAssetUrl } from "../shared/url"; +import { toBrowserSupportedUrl } from "../shared/url"; import type { Name } from "./types"; /** @@ -28,9 +29,13 @@ export function buildEnsMetadataServiceAvatarUrl( ): BrowserSupportedAssetUrl | null { switch (namespaceId) { case ENSNamespaceIds.Mainnet: - return new URL(name, `https://metadata.ens.domains/mainnet/avatar/`); + return toBrowserSupportedUrl( + new URL(name, `https://metadata.ens.domains/mainnet/avatar/`).toString(), + ); case ENSNamespaceIds.Sepolia: - return new URL(name, `https://metadata.ens.domains/sepolia/avatar/`); + return toBrowserSupportedUrl( + new URL(name, `https://metadata.ens.domains/sepolia/avatar/`).toString(), + ); case ENSNamespaceIds.Holesky: // metadata.ens.domains doesn't currently support holesky return null; diff --git a/packages/ensnode-sdk/src/shared/url.ts b/packages/ensnode-sdk/src/shared/url.ts index e8d94555a..f11d9b524 100644 --- a/packages/ensnode-sdk/src/shared/url.ts +++ b/packages/ensnode-sdk/src/shared/url.ts @@ -30,3 +30,23 @@ export function isHttpProtocol(url: URL): boolean { export function isWebSocketProtocol(url: URL): boolean { return ["ws:", "wss:"].includes(url.protocol); } + +/** + * Validates and converts a URL string to a BrowserSupportedAssetUrl. + * + * This function strictly validates that the URL uses a browser-supported protocol (http/https) + * before returning it as a BrowserSupportedAssetUrl type. + * + * @param urlString - The URL string to validate (must include protocol) + * @returns A BrowserSupportedAssetUrl if the protocol is http/https + * @throws if the URL string is invalid or uses an unsupported protocol + */ +export function toBrowserSupportedUrl(urlString: string): BrowserSupportedAssetUrl { + const url = new URL(urlString); + + if (!isHttpProtocol(url)) { + throw new Error(`URL must use http or https protocol, got: ${url.protocol}`); + } + + return url; +} From 0b89a9d671a3b24832eed1944958d920a285aa07 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 6 Oct 2025 16:41:03 +0100 Subject: [PATCH 20/73] update comments for normalizeAvatarUrl --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 7cf6bfc2b..97769bc8e 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -39,11 +39,10 @@ export interface UseAvatarUrlParameters extends QueryParameter, C } /** - * Normalizes an avatar URL by ensuring it has a valid protocol. - * Avatar URLs should be URLs to an image asset accessible via the http or https protocol. + * Normalizes a URL string by ensuring it has a valid protocol. * - * If the URL lacks a protocol, https:// is prepended. Non-http/https protocols (e.g., ipfs://, ar://) - * are preserved and can be handled by fallback mechanisms. + * If the URL lacks a protocol, https:// is prepended. + * URLs with existing protocols (http://, https://, ipfs://, ar://, eip155://, etc.) are preserved as-is. * * @param url - The URL string to normalize * @returns A URL object if the input is valid, null otherwise @@ -52,7 +51,7 @@ export interface UseAvatarUrlParameters extends QueryParameter, C * ```typescript * normalizeAvatarUrl("example.com/avatar.png") // Returns URL with https://example.com/avatar.png * normalizeAvatarUrl("http://example.com/avatar.png") // Returns URL with http://example.com/avatar.png - * normalizeAvatarUrl("ipfs://QmHash") // Returns URL with ipfs://QmHash (requires fallback) + * normalizeAvatarUrl("ipfs://QmHash") // Returns URL with ipfs://QmHash * normalizeAvatarUrl("invalid url") // Returns null * ``` */ @@ -85,8 +84,10 @@ export interface UseAvatarUrlResult { */ browserSupportedAvatarUrl: BrowserSupportedAssetUrl | null; /** - * Indicates whether the browserSupportedAvatarUrl was obtained via the fallback mechanism - * (true) or directly from the avatar text record (false). + * Indicates whether the browserSupportedAvatarUrl was obtained via the fallback mechanism. + * True if a fallback successfully resolved the URL. + * False if the URL was used directly from the avatar text record, or if there's no avatar, + * or if the fallback failed to resolve. */ fromFallback: boolean; } @@ -98,7 +99,8 @@ export interface UseAvatarUrlResult { * 1. Fetching the avatar text record using useRecords * 2. Normalizing the avatar text record as a URL * 3. Returning the URL if it uses http or https protocol - * 4. Falling back to the ENS Metadata Service (default) or custom fallback for other protocols + * 4. Falling back to the ENS Metadata Service (which proxies decentralized storage protocols + * like IPFS/Arweave to browser-accessible URLs) or a custom fallback for other protocols * * @param parameters - Configuration for the avatar URL resolution * @returns Query result with the avatar URL, loading state, and error handling From 8e42580116badc857a77a91c24352c6cce5823a6 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 6 Oct 2025 17:14:24 +0100 Subject: [PATCH 21/73] remove datasources pkg --- packages/ensnode-react/package.json | 3 +-- .../src/hooks/useENSNodeConfig.ts | 4 ++-- pnpm-lock.yaml | 19 ++++++++----------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/ensnode-react/package.json b/packages/ensnode-react/package.json index 1cbd76f94..416deab5a 100644 --- a/packages/ensnode-react/package.json +++ b/packages/ensnode-react/package.json @@ -60,7 +60,6 @@ "vitest": "catalog:" }, "dependencies": { - "@ensnode/ensnode-sdk": "workspace:*", - "@ensnode/datasources": "workspace:*" + "@ensnode/ensnode-sdk": "workspace:*" } } diff --git a/packages/ensnode-react/src/hooks/useENSNodeConfig.ts b/packages/ensnode-react/src/hooks/useENSNodeConfig.ts index 5f1811ec9..126a4d544 100644 --- a/packages/ensnode-react/src/hooks/useENSNodeConfig.ts +++ b/packages/ensnode-react/src/hooks/useENSNodeConfig.ts @@ -12,7 +12,7 @@ import type { ENSNodeConfig } from "../types"; * @throws Error if no config is available in context or parameters */ export function useENSNodeConfig( - config: TConfig | undefined, + config: TConfig | undefined ): TConfig { const contextConfig = useContext(ENSNodeContext); @@ -21,7 +21,7 @@ export function useENSNodeConfig( if (!resolvedConfig) { throw new Error( - "useENSNodeConfig must be used within an ENSNodeProvider or you must pass a config parameter", + "useENSNodeConfig must be used within an ENSNodeProvider or you must pass a config parameter" ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c33f3234..1dc558706 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -601,9 +601,6 @@ importers: packages/ensnode-react: dependencies: - '@ensnode/datasources': - specifier: workspace:* - version: link:../datasources '@ensnode/ensnode-sdk': specifier: workspace:* version: link:../ensnode-sdk @@ -13638,8 +13635,8 @@ snapshots: '@typescript-eslint/parser': 8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3) eslint: 9.20.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)) - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.20.1(jiti@2.4.2)) eslint-plugin-react: 7.37.4(eslint@9.20.1(jiti@2.4.2)) eslint-plugin-react-hooks: 5.1.0(eslint@9.20.1(jiti@2.4.2)) @@ -13658,7 +13655,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)): + eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 @@ -13669,22 +13666,22 @@ snapshots: stable-hash: 0.0.4 tinyglobby: 0.2.12 optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3) eslint: 9.20.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@9.20.1(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -13695,7 +13692,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.20.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.20.1(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)))(eslint@9.20.1(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 From 5c0c09518a6e30aa6cff2999d46915c74dd95109 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Wed, 8 Oct 2025 11:36:53 +0100 Subject: [PATCH 22/73] move comment to above property --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 5 +++-- .../ensnode-react/src/hooks/useENSNodeConfig.ts | 4 ++-- packages/ensnode-react/src/types.ts | 15 +++++++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 97769bc8e..c6c5b8472 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -21,10 +21,11 @@ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; /** * Parameters for the useAvatarUrl hook. - * - * If `name` is null, the query will not be executed. */ export interface UseAvatarUrlParameters extends QueryParameter, ConfigParameter { + /** + * If null, the query will not be executed. + */ name: Name | null; /** * Optional custom fallback function to get avatar URL when the avatar text record diff --git a/packages/ensnode-react/src/hooks/useENSNodeConfig.ts b/packages/ensnode-react/src/hooks/useENSNodeConfig.ts index 126a4d544..5f1811ec9 100644 --- a/packages/ensnode-react/src/hooks/useENSNodeConfig.ts +++ b/packages/ensnode-react/src/hooks/useENSNodeConfig.ts @@ -12,7 +12,7 @@ import type { ENSNodeConfig } from "../types"; * @throws Error if no config is available in context or parameters */ export function useENSNodeConfig( - config: TConfig | undefined + config: TConfig | undefined, ): TConfig { const contextConfig = useContext(ENSNodeContext); @@ -21,7 +21,7 @@ export function useENSNodeConfig( if (!resolvedConfig) { throw new Error( - "useENSNodeConfig must be used within an ENSNodeProvider or you must pass a config parameter" + "useENSNodeConfig must be used within an ENSNodeProvider or you must pass a config parameter", ); } diff --git a/packages/ensnode-react/src/types.ts b/packages/ensnode-react/src/types.ts index 74c633235..de097ff8a 100644 --- a/packages/ensnode-react/src/types.ts +++ b/packages/ensnode-react/src/types.ts @@ -35,33 +35,36 @@ export interface ConfigParameter /** * Parameters for the useRecords hook. - * - * If `name` is null, the query will not be executed. */ export interface UseRecordsParameters extends Omit, "name">, QueryParameter> { + /** + * If null, the query will not be executed. + */ name: ResolveRecordsRequest["name"] | null; } /** * Parameters for the usePrimaryName hook. - * - * If `address` is null, the query will not be executed. */ export interface UsePrimaryNameParameters extends Omit, QueryParameter { + /** + * If null, the query will not be executed. + */ address: ResolvePrimaryNameRequest["address"] | null; } /** * Parameters for the usePrimaryNames hook. - * - * If `address` is null, the query will not be executed. */ export interface UsePrimaryNamesParameters extends Omit, QueryParameter { + /** + * If null, the query will not be executed. + */ address: ResolvePrimaryNamesRequest["address"] | null; } From a5aeabd8424f03679f2d110a233192e2ea29be3a Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Wed, 8 Oct 2025 11:38:09 +0100 Subject: [PATCH 23/73] remove redundant enabled propetty --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index c6c5b8472..dc243c8f8 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -187,7 +187,6 @@ export function useAvatarUrl( const baseQueryOptions: { queryKey: readonly unknown[]; queryFn: () => Promise; - enabled: boolean; retry: boolean; placeholderData: UseAvatarUrlResult; } = { @@ -265,7 +264,6 @@ export function useAvatarUrl( fromFallback: false, }; }, - enabled: canEnable && recordsQuery.isSuccess && configQuery.isSuccess, retry: false, placeholderData: { rawAvatarUrl: null, From 4c0891d052bd7537825c880f486ae8fa790459ab Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Wed, 8 Oct 2025 11:43:32 +0100 Subject: [PATCH 24/73] update loading state --- apps/ensadmin/src/components/ens-avatar.tsx | 35 ++++++++++++++++++- .../ensnode-react/src/hooks/useAvatarUrl.ts | 16 ++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/ensadmin/src/components/ens-avatar.tsx b/apps/ensadmin/src/components/ens-avatar.tsx index bbbc5ff5c..90ea28790 100644 --- a/apps/ensadmin/src/components/ens-avatar.tsx +++ b/apps/ensadmin/src/components/ens-avatar.tsx @@ -15,13 +15,46 @@ type ImageLoadingStatus = Parameters< NonNullable["onLoadingStatusChange"]> >[0]; +/** + * Displays an avatar for an ENS name with proper loading and fallback states. + * + * This component handles three distinct states: + * 1. **Loading**: Shows a pulsing placeholder while fetching the avatar URL from ENS records + * 2. **Avatar Available**: Displays the avatar image once loaded, with an additional loading state for the image itself + * 3. **Fallback**: Shows a generated avatar based on the ENS name when no avatar is available or if the image fails to load + * + * The component ensures that the fallback avatar is only shown as a final state, never during loading, + * preventing unwanted visual transitions from fallback to actual avatar. + * + * @param name - The ENS name to display an avatar for + * @param className - Optional CSS class name to apply to the avatar container + * + * @example + * ```tsx + * + * ``` + * + * @example + * ```tsx + * + * ``` + */ export const EnsAvatar = ({ name, className }: EnsAvatarProps) => { const [loadingStatus, setLoadingStatus] = React.useState("idle"); - const { data: avatarData } = useAvatarUrl({ + const { data: avatarData, isLoading: isAvatarUrlLoading } = useAvatarUrl({ name, }); + // Show loading state while fetching avatar URL + if (isAvatarUrlLoading) { + return ( + + + + ); + } + // While useAvatarUrl has placeholderData, TanStack Query types data as possibly undefined // browserSupportedAvatarUrl is BrowserSupportedAssetUrl | null when present if (!avatarData || avatarData.browserSupportedAvatarUrl === null) { diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index dc243c8f8..95d6b401b 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -113,12 +113,16 @@ export interface UseAvatarUrlResult { * function ProfileAvatar({ name }: { name: string }) { * const { data, isLoading } = useAvatarUrl({ name }); * + * if (isLoading) { + * return
; + * } + * * const avatarUrl = data?.browserSupportedAvatarUrl; * * return ( *
- * {isLoading || !avatarUrl ? ( - *
+ * {!avatarUrl ? ( + *
* ) : ( * {`${name} * )} @@ -142,12 +146,16 @@ export interface UseAvatarUrlResult { * } * }); * + * if (isLoading) { + * return
; + * } + * * const avatarUrl = data?.browserSupportedAvatarUrl; * * return ( *
- * {isLoading || !avatarUrl ? ( - *
+ * {!avatarUrl ? ( + *
* ) : ( * {`${name} * )} From f064b24d860bbc5db361b3a7e69201243807d055 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 10 Oct 2025 13:55:33 +0100 Subject: [PATCH 25/73] use buildUrl --- packages/ensnode-sdk/src/shared/url.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/ensnode-sdk/src/shared/url.ts b/packages/ensnode-sdk/src/shared/url.ts index f11d9b524..603dd49ce 100644 --- a/packages/ensnode-sdk/src/shared/url.ts +++ b/packages/ensnode-sdk/src/shared/url.ts @@ -39,13 +39,17 @@ export function isWebSocketProtocol(url: URL): boolean { * * @param urlString - The URL string to validate (must include protocol) * @returns A BrowserSupportedAssetUrl if the protocol is http/https - * @throws if the URL string is invalid or uses an unsupported protocol + * @throws if the URL string cannot be successfully converted to a BrowserSupportedAssetUrl */ -export function toBrowserSupportedUrl(urlString: string): BrowserSupportedAssetUrl { - const url = new URL(urlString); +export function toBrowserSupportedUrl( + urlString: string +): BrowserSupportedAssetUrl { + const url = buildUrl(urlString); if (!isHttpProtocol(url)) { - throw new Error(`URL must use http or https protocol, got: ${url.protocol}`); + throw new Error( + `BrowserSupportedAssetUrl must use http or https protocol, got: ${url.protocol}` + ); } return url; From c71a0a6d7517249db4376f1e67cd6c431b7f52f5 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 10 Oct 2025 13:58:21 +0100 Subject: [PATCH 26/73] use toBrowserSupportedUrl only --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 42 ++++--------------- packages/ensnode-sdk/src/shared/url.ts | 6 +-- 2 files changed, 10 insertions(+), 38 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 95d6b401b..740f0586c 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -39,33 +39,6 @@ export interface UseAvatarUrlParameters extends QueryParameter, C browserUnsupportedProtocolFallback?: (name: Name) => Promise; } -/** - * Normalizes a URL string by ensuring it has a valid protocol. - * - * If the URL lacks a protocol, https:// is prepended. - * URLs with existing protocols (http://, https://, ipfs://, ar://, eip155://, etc.) are preserved as-is. - * - * @param url - The URL string to normalize - * @returns A URL object if the input is valid, null otherwise - * - * @example - * ```typescript - * normalizeAvatarUrl("example.com/avatar.png") // Returns URL with https://example.com/avatar.png - * normalizeAvatarUrl("http://example.com/avatar.png") // Returns URL with http://example.com/avatar.png - * normalizeAvatarUrl("ipfs://QmHash") // Returns URL with ipfs://QmHash - * normalizeAvatarUrl("invalid url") // Returns null - * ``` - */ -function normalizeAvatarUrl(url: string | null | undefined): BrowserSupportedAssetUrl | null { - if (!url) return null; - - try { - return buildUrl(url); - } catch { - return null; - } -} - /** * Result returned by the useAvatarUrl hook. * @@ -226,11 +199,12 @@ export function useAvatarUrl( }; } - // Try to normalize the avatar URL - const normalizedUrl = normalizeAvatarUrl(avatarTextRecord); - - // If normalization failed, the URL is completely invalid - if (!normalizedUrl) { + // Try to parse the avatar URL + let parsedUrl: URL; + try { + parsedUrl = buildUrl(avatarTextRecord); + } catch { + // If parsing failed, the URL is completely invalid return { rawAvatarUrl: avatarTextRecord, browserSupportedAvatarUrl: null, @@ -239,10 +213,10 @@ export function useAvatarUrl( } // If the URL uses http or https protocol, return it - if (isHttpProtocol(normalizedUrl)) { + if (isHttpProtocol(parsedUrl)) { return { rawAvatarUrl: avatarTextRecord, - browserSupportedAvatarUrl: normalizedUrl, + browserSupportedAvatarUrl: parsedUrl, fromFallback: false, }; } diff --git a/packages/ensnode-sdk/src/shared/url.ts b/packages/ensnode-sdk/src/shared/url.ts index 603dd49ce..d91686b27 100644 --- a/packages/ensnode-sdk/src/shared/url.ts +++ b/packages/ensnode-sdk/src/shared/url.ts @@ -41,14 +41,12 @@ export function isWebSocketProtocol(url: URL): boolean { * @returns A BrowserSupportedAssetUrl if the protocol is http/https * @throws if the URL string cannot be successfully converted to a BrowserSupportedAssetUrl */ -export function toBrowserSupportedUrl( - urlString: string -): BrowserSupportedAssetUrl { +export function toBrowserSupportedUrl(urlString: string): BrowserSupportedAssetUrl { const url = buildUrl(urlString); if (!isHttpProtocol(url)) { throw new Error( - `BrowserSupportedAssetUrl must use http or https protocol, got: ${url.protocol}` + `BrowserSupportedAssetUrl must use http or https protocol, got: ${url.protocol}`, ); } From f844aedc2d3ba35a1da42f8fff36e8ba35b03fc7 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 10 Oct 2025 14:41:54 +0100 Subject: [PATCH 27/73] call toBrowserSupportedUrl --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 740f0586c..1bb562a0d 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -4,8 +4,7 @@ import { type BrowserSupportedAssetUrl, type Name, buildEnsMetadataServiceAvatarUrl, - buildUrl, - isHttpProtocol, + toBrowserSupportedUrl, } from "@ensnode/ensnode-sdk"; import { type UseQueryResult, useQuery } from "@tanstack/react-query"; @@ -199,26 +198,16 @@ export function useAvatarUrl( }; } - // Try to parse the avatar URL - let parsedUrl: URL; try { - parsedUrl = buildUrl(avatarTextRecord); - } catch { - // If parsing failed, the URL is completely invalid - return { - rawAvatarUrl: avatarTextRecord, - browserSupportedAvatarUrl: null, - fromFallback: false, - }; - } + const browserSupportedUrl = toBrowserSupportedUrl(avatarTextRecord); - // If the URL uses http or https protocol, return it - if (isHttpProtocol(parsedUrl)) { return { rawAvatarUrl: avatarTextRecord, - browserSupportedAvatarUrl: parsedUrl, + browserSupportedAvatarUrl: browserSupportedUrl, fromFallback: false, }; + } catch { + // Continue to fallback handling below } // For other protocols (ipfs, data, NFT URIs, etc.), use fallback if available From 0b888b85faa143f5b29b485bb11d0aca11f0f15f Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 12 Oct 2025 10:53:14 +0100 Subject: [PATCH 28/73] revisit feedback --- apps/ensadmin/src/components/ens-avatar.tsx | 28 +++++----- .../ensnode-react/src/hooks/useAvatarUrl.ts | 54 +++++++++++-------- packages/ensnode-sdk/src/shared/url.ts | 2 + 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/apps/ensadmin/src/components/ens-avatar.tsx b/apps/ensadmin/src/components/ens-avatar.tsx index 90ea28790..182a40804 100644 --- a/apps/ensadmin/src/components/ens-avatar.tsx +++ b/apps/ensadmin/src/components/ens-avatar.tsx @@ -20,8 +20,11 @@ type ImageLoadingStatus = Parameters< * * This component handles three distinct states: * 1. **Loading**: Shows a pulsing placeholder while fetching the avatar URL from ENS records - * 2. **Avatar Available**: Displays the avatar image once loaded, with an additional loading state for the image itself - * 3. **Fallback**: Shows a generated avatar based on the ENS name when no avatar is available or if the image fails to load + * and while loading the avatar image asset itself + * 2. **Avatar Loaded**: Displays the avatar image once loaded + * 3. **Fallback**: Shows a generated avatar based on the ENS name when no avatar record is set for `name`, + * or the avatar record set for `name` is not formatted as a proper url, or if no browser-supported url + * was available, or if the browser-supported url for the avatar failed to load. * * The component ensures that the fallback avatar is only shown as a final state, never during loading, * preventing unwanted visual transitions from fallback to actual avatar. @@ -40,14 +43,14 @@ type ImageLoadingStatus = Parameters< * ``` */ export const EnsAvatar = ({ name, className }: EnsAvatarProps) => { - const [loadingStatus, setLoadingStatus] = React.useState("idle"); + const [imageLoadingStatus, setImageLoadingStatus] = React.useState("idle"); - const { data: avatarData, isLoading: isAvatarUrlLoading } = useAvatarUrl({ + const { data: avatarUrlData, isLoading: isAvatarUrlLoading } = useAvatarUrl({ name, }); - // Show loading state while fetching avatar URL - if (isAvatarUrlLoading) { + // Show loading state while fetching avatar URL or if data is not yet available + if (isAvatarUrlLoading || !avatarUrlData) { return ( @@ -55,9 +58,8 @@ export const EnsAvatar = ({ name, className }: EnsAvatarProps) => { ); } - // While useAvatarUrl has placeholderData, TanStack Query types data as possibly undefined - // browserSupportedAvatarUrl is BrowserSupportedAssetUrl | null when present - if (!avatarData || avatarData.browserSupportedAvatarUrl === null) { + // No avatar available - show fallback + if (avatarUrlData.browserSupportedAvatarUrl === null) { return ( @@ -65,7 +67,7 @@ export const EnsAvatar = ({ name, className }: EnsAvatarProps) => { ); } - const avatarUrl = avatarData.browserSupportedAvatarUrl; + const avatarUrl = avatarUrlData.browserSupportedAvatarUrl; return ( @@ -73,11 +75,11 @@ export const EnsAvatar = ({ name, className }: EnsAvatarProps) => { src={avatarUrl.toString()} alt={name} onLoadingStatusChange={(status: ImageLoadingStatus) => { - setLoadingStatus(status); + setImageLoadingStatus(status); }} /> - {loadingStatus === "error" && } - {(loadingStatus === "idle" || loadingStatus === "loading") && } + {imageLoadingStatus === "error" && } + {(imageLoadingStatus === "idle" || imageLoadingStatus === "loading") && } ); }; diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 1bb562a0d..265064c76 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -4,6 +4,7 @@ import { type BrowserSupportedAssetUrl, type Name, buildEnsMetadataServiceAvatarUrl, + isHttpProtocol, toBrowserSupportedUrl, } from "@ensnode/ensnode-sdk"; import { type UseQueryResult, useQuery } from "@tanstack/react-query"; @@ -32,6 +33,10 @@ export interface UseAvatarUrlParameters extends QueryParameter, C * * If not provided, defaults to using the ENS Metadata Service as a fallback proxy for browser-supported avatar urls. * + * IMPORTANT: Custom implementations MUST use `toBrowserSupportedUrl()` to create BrowserSupportedAssetUrl values, + * or return the result from `buildEnsMetadataServiceAvatarUrl()`. The returned URL is validated at runtime + * to ensure it passes the `isHttpProtocol` check. + * * @param name - The ENS name to get the avatar URL for * @returns Promise resolving to the browser supported avatar URL, or null if unavailable */ @@ -85,18 +90,16 @@ export interface UseAvatarUrlResult { * function ProfileAvatar({ name }: { name: string }) { * const { data, isLoading } = useAvatarUrl({ name }); * - * if (isLoading) { + * if (isLoading || !data) { * return
; * } * - * const avatarUrl = data?.browserSupportedAvatarUrl; - * * return ( *
- * {!avatarUrl ? ( + * {!data.browserSupportedAvatarUrl ? ( *
* ) : ( - * {`${name} + * {`${name} * )} *
* ); @@ -118,18 +121,16 @@ export interface UseAvatarUrlResult { * } * }); * - * if (isLoading) { + * if (isLoading || !data) { * return
; * } * - * const avatarUrl = data?.browserSupportedAvatarUrl; - * * return ( *
- * {!avatarUrl ? ( + * {!data.browserSupportedAvatarUrl ? ( *
* ) : ( - * {`${name} + * {`${name} * )} *
* ); @@ -153,15 +154,6 @@ export function useAvatarUrl( // Get namespace from config const configQuery = useENSIndexerConfig({ config: _config }); - const namespaceId = configQuery.data?.namespace ?? null; - - // Create default fallback using ENS Metadata Service - const defaultFallback = async (name: Name) => { - return buildEnsMetadataServiceAvatarUrl(name, namespaceId!); - }; - - // Use custom fallback if provided, otherwise use default - const activeFallback = browserUnsupportedProtocolFallback ?? defaultFallback; // Construct query options object const baseQueryOptions: { @@ -174,12 +166,12 @@ export function useAvatarUrl( "avatarUrl", name, _config.client.url.href, - namespaceId, + configQuery.data?.namespace, !!browserUnsupportedProtocolFallback, recordsQuery.data?.records?.texts?.avatar ?? null, ] as const, queryFn: async (): Promise => { - if (!name || !recordsQuery.data) { + if (!name || !recordsQuery.data || !configQuery.data) { return { rawAvatarUrl: null, browserSupportedAvatarUrl: null, @@ -187,6 +179,9 @@ export function useAvatarUrl( }; } + // Invariant: configQuery.data.namespace is guaranteed to be defined when configQuery.data exists + const namespaceId = configQuery.data.namespace; + const avatarTextRecord = recordsQuery.data.records?.texts?.avatar ?? null; // If no avatar text record, return null values @@ -210,10 +205,27 @@ export function useAvatarUrl( // Continue to fallback handling below } + // Create default fallback using ENS Metadata Service + const defaultFallback = async (name: Name): Promise => { + return buildEnsMetadataServiceAvatarUrl(name, namespaceId); + }; + + // Use custom fallback if provided, otherwise use default + const activeFallback: (name: Name) => Promise = + browserUnsupportedProtocolFallback ?? defaultFallback; + // For other protocols (ipfs, data, NFT URIs, etc.), use fallback if available if (activeFallback) { try { const fallbackUrl = await activeFallback(name); + + // Invariant: BrowserSupportedAssetUrl must pass isHttpProtocol check + if (fallbackUrl !== null && !isHttpProtocol(fallbackUrl)) { + throw new Error( + `browserUnsupportedProtocolFallback returned a URL with unsupported protocol: ${fallbackUrl.protocol}. BrowserSupportedAssetUrl must use http or https protocol.`, + ); + } + return { rawAvatarUrl: avatarTextRecord, browserSupportedAvatarUrl: fallbackUrl, diff --git a/packages/ensnode-sdk/src/shared/url.ts b/packages/ensnode-sdk/src/shared/url.ts index d91686b27..82e14b564 100644 --- a/packages/ensnode-sdk/src/shared/url.ts +++ b/packages/ensnode-sdk/src/shared/url.ts @@ -1,6 +1,8 @@ /** * Type alias for URLs to assets that are supported by browsers for direct rendering. * Assets must be accessible via the http or https protocol. + * + * Invariant: value guaranteed to pass isHttpProtocol check. */ export type BrowserSupportedAssetUrl = URL; From 2baeb886a5857644a61824cd4c62ff608332691f Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 12 Oct 2025 10:54:45 +0100 Subject: [PATCH 29/73] apply feedback --- packages/ensnode-sdk/src/ens/metadata-service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ensnode-sdk/src/ens/metadata-service.ts b/packages/ensnode-sdk/src/ens/metadata-service.ts index 70785b5e9..beeb0da37 100644 --- a/packages/ensnode-sdk/src/ens/metadata-service.ts +++ b/packages/ensnode-sdk/src/ens/metadata-service.ts @@ -30,11 +30,11 @@ export function buildEnsMetadataServiceAvatarUrl( switch (namespaceId) { case ENSNamespaceIds.Mainnet: return toBrowserSupportedUrl( - new URL(name, `https://metadata.ens.domains/mainnet/avatar/`).toString(), + `https://metadata.ens.domains/mainnet/avatar/${encodeURIComponent(name)}`, ); case ENSNamespaceIds.Sepolia: return toBrowserSupportedUrl( - new URL(name, `https://metadata.ens.domains/sepolia/avatar/`).toString(), + `https://metadata.ens.domains/sepolia/avatar/${encodeURIComponent(name)}`, ); case ENSNamespaceIds.Holesky: // metadata.ens.domains doesn't currently support holesky From a25a6865590f7a69c7760d62731f2d45a8e4a581 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 12 Oct 2025 11:00:51 +0100 Subject: [PATCH 30/73] toString --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 265064c76..1f8ee1fac 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -99,7 +99,7 @@ export interface UseAvatarUrlResult { * {!data.browserSupportedAvatarUrl ? ( *
* ) : ( - * {`${name} + * {`${name} * )} *
* ); @@ -116,8 +116,7 @@ export interface UseAvatarUrlResult { * name, * browserUnsupportedProtocolFallback: async (name) => { * // Use the ENS Metadata Service for the current namespace - * const url = buildEnsMetadataServiceAvatarUrl(name, namespaceId); - * return url?.toString() ?? null; + * return buildEnsMetadataServiceAvatarUrl(name, namespaceId); * } * }); * @@ -130,7 +129,7 @@ export interface UseAvatarUrlResult { * {!data.browserSupportedAvatarUrl ? ( *
* ) : ( - * {`${name} + * {`${name} * )} *
* ); From 2493e5e50df88c46e4d8ce55b6f3e17de0b67418 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 12 Oct 2025 11:25:43 +0100 Subject: [PATCH 31/73] add docs to readme --- packages/ensnode-react/README.md | 218 ++++++++++++++++++++++++++++++- 1 file changed, 214 insertions(+), 4 deletions(-) diff --git a/packages/ensnode-react/README.md b/packages/ensnode-react/README.md index 21f996fda..11c3b6d40 100644 --- a/packages/ensnode-react/README.md +++ b/packages/ensnode-react/README.md @@ -73,7 +73,7 @@ function DisplayNameRecords() { #### Primary Name Resolution — `usePrimaryName` ```tsx -import { mainnet } from 'viem/chains'; +import { mainnet } from "viem/chains"; import { usePrimaryName } from "@ensnode/ensnode-react"; function DisplayPrimaryName() { @@ -88,7 +88,7 @@ function DisplayPrimaryName() { return (

Primary Name (for Mainnet)

-

{data.name ?? 'No Primary Name'}

+

{data.name ?? "No Primary Name"}

); } @@ -97,7 +97,7 @@ function DisplayPrimaryName() { #### Primary Names Resolution — `usePrimaryNames` ```tsx -import { mainnet } from 'viem/chains'; +import { mainnet } from "viem/chains"; import { usePrimaryNames } from "@ensnode/ensnode-react"; function DisplayPrimaryNames() { @@ -121,6 +121,33 @@ function DisplayPrimaryNames() { } ``` +#### Avatar URL Resolution — `useAvatarUrl` + +```tsx +import { useAvatarUrl } from "@ensnode/ensnode-react"; + +function ProfileAvatar({ name }: { name: string }) { + const { data, isLoading } = useAvatarUrl({ name }); + + if (isLoading || !data) { + return
; + } + + return ( +
+ {!data.browserSupportedAvatarUrl ? ( +
+ ) : ( + {`${name} + )} +
+ ); +} +``` + ## API Reference ### ENSNodeProvider @@ -224,6 +251,189 @@ const { data, isLoading, error, refetch } = usePrimaryNames({ }); ``` +### `useAvatarUrl` + +Hook that resolves the avatar URL for an ENS name. This hook automatically handles the avatar text record resolution and provides browser-compatible URLs. + +#### Resolution Flow + +The hook follows this resolution process: + +1. **Fetches the avatar text record** using `useRecords` internally +2. **Normalizes the avatar text record** as a URL +3. **Returns the URL directly** if it uses http or https protocol +4. **Falls back to ENS Metadata Service** (or custom fallback) for non-http/https protocols (e.g., `ipfs://`, `ar://`, `eip155://`) + +The ENS Metadata Service acts as a proxy, converting decentralized storage protocols like IPFS and Arweave to browser-accessible URLs. + +#### Invariants + +- **If `rawAvatarUrl` is `null`, then `browserSupportedAvatarUrl` must also be `null`** +- The `browserSupportedAvatarUrl` is guaranteed to use http or https protocol when non-null +- The `fromFallback` flag is `true` only when a fallback successfully resolved the URL + +#### Parameters + +- `name`: The ENS Name whose avatar URL to resolve (set to `null` to disable the query) +- `browserUnsupportedProtocolFallback`: (optional) Custom fallback function for non-http/https protocols. Must return a `BrowserSupportedAssetUrl` created using `toBrowserSupportedUrl()` or from `buildEnsMetadataServiceAvatarUrl()`. Defaults to using the ENS Metadata Service. +- `query`: (optional) TanStack Query options for customization + +#### Return Value + +```tsx +interface UseAvatarUrlResult { + rawAvatarUrl: string | null; + browserSupportedAvatarUrl: BrowserSupportedAssetUrl | null; + fromFallback: boolean; +} +``` + +- `rawAvatarUrl`: The original avatar text record value from ENS, before any normalization or fallback processing. `null` if no avatar text record is set. +- `browserSupportedAvatarUrl`: A browser-supported (http/https) avatar URL ready for use in `` tags. `null` if no avatar is set, or if the avatar uses a non-http/https protocol and the fallback fails to resolve. +- `fromFallback`: Indicates whether the `browserSupportedAvatarUrl` was obtained via the fallback mechanism. + +
+Basic Example + +```tsx +import { useAvatarUrl } from "@ensnode/ensnode-react"; + +function ProfileAvatar({ name }: { name: string }) { + const { data, isLoading } = useAvatarUrl({ name }); + + if (isLoading || !data) { + return
; + } + + return ( +
+ {!data.browserSupportedAvatarUrl ? ( +
No avatar
+ ) : ( + {`${name} + )} +
+ ); +} +``` + +
+ +
+Advanced Example with Loading States + +```tsx +import { useAvatarUrl } from "@ensnode/ensnode-react"; +import { useState } from "react"; + +function EnsAvatar({ name }: { name: string }) { + const [imageLoadingStatus, setImageLoadingStatus] = useState< + "idle" | "loading" | "loaded" | "error" + >("idle"); + const { data: avatarUrlData, isLoading: isAvatarUrlLoading } = useAvatarUrl({ + name, + }); + + // Show loading state while fetching avatar URL + if (isAvatarUrlLoading || !avatarUrlData) { + return
; + } + + // No avatar available - show fallback + if (avatarUrlData.browserSupportedAvatarUrl === null) { + return ( +
+ ); + } + + const avatarUrl = avatarUrlData.browserSupportedAvatarUrl; + + return ( +
+ {`${name} setImageLoadingStatus("loaded")} + onError={() => setImageLoadingStatus("error")} + /> + + {/* Show loading state while image is loading */} + {(imageLoadingStatus === "idle" || imageLoadingStatus === "loading") && ( +
+ )} + + {/* Show fallback if image fails to load */} + {imageLoadingStatus === "error" && ( +
+ )} +
+ ); +} +``` + +
+ +
+Custom Fallback Example + +```tsx +import { + useAvatarUrl, + buildEnsMetadataServiceAvatarUrl, + toBrowserSupportedUrl, +} from "@ensnode/ensnode-react"; + +function ProfileAvatar({ + name, + namespaceId, +}: { + name: string; + namespaceId: string; +}) { + const { data, isLoading } = useAvatarUrl({ + name, + browserUnsupportedProtocolFallback: async (name) => { + // Option 1: Use ENS Metadata Service for a specific namespace + return buildEnsMetadataServiceAvatarUrl(name, namespaceId); + + // Option 2: Use your own custom IPFS gateway + // const avatarRecord = /* get from somewhere */; + // if (avatarRecord.startsWith('ipfs://')) { + // const ipfsHash = avatarRecord.replace('ipfs://', ''); + // return toBrowserSupportedUrl(`https://my-gateway.io/ipfs/${ipfsHash}`); + // } + // return null; + }, + }); + + if (isLoading || !data) { + return
; + } + + return ( +
+ {!data.browserSupportedAvatarUrl ? ( +
+ ) : ( + <> + {`${name} + {data.fromFallback && Via Fallback} + + )} +
+ ); +} +``` + +
+ ## Advanced Usage ### Custom Query Configuration @@ -284,7 +494,7 @@ const [address, setAddress] = useState(""); // only executes when address is not null const { data } = usePrimaryName({ address: address || null, - chainId: 1 + chainId: 1, }); ``` From a7b412b0bdb9519ed789e3f27ddafdbe8588ac27 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 16:13:45 +0100 Subject: [PATCH 32/73] Apply suggestions from code review Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- packages/ensnode-react/README.md | 26 +++++++++---------- .../ensnode-react/src/hooks/useAvatarUrl.ts | 24 ++++++++--------- packages/ensnode-sdk/src/shared/url.ts | 2 +- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/ensnode-react/README.md b/packages/ensnode-react/README.md index 11c3b6d40..3f69b079a 100644 --- a/packages/ensnode-react/README.md +++ b/packages/ensnode-react/README.md @@ -253,7 +253,7 @@ const { data, isLoading, error, refetch } = usePrimaryNames({ ### `useAvatarUrl` -Hook that resolves the avatar URL for an ENS name. This hook automatically handles the avatar text record resolution and provides browser-compatible URLs. +Hook that resolves the avatar URL for an ENS name. This hook automatically handles the avatar text record resolution and provides browser-compatible URLs (potentially using a proxy). #### Resolution Flow @@ -261,21 +261,21 @@ The hook follows this resolution process: 1. **Fetches the avatar text record** using `useRecords` internally 2. **Normalizes the avatar text record** as a URL -3. **Returns the URL directly** if it uses http or https protocol -4. **Falls back to ENS Metadata Service** (or custom fallback) for non-http/https protocols (e.g., `ipfs://`, `ar://`, `eip155://`) +3. **Returns the normalized URL directly** if it uses http or https protocol +4. **Falls back to ENS Metadata Service** (or custom proxy) if the avatar text record is a valid url, but uses non-http/https protocols (e.g., `ipfs://`, `ar://`, `eip155://`) -The ENS Metadata Service acts as a proxy, converting decentralized storage protocols like IPFS and Arweave to browser-accessible URLs. +The ENS Metadata Service can be used as a proxy for loading avatar images when the related avatar text records use non-browser-supported protocols. #### Invariants - **If `rawAvatarUrl` is `null`, then `browserSupportedAvatarUrl` must also be `null`** - The `browserSupportedAvatarUrl` is guaranteed to use http or https protocol when non-null -- The `fromFallback` flag is `true` only when a fallback successfully resolved the URL +- The `usesProxy ` flag is `true` if `browserSupportedAvatarUrl` will use the configured proxy. #### Parameters - `name`: The ENS Name whose avatar URL to resolve (set to `null` to disable the query) -- `browserUnsupportedProtocolFallback`: (optional) Custom fallback function for non-http/https protocols. Must return a `BrowserSupportedAssetUrl` created using `toBrowserSupportedUrl()` or from `buildEnsMetadataServiceAvatarUrl()`. Defaults to using the ENS Metadata Service. +- `browserSupportedAvatarUrlProxy `: (optional) Custom function to build a proxy URL for loading the avatar image for a name who's avatar text records that are formatted as valid URLs but use non-http/https protocols. Must return a `BrowserSupportedAssetUrl` created using `toBrowserSupportedUrl()` or from `buildEnsMetadataServiceAvatarUrl()`. Defaults to using the ENS Metadata Service. - `query`: (optional) TanStack Query options for customization #### Return Value @@ -284,13 +284,13 @@ The ENS Metadata Service acts as a proxy, converting decentralized storage proto interface UseAvatarUrlResult { rawAvatarUrl: string | null; browserSupportedAvatarUrl: BrowserSupportedAssetUrl | null; - fromFallback: boolean; + usesProxy: boolean; } ``` -- `rawAvatarUrl`: The original avatar text record value from ENS, before any normalization or fallback processing. `null` if no avatar text record is set. -- `browserSupportedAvatarUrl`: A browser-supported (http/https) avatar URL ready for use in `` tags. `null` if no avatar is set, or if the avatar uses a non-http/https protocol and the fallback fails to resolve. -- `fromFallback`: Indicates whether the `browserSupportedAvatarUrl` was obtained via the fallback mechanism. +- `rawAvatarUrl`: The original avatar text record value from ENS, before any normalization or proxy processing. `null` if no avatar text record is set. +- `browserSupportedAvatarUrl`: A browser-supported (http/https) avatar URL ready for use in `` tags. `null` if no avatar is set, or if the avatar uses a non-http/https protocol and no proxy url is available. +- `usesProxy `: Indicates if the `browserSupportedAvatarUrl` uses the configured proxy.
Basic Example @@ -378,7 +378,7 @@ function EnsAvatar({ name }: { name: string }) {
-Custom Fallback Example +Custom Proxy Example ```tsx import { @@ -391,7 +391,7 @@ function ProfileAvatar({ name, namespaceId, }: { - name: string; + name: Name; namespaceId: string; }) { const { data, isLoading } = useAvatarUrl({ @@ -424,7 +424,7 @@ function ProfileAvatar({ src={data.browserSupportedAvatarUrl.toString()} alt={`${name} avatar`} /> - {data.fromFallback && Via Fallback} + {data.usesProxy && Uses Proxy} )}
diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 1f8ee1fac..15b9e3e6f 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -31,16 +31,16 @@ export interface UseAvatarUrlParameters extends QueryParameter, C * Optional custom fallback function to get avatar URL when the avatar text record * uses a non-http/https protocol (e.g., ipfs://, ar://, eip155:/). * - * If not provided, defaults to using the ENS Metadata Service as a fallback proxy for browser-supported avatar urls. + * If undefined, defaults to using the ENS Metadata Service as a proxy for browser-supported avatar urls. * * IMPORTANT: Custom implementations MUST use `toBrowserSupportedUrl()` to create BrowserSupportedAssetUrl values, * or return the result from `buildEnsMetadataServiceAvatarUrl()`. The returned URL is validated at runtime * to ensure it passes the `isHttpProtocol` check. * - * @param name - The ENS name to get the avatar URL for + * @param name - The ENS name to get the browser supported avatar URL for * @returns Promise resolving to the browser supported avatar URL, or null if unavailable */ - browserUnsupportedProtocolFallback?: (name: Name) => Promise; + browserSupportedAvatarUrlProxy?: (name: Name) => Promise; } /** @@ -114,7 +114,7 @@ export interface UseAvatarUrlResult { * function ProfileAvatar({ name, namespaceId }: { name: string; namespaceId: string }) { * const { data, isLoading } = useAvatarUrl({ * name, - * browserUnsupportedProtocolFallback: async (name) => { + * browserSupportedAvatarUrlProxy: async (name) => { * // Use the ENS Metadata Service for the current namespace * return buildEnsMetadataServiceAvatarUrl(name, namespaceId); * } @@ -201,27 +201,27 @@ export function useAvatarUrl( fromFallback: false, }; } catch { - // Continue to fallback handling below + // Continue to proxy handling below } - // Create default fallback using ENS Metadata Service - const defaultFallback = async (name: Name): Promise => { + // Default proxy is to use the ENS Metadata Service + const defaultProxy = async (name: Name): Promise => { return buildEnsMetadataServiceAvatarUrl(name, namespaceId); }; - // Use custom fallback if provided, otherwise use default - const activeFallback: (name: Name) => Promise = + // Use custom proxy if provided, otherwise use default + const activeProxy: (name: Name) => Promise = browserUnsupportedProtocolFallback ?? defaultFallback; - // For other protocols (ipfs, data, NFT URIs, etc.), use fallback if available + // For other protocols (ipfs, data, NFT URIs, etc.), use proxy if available if (activeFallback) { try { - const fallbackUrl = await activeFallback(name); + const proxyUrl = await activeFallback(name); // Invariant: BrowserSupportedAssetUrl must pass isHttpProtocol check if (fallbackUrl !== null && !isHttpProtocol(fallbackUrl)) { throw new Error( - `browserUnsupportedProtocolFallback returned a URL with unsupported protocol: ${fallbackUrl.protocol}. BrowserSupportedAssetUrl must use http or https protocol.`, + `browserSupportedAvatarUrlProxy returned a URL with unsupported protocol: ${fallbackUrl.protocol}. BrowserSupportedAssetUrl must use http or https protocol.`, ); } diff --git a/packages/ensnode-sdk/src/shared/url.ts b/packages/ensnode-sdk/src/shared/url.ts index 82e14b564..fed84e618 100644 --- a/packages/ensnode-sdk/src/shared/url.ts +++ b/packages/ensnode-sdk/src/shared/url.ts @@ -39,7 +39,7 @@ export function isWebSocketProtocol(url: URL): boolean { * This function strictly validates that the URL uses a browser-supported protocol (http/https) * before returning it as a BrowserSupportedAssetUrl type. * - * @param urlString - The URL string to validate (must include protocol) + * @param urlString - The URL string to validate and convert * @returns A BrowserSupportedAssetUrl if the protocol is http/https * @throws if the URL string cannot be successfully converted to a BrowserSupportedAssetUrl */ From 7abe5019bc725613a8290a6fa603907fce1c3c77 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 16:35:50 +0100 Subject: [PATCH 33/73] apply missed usesProxy --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 15b9e3e6f..b829e1164 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -62,12 +62,12 @@ export interface UseAvatarUrlResult { */ browserSupportedAvatarUrl: BrowserSupportedAssetUrl | null; /** - * Indicates whether the browserSupportedAvatarUrl was obtained via the fallback mechanism. - * True if a fallback successfully resolved the URL. + * Indicates whether the browserSupportedAvatarUrl was obtained via a proxy service. + * True if a proxy (either the default ENS Metadata Service or a custom proxy) successfully resolved the URL. * False if the URL was used directly from the avatar text record, or if there's no avatar, - * or if the fallback failed to resolve. + * or if the proxy failed to resolve. */ - fromFallback: boolean; + usesProxy: boolean; } /** @@ -174,7 +174,7 @@ export function useAvatarUrl( return { rawAvatarUrl: null, browserSupportedAvatarUrl: null, - fromFallback: false, + usesProxy: false, }; } @@ -188,7 +188,7 @@ export function useAvatarUrl( return { rawAvatarUrl: null, browserSupportedAvatarUrl: null, - fromFallback: false, + usesProxy: false, }; } @@ -198,7 +198,7 @@ export function useAvatarUrl( return { rawAvatarUrl: avatarTextRecord, browserSupportedAvatarUrl: browserSupportedUrl, - fromFallback: false, + usesProxy: false, }; } catch { // Continue to proxy handling below @@ -228,13 +228,13 @@ export function useAvatarUrl( return { rawAvatarUrl: avatarTextRecord, browserSupportedAvatarUrl: fallbackUrl, - fromFallback: fallbackUrl !== null, + usesProxy: fallbackUrl !== null, }; } catch { return { rawAvatarUrl: avatarTextRecord, browserSupportedAvatarUrl: null, - fromFallback: false, + usesProxy: false, }; } } @@ -243,14 +243,14 @@ export function useAvatarUrl( return { rawAvatarUrl: avatarTextRecord, browserSupportedAvatarUrl: null, - fromFallback: false, + usesProxy: false, }; }, retry: false, placeholderData: { rawAvatarUrl: null, browserSupportedAvatarUrl: null, - fromFallback: false, + usesProxy: false, } as const, }; From 4c9fd42c9ca3ed89c8721a1c4ed04f0fc942970c Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 16:40:19 +0100 Subject: [PATCH 34/73] apply feedback --- .../app/name/[name]/_components/ProfileHeader.tsx | 3 --- packages/ensnode-react/README.md | 15 ++++++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx b/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx index 47cd6e8d1..fa9ab425d 100644 --- a/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx +++ b/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx @@ -4,7 +4,6 @@ import { EnsAvatar } from "@/components/ens-avatar"; import { ExternalLinkWithIcon } from "@/components/external-link-with-icon"; import { NameDisplay } from "@/components/identity/utils"; import { Card, CardContent } from "@/components/ui/card"; -import { useActiveNamespace } from "@/hooks/active/use-active-namespace"; import { beautifyUrl } from "@/lib/beautify-url"; import { Name } from "@ensnode/ensnode-sdk"; @@ -15,8 +14,6 @@ interface ProfileHeaderProps { } export function ProfileHeader({ name, headerImage, websiteUrl }: ProfileHeaderProps) { - const namespace = useActiveNamespace(); - // Parse header image URI and only use it if it's HTTP/HTTPS // TODO: Add support for more URI types as defined in ENSIP-12 // See: https://docs.ens.domains/ensip/12#uri-types diff --git a/packages/ensnode-react/README.md b/packages/ensnode-react/README.md index 3f69b079a..220eb5093 100644 --- a/packages/ensnode-react/README.md +++ b/packages/ensnode-react/README.md @@ -125,8 +125,9 @@ function DisplayPrimaryNames() { ```tsx import { useAvatarUrl } from "@ensnode/ensnode-react"; +import { Name } from "@ensnode/ensnode-sdk"; -function ProfileAvatar({ name }: { name: string }) { +function ProfileAvatar({ name }: { name: Name }) { const { data, isLoading } = useAvatarUrl({ name }); if (isLoading || !data) { @@ -297,8 +298,9 @@ interface UseAvatarUrlResult { ```tsx import { useAvatarUrl } from "@ensnode/ensnode-react"; +import { Name } from "@ensnode/ensnode-sdk"; -function ProfileAvatar({ name }: { name: string }) { +function ProfileAvatar({ name }: { name: Name }) { const { data, isLoading } = useAvatarUrl({ name }); if (isLoading || !data) { @@ -308,7 +310,7 @@ function ProfileAvatar({ name }: { name: string }) { return (
{!data.browserSupportedAvatarUrl ? ( -
No avatar
+
) : ( ("idle"); @@ -386,13 +389,15 @@ import { buildEnsMetadataServiceAvatarUrl, toBrowserSupportedUrl, } from "@ensnode/ensnode-react"; +import { Name } from "@ensnode/ensnode-sdk"; +import { ENSNamespaceId } from "@ensnode/datasources"; function ProfileAvatar({ name, namespaceId, }: { name: Name; - namespaceId: string; + namespaceId: ENSNamespaceId; }) { const { data, isLoading } = useAvatarUrl({ name, From 34d131be103f9804ac580d799f290d5588234181 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 16:42:21 +0100 Subject: [PATCH 35/73] apply example feedback --- packages/ensnode-react/README.md | 34 ++++++++++---------------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/packages/ensnode-react/README.md b/packages/ensnode-react/README.md index 220eb5093..ebc352d73 100644 --- a/packages/ensnode-react/README.md +++ b/packages/ensnode-react/README.md @@ -384,34 +384,20 @@ function EnsAvatar({ name }: { name: Name }) { Custom Proxy Example ```tsx -import { - useAvatarUrl, - buildEnsMetadataServiceAvatarUrl, - toBrowserSupportedUrl, -} from "@ensnode/ensnode-react"; +import { useAvatarUrl, toBrowserSupportedUrl } from "@ensnode/ensnode-react"; import { Name } from "@ensnode/ensnode-sdk"; -import { ENSNamespaceId } from "@ensnode/datasources"; - -function ProfileAvatar({ - name, - namespaceId, -}: { - name: Name; - namespaceId: ENSNamespaceId; -}) { + +function ProfileAvatar({ name }: { name: Name }) { const { data, isLoading } = useAvatarUrl({ name, browserUnsupportedProtocolFallback: async (name) => { - // Option 1: Use ENS Metadata Service for a specific namespace - return buildEnsMetadataServiceAvatarUrl(name, namespaceId); - - // Option 2: Use your own custom IPFS gateway - // const avatarRecord = /* get from somewhere */; - // if (avatarRecord.startsWith('ipfs://')) { - // const ipfsHash = avatarRecord.replace('ipfs://', ''); - // return toBrowserSupportedUrl(`https://my-gateway.io/ipfs/${ipfsHash}`); - // } - // return null; + // Use your own custom IPFS gateway + const avatarRecord = /* get from somewhere */; + if (avatarRecord.startsWith('ipfs://')) { + const ipfsHash = avatarRecord.replace('ipfs://', ''); + return toBrowserSupportedUrl(`https://my-gateway.io/ipfs/${ipfsHash}`); + } + return null; }, }); From 22aab5f25b45ffba1fcb85c8a3f472add0cb1be2 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 16:53:26 +0100 Subject: [PATCH 36/73] fix async proxy --- packages/ensnode-react/README.md | 8 ++-- .../ensnode-react/src/hooks/useAvatarUrl.ts | 44 +++++++++++-------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/packages/ensnode-react/README.md b/packages/ensnode-react/README.md index ebc352d73..bc9d87f1e 100644 --- a/packages/ensnode-react/README.md +++ b/packages/ensnode-react/README.md @@ -390,13 +390,13 @@ import { Name } from "@ensnode/ensnode-sdk"; function ProfileAvatar({ name }: { name: Name }) { const { data, isLoading } = useAvatarUrl({ name, - browserUnsupportedProtocolFallback: async (name) => { + browserSupportedAvatarUrlProxy: (name, rawAvatarUrl) => { // Use your own custom IPFS gateway - const avatarRecord = /* get from somewhere */; - if (avatarRecord.startsWith('ipfs://')) { - const ipfsHash = avatarRecord.replace('ipfs://', ''); + if (rawAvatarUrl.startsWith("ipfs://")) { + const ipfsHash = rawAvatarUrl.replace("ipfs://", ""); return toBrowserSupportedUrl(`https://my-gateway.io/ipfs/${ipfsHash}`); } + // Could handle other protocols like ar://, eip155://, etc. return null; }, }); diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index b829e1164..7d6775e3b 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -28,7 +28,7 @@ export interface UseAvatarUrlParameters extends QueryParameter, C */ name: Name | null; /** - * Optional custom fallback function to get avatar URL when the avatar text record + * Optional custom proxy function to get avatar URL when the avatar text record * uses a non-http/https protocol (e.g., ipfs://, ar://, eip155:/). * * If undefined, defaults to using the ENS Metadata Service as a proxy for browser-supported avatar urls. @@ -38,9 +38,13 @@ export interface UseAvatarUrlParameters extends QueryParameter, C * to ensure it passes the `isHttpProtocol` check. * * @param name - The ENS name to get the browser supported avatar URL for - * @returns Promise resolving to the browser supported avatar URL, or null if unavailable + * @param rawAvatarUrl - The original avatar text record value, allowing protocol-specific logic (e.g., ipfs:// vs ar://) + * @returns The browser supported avatar URL, or null if unavailable */ - browserSupportedAvatarUrlProxy?: (name: Name) => Promise; + browserSupportedAvatarUrlProxy?: ( + name: Name, + rawAvatarUrl: string, + ) => BrowserSupportedAssetUrl | null; } /** @@ -108,15 +112,19 @@ export interface UseAvatarUrlResult { * * @example * ```typescript - * // With custom fallback - * import { useAvatarUrl, buildEnsMetadataServiceAvatarUrl } from "@ensnode/ensnode-react"; + * // With custom proxy + * import { useAvatarUrl, toBrowserSupportedUrl } from "@ensnode/ensnode-react"; * - * function ProfileAvatar({ name, namespaceId }: { name: string; namespaceId: string }) { + * function ProfileAvatar({ name }: { name: string }) { * const { data, isLoading } = useAvatarUrl({ * name, - * browserSupportedAvatarUrlProxy: async (name) => { - * // Use the ENS Metadata Service for the current namespace - * return buildEnsMetadataServiceAvatarUrl(name, namespaceId); + * browserSupportedAvatarUrlProxy: (name, rawAvatarUrl) => { + * // Use your own custom IPFS gateway + * if (rawAvatarUrl.startsWith('ipfs://')) { + * const ipfsHash = rawAvatarUrl.replace('ipfs://', ''); + * return toBrowserSupportedUrl(`https://my-gateway.io/ipfs/${ipfsHash}`); + * } + * return null; * } * }); * @@ -205,30 +213,30 @@ export function useAvatarUrl( } // Default proxy is to use the ENS Metadata Service - const defaultProxy = async (name: Name): Promise => { + const defaultProxy = (name: Name, rawAvatarUrl: string): BrowserSupportedAssetUrl | null => { return buildEnsMetadataServiceAvatarUrl(name, namespaceId); }; // Use custom proxy if provided, otherwise use default - const activeProxy: (name: Name) => Promise = - browserUnsupportedProtocolFallback ?? defaultFallback; + const activeProxy: (name: Name, rawAvatarUrl: string) => BrowserSupportedAssetUrl | null = + browserUnsupportedProtocolFallback ?? defaultProxy; // For other protocols (ipfs, data, NFT URIs, etc.), use proxy if available - if (activeFallback) { + if (activeProxy) { try { - const proxyUrl = await activeFallback(name); + const proxyUrl = activeProxy(name, avatarTextRecord); // Invariant: BrowserSupportedAssetUrl must pass isHttpProtocol check - if (fallbackUrl !== null && !isHttpProtocol(fallbackUrl)) { + if (proxyUrl !== null && !isHttpProtocol(proxyUrl)) { throw new Error( - `browserSupportedAvatarUrlProxy returned a URL with unsupported protocol: ${fallbackUrl.protocol}. BrowserSupportedAssetUrl must use http or https protocol.`, + `browserSupportedAvatarUrlProxy returned a URL with unsupported protocol: ${proxyUrl.protocol}. BrowserSupportedAssetUrl must use http or https protocol.`, ); } return { rawAvatarUrl: avatarTextRecord, - browserSupportedAvatarUrl: fallbackUrl, - usesProxy: fallbackUrl !== null, + browserSupportedAvatarUrl: proxyUrl, + usesProxy: proxyUrl !== null, }; } catch { return { From c230242b8a81b31c855d930604945ace0e66e809 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 16:54:48 +0100 Subject: [PATCH 37/73] more fallback > proxy terminology --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 7d6775e3b..cffacd036 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -54,15 +54,15 @@ export interface UseAvatarUrlParameters extends QueryParameter, C */ export interface UseAvatarUrlResult { /** - * The original avatar text record value from ENS, before any normalization or fallback processing. + * The original avatar text record value from ENS, before any normalization or proxy processing. * Null if the avatar text record is not set for the ENS name. */ rawAvatarUrl: string | null; /** * A browser-supported (http/https) avatar URL ready for use in tags. - * Populated when the avatar uses http/https protocol or when a fallback successfully resolves. + * Populated when the avatar uses http/https protocol or when a proxy successfully resolves it. * Null if the avatar text record is not set, or if the avatar uses a non-http/https protocol - * and either no fallback is available or the fallback fails to resolve. + * and either no proxy is available or the proxy fails to resolve it. */ browserSupportedAvatarUrl: BrowserSupportedAssetUrl | null; /** From b0c6adce60463ab6c33011c703a6d3d1937067f8 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 16:57:06 +0100 Subject: [PATCH 38/73] toBrowserSupportedUrl jsdoc improvements --- packages/ensnode-sdk/src/shared/url.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/ensnode-sdk/src/shared/url.ts b/packages/ensnode-sdk/src/shared/url.ts index fed84e618..4963a2af8 100644 --- a/packages/ensnode-sdk/src/shared/url.ts +++ b/packages/ensnode-sdk/src/shared/url.ts @@ -36,12 +36,26 @@ export function isWebSocketProtocol(url: URL): boolean { /** * Validates and converts a URL string to a BrowserSupportedAssetUrl. * - * This function strictly validates that the URL uses a browser-supported protocol (http/https) + * This function normalizes the URL string (adding 'https://' if no protocol is specified), + * then validates that the resulting URL uses a browser-supported protocol (http/https) * before returning it as a BrowserSupportedAssetUrl type. * - * @param urlString - The URL string to validate and convert - * @returns A BrowserSupportedAssetUrl if the protocol is http/https - * @throws if the URL string cannot be successfully converted to a BrowserSupportedAssetUrl + * @param urlString - The URL string to validate and convert. If no protocol is specified, 'https://' will be prepended. + * @returns A BrowserSupportedAssetUrl if the protocol is (or becomes) http/https + * @throws if the URL string is invalid or uses a non-http/https protocol + * + * @example + * ```typescript + * // Explicit protocol - no transformation + * toBrowserSupportedUrl('https://example.com') // returns URL with https:// + * toBrowserSupportedUrl('http://example.com') // returns URL with http:// + * + * // Implicit protocol - adds https:// + * toBrowserSupportedUrl('example.com') // returns URL with https://example.com + * + * // Non-browser-supported protocols - throws error + * toBrowserSupportedUrl('ipfs://QmHash') // throws Error + * ``` */ export function toBrowserSupportedUrl(urlString: string): BrowserSupportedAssetUrl { const url = buildUrl(urlString); From 4c98d736893cd29cc5404536d78ed52d9f8339e0 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 17:00:40 +0100 Subject: [PATCH 39/73] catch and buildUrl --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index cffacd036..5ff053d05 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -4,6 +4,7 @@ import { type BrowserSupportedAssetUrl, type Name, buildEnsMetadataServiceAvatarUrl, + buildUrl, isHttpProtocol, toBrowserSupportedUrl, } from "@ensnode/ensnode-sdk"; @@ -61,8 +62,8 @@ export interface UseAvatarUrlResult { /** * A browser-supported (http/https) avatar URL ready for use in tags. * Populated when the avatar uses http/https protocol or when a proxy successfully resolves it. - * Null if the avatar text record is not set, or if the avatar uses a non-http/https protocol - * and either no proxy is available or the proxy fails to resolve it. + * Null if the avatar text record is not set, if the avatar text record is malformed/invalid, + * or if the avatar uses a non-http/https protocol and either no proxy is available or the proxy fails to resolve it. */ browserSupportedAvatarUrl: BrowserSupportedAssetUrl | null; /** @@ -81,8 +82,9 @@ export interface UseAvatarUrlResult { * 1. Fetching the avatar text record using useRecords * 2. Normalizing the avatar text record as a URL * 3. Returning the URL if it uses http or https protocol - * 4. Falling back to the ENS Metadata Service (which proxies decentralized storage protocols - * like IPFS/Arweave to browser-accessible URLs) or a custom fallback for other protocols + * 4. For valid URLs with unsupported protocols (e.g., ipfs://, ar://), using the ENS Metadata Service + * (or a custom proxy) to convert them to browser-accessible URLs + * 5. For malformed/invalid URLs, returning null without attempting proxy conversion * * @param parameters - Configuration for the avatar URL resolution * @returns Query result with the avatar URL, loading state, and error handling @@ -209,7 +211,21 @@ export function useAvatarUrl( usesProxy: false, }; } catch { - // Continue to proxy handling below + // toBrowserSupportedUrl failed - could be unsupported protocol or malformed URL + // Try to parse as a general URL to determine which case we're in + try { + buildUrl(avatarTextRecord); + // buildUrl succeeded, so the avatar text record is a valid URL with an unsupported protocol + // Continue to proxy handling below + } catch { + // buildUrl failed, so the avatar text record is malformed/invalid + // Skip proxy logic and return null + return { + rawAvatarUrl: avatarTextRecord, + browserSupportedAvatarUrl: null, + usesProxy: false, + }; + } } // Default proxy is to use the ENS Metadata Service From 1faed006f0ae40e6aaaadd7c748a528e61968856 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 17:03:32 +0100 Subject: [PATCH 40/73] fix: use browserSupportedAvatarUrlProxy --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 5ff053d05..02ca57c44 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -23,7 +23,9 @@ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; /** * Parameters for the useAvatarUrl hook. */ -export interface UseAvatarUrlParameters extends QueryParameter, ConfigParameter { +export interface UseAvatarUrlParameters + extends QueryParameter, + ConfigParameter { /** * If null, the query will not be executed. */ @@ -44,7 +46,7 @@ export interface UseAvatarUrlParameters extends QueryParameter, C */ browserSupportedAvatarUrlProxy?: ( name: Name, - rawAvatarUrl: string, + rawAvatarUrl: string ) => BrowserSupportedAssetUrl | null; } @@ -147,9 +149,14 @@ export interface UseAvatarUrlResult { * ``` */ export function useAvatarUrl( - parameters: UseAvatarUrlParameters, + parameters: UseAvatarUrlParameters ): UseQueryResult { - const { name, config, query: queryOptions, browserUnsupportedProtocolFallback } = parameters; + const { + name, + config, + query: queryOptions, + browserSupportedAvatarUrlProxy, + } = parameters; const _config = useENSNodeConfig(config); const canEnable = name !== null; @@ -176,7 +183,7 @@ export function useAvatarUrl( name, _config.client.url.href, configQuery.data?.namespace, - !!browserUnsupportedProtocolFallback, + !!browserSupportedAvatarUrlProxy, recordsQuery.data?.records?.texts?.avatar ?? null, ] as const, queryFn: async (): Promise => { @@ -229,13 +236,19 @@ export function useAvatarUrl( } // Default proxy is to use the ENS Metadata Service - const defaultProxy = (name: Name, rawAvatarUrl: string): BrowserSupportedAssetUrl | null => { + const defaultProxy = ( + name: Name, + rawAvatarUrl: string + ): BrowserSupportedAssetUrl | null => { return buildEnsMetadataServiceAvatarUrl(name, namespaceId); }; // Use custom proxy if provided, otherwise use default - const activeProxy: (name: Name, rawAvatarUrl: string) => BrowserSupportedAssetUrl | null = - browserUnsupportedProtocolFallback ?? defaultProxy; + const activeProxy: ( + name: Name, + rawAvatarUrl: string + ) => BrowserSupportedAssetUrl | null = + browserSupportedAvatarUrlProxy ?? defaultProxy; // For other protocols (ipfs, data, NFT URIs, etc.), use proxy if available if (activeProxy) { @@ -245,7 +258,7 @@ export function useAvatarUrl( // Invariant: BrowserSupportedAssetUrl must pass isHttpProtocol check if (proxyUrl !== null && !isHttpProtocol(proxyUrl)) { throw new Error( - `browserSupportedAvatarUrlProxy returned a URL with unsupported protocol: ${proxyUrl.protocol}. BrowserSupportedAssetUrl must use http or https protocol.`, + `browserSupportedAvatarUrlProxy returned a URL with unsupported protocol: ${proxyUrl.protocol}. BrowserSupportedAssetUrl must use http or https protocol.` ); } From 1cec6c9f589548020a19c579b8e0004c6f46bb3e Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 17:09:49 +0100 Subject: [PATCH 41/73] lint --- .../src/components/identity/index.tsx | 2 +- packages/ensnode-react/src/hooks/index.ts | 1 - .../ensnode-react/src/hooks/useAvatarUrl.ts | 27 +++++-------------- 3 files changed, 8 insertions(+), 22 deletions(-) diff --git a/apps/ensadmin/src/components/identity/index.tsx b/apps/ensadmin/src/components/identity/index.tsx index 863963c95..50dd20591 100644 --- a/apps/ensadmin/src/components/identity/index.tsx +++ b/apps/ensadmin/src/components/identity/index.tsx @@ -142,4 +142,4 @@ export function DisplayIdentity({ } return result; -} \ No newline at end of file +} diff --git a/packages/ensnode-react/src/hooks/index.ts b/packages/ensnode-react/src/hooks/index.ts index 85d266c8c..83bb3c4d2 100644 --- a/packages/ensnode-react/src/hooks/index.ts +++ b/packages/ensnode-react/src/hooks/index.ts @@ -9,4 +9,3 @@ export * from "./useResolvedIdentity"; // Re-export BrowserSupportedAssetUrl for convenience export type { BrowserSupportedAssetUrl } from "@ensnode/ensnode-sdk"; - diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 02ca57c44..912fe1efc 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -23,9 +23,7 @@ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; /** * Parameters for the useAvatarUrl hook. */ -export interface UseAvatarUrlParameters - extends QueryParameter, - ConfigParameter { +export interface UseAvatarUrlParameters extends QueryParameter, ConfigParameter { /** * If null, the query will not be executed. */ @@ -46,7 +44,7 @@ export interface UseAvatarUrlParameters */ browserSupportedAvatarUrlProxy?: ( name: Name, - rawAvatarUrl: string + rawAvatarUrl: string, ) => BrowserSupportedAssetUrl | null; } @@ -149,14 +147,9 @@ export interface UseAvatarUrlResult { * ``` */ export function useAvatarUrl( - parameters: UseAvatarUrlParameters + parameters: UseAvatarUrlParameters, ): UseQueryResult { - const { - name, - config, - query: queryOptions, - browserSupportedAvatarUrlProxy, - } = parameters; + const { name, config, query: queryOptions, browserSupportedAvatarUrlProxy } = parameters; const _config = useENSNodeConfig(config); const canEnable = name !== null; @@ -236,18 +229,12 @@ export function useAvatarUrl( } // Default proxy is to use the ENS Metadata Service - const defaultProxy = ( - name: Name, - rawAvatarUrl: string - ): BrowserSupportedAssetUrl | null => { + const defaultProxy = (name: Name, rawAvatarUrl: string): BrowserSupportedAssetUrl | null => { return buildEnsMetadataServiceAvatarUrl(name, namespaceId); }; // Use custom proxy if provided, otherwise use default - const activeProxy: ( - name: Name, - rawAvatarUrl: string - ) => BrowserSupportedAssetUrl | null = + const activeProxy: (name: Name, rawAvatarUrl: string) => BrowserSupportedAssetUrl | null = browserSupportedAvatarUrlProxy ?? defaultProxy; // For other protocols (ipfs, data, NFT URIs, etc.), use proxy if available @@ -258,7 +245,7 @@ export function useAvatarUrl( // Invariant: BrowserSupportedAssetUrl must pass isHttpProtocol check if (proxyUrl !== null && !isHttpProtocol(proxyUrl)) { throw new Error( - `browserSupportedAvatarUrlProxy returned a URL with unsupported protocol: ${proxyUrl.protocol}. BrowserSupportedAssetUrl must use http or https protocol.` + `browserSupportedAvatarUrlProxy returned a URL with unsupported protocol: ${proxyUrl.protocol}. BrowserSupportedAssetUrl must use http or https protocol.`, ); } From 5d484b907670211bce4f14c4514aade9c0ed4ead Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 17:37:38 +0100 Subject: [PATCH 42/73] fix merge conflict --- .../src/app/name/[name]/_components/ProfileHeader.tsx | 11 ++++++++--- apps/ensadmin/src/app/name/[name]/page.tsx | 1 + apps/ensadmin/src/components/ens-avatar.tsx | 9 +++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx b/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx index ff6b4ded6..b38c8a177 100644 --- a/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx +++ b/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx @@ -5,15 +5,16 @@ import { NameDisplay } from "@/components/identity/utils"; import { ExternalLinkWithIcon } from "@/components/link"; import { Card, CardContent } from "@/components/ui/card"; import { beautifyUrl } from "@/lib/beautify-url"; -import { Name } from "@ensnode/ensnode-sdk"; +import { ENSNamespaceId, Name } from "@ensnode/ensnode-sdk"; interface ProfileHeaderProps { name: Name; + namespaceId: ENSNamespaceId; headerImage?: string | null; websiteUrl?: string | null; } -export function ProfileHeader({ name, headerImage, websiteUrl }: ProfileHeaderProps) { +export function ProfileHeader({ name, namespaceId, headerImage, websiteUrl }: ProfileHeaderProps) { // Parse header image URI and only use it if it's HTTP/HTTPS // TODO: Add support for more URI types as defined in ENSIP-12 // See: https://docs.ens.domains/ensip/12#uri-types @@ -65,7 +66,11 @@ export function ProfileHeader({ name, headerImage, websiteUrl }: ProfileHeaderPr
- +

diff --git a/apps/ensadmin/src/app/name/[name]/page.tsx b/apps/ensadmin/src/app/name/[name]/page.tsx index 9ed6d83cd..906e3f413 100644 --- a/apps/ensadmin/src/app/name/[name]/page.tsx +++ b/apps/ensadmin/src/app/name/[name]/page.tsx @@ -78,6 +78,7 @@ export default function NameDetailPage() {
diff --git a/apps/ensadmin/src/components/ens-avatar.tsx b/apps/ensadmin/src/components/ens-avatar.tsx index 182a40804..fe40b478a 100644 --- a/apps/ensadmin/src/components/ens-avatar.tsx +++ b/apps/ensadmin/src/components/ens-avatar.tsx @@ -2,12 +2,13 @@ import { Avatar, AvatarImage } from "@/components/ui/avatar"; import { useAvatarUrl } from "@ensnode/ensnode-react"; -import { Name } from "@ensnode/ensnode-sdk"; +import { ENSNamespaceId, Name, buildEnsMetadataServiceAvatarUrl } from "@ensnode/ensnode-sdk"; import BoringAvatar from "boring-avatars"; import * as React from "react"; interface EnsAvatarProps { name: Name; + namespaceId: ENSNamespaceId; className?: string; } @@ -42,11 +43,15 @@ type ImageLoadingStatus = Parameters< * * ``` */ -export const EnsAvatar = ({ name, className }: EnsAvatarProps) => { +export const EnsAvatar = ({ name, namespaceId, className }: EnsAvatarProps) => { const [imageLoadingStatus, setImageLoadingStatus] = React.useState("idle"); const { data: avatarUrlData, isLoading: isAvatarUrlLoading } = useAvatarUrl({ name, + browserSupportedAvatarUrlProxy: React.useCallback( + (name: Name) => buildEnsMetadataServiceAvatarUrl(name, namespaceId), + [namespaceId], + ), }); // Show loading state while fetching avatar URL or if data is not yet available From 9675b641f1f9a8d91200146c18685984b7f3aa10 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 19:09:01 +0100 Subject: [PATCH 43/73] Update packages/ensnode-react/README.md Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- packages/ensnode-react/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensnode-react/README.md b/packages/ensnode-react/README.md index bc9d87f1e..6c8725032 100644 --- a/packages/ensnode-react/README.md +++ b/packages/ensnode-react/README.md @@ -290,7 +290,7 @@ interface UseAvatarUrlResult { ``` - `rawAvatarUrl`: The original avatar text record value from ENS, before any normalization or proxy processing. `null` if no avatar text record is set. -- `browserSupportedAvatarUrl`: A browser-supported (http/https) avatar URL ready for use in `` tags. `null` if no avatar is set, or if the avatar uses a non-http/https protocol and no proxy url is available. +- `browserSupportedAvatarUrl`: A browser-supported (http/https) avatar URL ready for use in `` tags. `null` if no avatar is set, if the avatar that is set is an invalid URL, or if the avatar uses a non-http/https protocol and no proxy url is available. - `usesProxy `: Indicates if the `browserSupportedAvatarUrl` uses the configured proxy.
From 9933fad7660a7a7f67c90ae68807725fa9dff43d Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 19:15:17 +0100 Subject: [PATCH 44/73] apply doc changes --- apps/ensadmin/src/components/ens-avatar.tsx | 2 +- .../ensnode-react/src/hooks/useAvatarUrl.ts | 38 +++++++++++++------ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/ensadmin/src/components/ens-avatar.tsx b/apps/ensadmin/src/components/ens-avatar.tsx index fe40b478a..749c75671 100644 --- a/apps/ensadmin/src/components/ens-avatar.tsx +++ b/apps/ensadmin/src/components/ens-avatar.tsx @@ -49,7 +49,7 @@ export const EnsAvatar = ({ name, namespaceId, className }: EnsAvatarProps) => { const { data: avatarUrlData, isLoading: isAvatarUrlLoading } = useAvatarUrl({ name, browserSupportedAvatarUrlProxy: React.useCallback( - (name: Name) => buildEnsMetadataServiceAvatarUrl(name, namespaceId), + (name: Name, rawAvatarUrl: string) => buildEnsMetadataServiceAvatarUrl(name, namespaceId), [namespaceId], ), }); diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 912fe1efc..c156a46b7 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -29,8 +29,8 @@ export interface UseAvatarUrlParameters extends QueryParameter, C */ name: Name | null; /** - * Optional custom proxy function to get avatar URL when the avatar text record - * uses a non-http/https protocol (e.g., ipfs://, ar://, eip155:/). + * Optional function to build a BrowserSupportedAssetUrl for a name's avatar image + * when the avatar text record uses a non-http/https protocol (e.g., ipfs://, ar://, eip155:/). * * If undefined, defaults to using the ENS Metadata Service as a proxy for browser-supported avatar urls. * @@ -61,16 +61,16 @@ export interface UseAvatarUrlResult { rawAvatarUrl: string | null; /** * A browser-supported (http/https) avatar URL ready for use in tags. - * Populated when the avatar uses http/https protocol or when a proxy successfully resolves it. + * Populated when the rawAvatarUrl is a valid URL that uses the http/https protocol or when a url is available to load the avatar using a proxy. * Null if the avatar text record is not set, if the avatar text record is malformed/invalid, - * or if the avatar uses a non-http/https protocol and either no proxy is available or the proxy fails to resolve it. + * or if the avatar uses a non-http/https protocol and no url is known for how to load the avatar using a proxy. */ browserSupportedAvatarUrl: BrowserSupportedAssetUrl | null; /** - * Indicates whether the browserSupportedAvatarUrl was obtained via a proxy service. - * True if a proxy (either the default ENS Metadata Service or a custom proxy) successfully resolved the URL. - * False if the URL was used directly from the avatar text record, or if there's no avatar, - * or if the proxy failed to resolve. + * Indicates whether the browserSupportedAvatarUrl uses a proxy service. + * True if the url uses a proxy (either the default ENS Metadata Service or a custom proxy). + * False if the URL comes directly from the avatar text record, or if there's no avatar text record, + * or if the avatar text record has an invalid format, or if no url is known for loading the avatar using a proxy. */ usesProxy: boolean; } @@ -114,18 +114,32 @@ export interface UseAvatarUrlResult { * * @example * ```typescript - * // With custom proxy + * // With custom IPFS gateway proxy * import { useAvatarUrl, toBrowserSupportedUrl } from "@ensnode/ensnode-react"; * * function ProfileAvatar({ name }: { name: string }) { * const { data, isLoading } = useAvatarUrl({ * name, * browserSupportedAvatarUrlProxy: (name, rawAvatarUrl) => { - * // Use your own custom IPFS gateway + * // Handle IPFS protocol URLs with a custom gateway * if (rawAvatarUrl.startsWith('ipfs://')) { - * const ipfsHash = rawAvatarUrl.replace('ipfs://', ''); - * return toBrowserSupportedUrl(`https://my-gateway.io/ipfs/${ipfsHash}`); + * // Extract CID and optional path from ipfs://{CID}/{path} + * const ipfsPath = rawAvatarUrl.replace('ipfs://', ''); + * + * // Use ipfs.io public gateway (best-effort, not for production) + * return toBrowserSupportedUrl(`https://ipfs.io/ipfs/${ipfsPath}`); + * + * // Or use your own gateway: + * // return toBrowserSupportedUrl(`https://my-gateway.example.com/ipfs/${ipfsPath}`); * } + * + * // Handle Arweave protocol + * if (rawAvatarUrl.startsWith('ar://')) { + * const arweaveId = rawAvatarUrl.replace('ar://', ''); + * return toBrowserSupportedUrl(`https://arweave.net/${arweaveId}`); + * } + * + * // Fall back to ENS Metadata Service for other protocols * return null; * } * }); From d6bf7cd81cca489ab558de15e7cee4257a87adb0 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 14 Oct 2025 19:15:29 +0100 Subject: [PATCH 45/73] ipfs example --- packages/ensnode-react/README.md | 44 ++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/ensnode-react/README.md b/packages/ensnode-react/README.md index 6c8725032..e0507909c 100644 --- a/packages/ensnode-react/README.md +++ b/packages/ensnode-react/README.md @@ -381,7 +381,9 @@ function EnsAvatar({ name }: { name: Name }) {
-Custom Proxy Example +Custom IPFS Gateway Proxy Example + +When ENS avatars use the IPFS protocol (e.g., `ipfs://QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco`), you can configure a custom IPFS gateway to load the images. This is useful for using your own infrastructure or a preferred public gateway. ```tsx import { useAvatarUrl, toBrowserSupportedUrl } from "@ensnode/ensnode-react"; @@ -391,12 +393,34 @@ function ProfileAvatar({ name }: { name: Name }) { const { data, isLoading } = useAvatarUrl({ name, browserSupportedAvatarUrlProxy: (name, rawAvatarUrl) => { - // Use your own custom IPFS gateway + // Handle IPFS protocol URLs if (rawAvatarUrl.startsWith("ipfs://")) { - const ipfsHash = rawAvatarUrl.replace("ipfs://", ""); - return toBrowserSupportedUrl(`https://my-gateway.io/ipfs/${ipfsHash}`); + // Extract the CID (Content Identifier) from the IPFS URL + // Format: ipfs://{CID} or ipfs://{CID}/{path} + const ipfsPath = rawAvatarUrl.replace("ipfs://", ""); + + // Option 1: Use ipfs.io public gateway (path-based) + // Note: Public gateways are best-effort and not for production + return toBrowserSupportedUrl(`https://ipfs.io/ipfs/${ipfsPath}`); + + // Option 2: Use dweb.link public gateway (subdomain-based, better origin isolation) + // return toBrowserSupportedUrl(`https://dweb.link/ipfs/${ipfsPath}`); + + // Option 3: Use your own IPFS gateway + // return toBrowserSupportedUrl(`https://my-gateway.example.com/ipfs/${ipfsPath}`); + + // Option 4: Use Cloudflare's IPFS gateway + // return toBrowserSupportedUrl(`https://cloudflare-ipfs.com/ipfs/${ipfsPath}`); } - // Could handle other protocols like ar://, eip155://, etc. + + // Handle Arweave protocol URLs (ar://) + if (rawAvatarUrl.startsWith("ar://")) { + const arweaveId = rawAvatarUrl.replace("ar://", ""); + return toBrowserSupportedUrl(`https://arweave.net/${arweaveId}`); + } + + // For other protocols, fall back to ENS Metadata Service + // by returning null (the default behavior) return null; }, }); @@ -423,6 +447,16 @@ function ProfileAvatar({ name }: { name: Name }) { } ``` +**Important Notes:** + +- **IPFS URL Format**: IPFS URLs follow the format `ipfs://{CID}` or `ipfs://{CID}/{path}`, where CID is the Content Identifier (hash) of the content. +- **Public Gateways**: The IPFS Foundation provides public gateways like `ipfs.io` and `dweb.link` on a best-effort basis. These are **not intended for production use** and may throttle or block heavy usage. +- **Production Use**: For production applications, consider: + - Running your own IPFS node and gateway + - Using a commercial IPFS pinning service with gateway access (e.g., Pinata, Infura, Fleek) + - Using a CDN with IPFS support (e.g., Cloudflare) +- **Testing**: To test with real IPFS avatars, you can use ENS names like those that have set IPFS-based avatar records in their resolver. +
## Advanced Usage From 994166cb54c8eec7476d6b9a2beb34fe089b89f9 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Wed, 15 Oct 2025 08:34:16 +0100 Subject: [PATCH 46/73] remove notes --- packages/ensnode-react/README.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/ensnode-react/README.md b/packages/ensnode-react/README.md index e0507909c..f3de5eda5 100644 --- a/packages/ensnode-react/README.md +++ b/packages/ensnode-react/README.md @@ -447,16 +447,6 @@ function ProfileAvatar({ name }: { name: Name }) { } ``` -**Important Notes:** - -- **IPFS URL Format**: IPFS URLs follow the format `ipfs://{CID}` or `ipfs://{CID}/{path}`, where CID is the Content Identifier (hash) of the content. -- **Public Gateways**: The IPFS Foundation provides public gateways like `ipfs.io` and `dweb.link` on a best-effort basis. These are **not intended for production use** and may throttle or block heavy usage. -- **Production Use**: For production applications, consider: - - Running your own IPFS node and gateway - - Using a commercial IPFS pinning service with gateway access (e.g., Pinata, Infura, Fleek) - - Using a CDN with IPFS support (e.g., Cloudflare) -- **Testing**: To test with real IPFS avatars, you can use ENS names like those that have set IPFS-based avatar records in their resolver. - ## Advanced Usage From 6264addad2334b78b7effd22c66fd21e549bb343 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Wed, 15 Oct 2025 10:31:25 +0100 Subject: [PATCH 47/73] handle data protocol --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 177 ++++++++++-------- packages/ensnode-sdk/src/shared/url.ts | 52 ++++- 2 files changed, 144 insertions(+), 85 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index c156a46b7..4c011198b 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -2,6 +2,7 @@ import { type BrowserSupportedAssetUrl, + ENSNamespaceId, type Name, buildEnsMetadataServiceAvatarUrl, buildUrl, @@ -20,6 +21,104 @@ import { useRecords } from "./useRecords"; */ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; +/** + * Resolves an avatar text record to a browser-supported URL. + * This is the core resolution logic extracted for testing. + * + * @param avatarTextRecord - The raw avatar text record value from ENS + * @param name - The ENS name + * @param namespaceId - The ENS namespace ID + * @param browserSupportedAvatarUrlProxy - Optional custom proxy function + * @returns The resolved avatar URL result + * @internal + */ +export function resolveAvatarUrl( + avatarTextRecord: string | null, + name: Name, + namespaceId: ENSNamespaceId, + browserSupportedAvatarUrlProxy?: ( + name: Name, + rawAvatarUrl: string, + ) => BrowserSupportedAssetUrl | null, +): UseAvatarUrlResult { + // If no avatar text record, return null values + if (!avatarTextRecord) { + return { + rawAvatarUrl: null, + browserSupportedAvatarUrl: null, + usesProxy: false, + }; + } + + try { + const browserSupportedUrl = toBrowserSupportedUrl(avatarTextRecord); + + return { + rawAvatarUrl: avatarTextRecord, + browserSupportedAvatarUrl: browserSupportedUrl, + usesProxy: false, + }; + } catch { + // toBrowserSupportedUrl failed - could be unsupported protocol or malformed URL + // Try to parse as a general URL to determine which case we're in + try { + buildUrl(avatarTextRecord); + // buildUrl succeeded, so the avatar text record is a valid URL with an unsupported protocol + // Continue to proxy handling below + } catch { + // buildUrl failed, so the avatar text record is malformed/invalid + // Skip proxy logic and return null + return { + rawAvatarUrl: avatarTextRecord, + browserSupportedAvatarUrl: null, + usesProxy: false, + }; + } + } + + // Default proxy is to use the ENS Metadata Service + const defaultProxy = (name: Name, rawAvatarUrl: string): BrowserSupportedAssetUrl | null => { + return buildEnsMetadataServiceAvatarUrl(name, namespaceId); + }; + + // Use custom proxy if provided, otherwise use default + const activeProxy: (name: Name, rawAvatarUrl: string) => BrowserSupportedAssetUrl | null = + browserSupportedAvatarUrlProxy ?? defaultProxy; + + // For other protocols (ipfs, data, NFT URIs, etc.), use proxy if available + if (activeProxy) { + try { + const proxyUrl = activeProxy(name, avatarTextRecord); + + // Invariant: BrowserSupportedAssetUrl must pass isHttpProtocol check + if (proxyUrl !== null && !isHttpProtocol(proxyUrl)) { + throw new Error( + `browserSupportedAvatarUrlProxy returned a URL with unsupported protocol: ${proxyUrl.protocol}. BrowserSupportedAssetUrl must use http or https protocol.`, + ); + } + + return { + rawAvatarUrl: avatarTextRecord, + browserSupportedAvatarUrl: proxyUrl, + usesProxy: proxyUrl !== null, + }; + } catch { + return { + rawAvatarUrl: avatarTextRecord, + browserSupportedAvatarUrl: null, + usesProxy: false, + }; + } + } + + // No fallback available + return { + rawAvatarUrl: avatarTextRecord, + browserSupportedAvatarUrl: null, + usesProxy: false, + }; +} + /** * Parameters for the useAvatarUrl hook. */ @@ -175,7 +274,6 @@ export function useAvatarUrl( query: { enabled: canEnable }, }); - // Get namespace from config const configQuery = useENSIndexerConfig({ config: _config }); // Construct query options object @@ -207,82 +305,7 @@ export function useAvatarUrl( const avatarTextRecord = recordsQuery.data.records?.texts?.avatar ?? null; - // If no avatar text record, return null values - if (!avatarTextRecord) { - return { - rawAvatarUrl: null, - browserSupportedAvatarUrl: null, - usesProxy: false, - }; - } - - try { - const browserSupportedUrl = toBrowserSupportedUrl(avatarTextRecord); - - return { - rawAvatarUrl: avatarTextRecord, - browserSupportedAvatarUrl: browserSupportedUrl, - usesProxy: false, - }; - } catch { - // toBrowserSupportedUrl failed - could be unsupported protocol or malformed URL - // Try to parse as a general URL to determine which case we're in - try { - buildUrl(avatarTextRecord); - // buildUrl succeeded, so the avatar text record is a valid URL with an unsupported protocol - // Continue to proxy handling below - } catch { - // buildUrl failed, so the avatar text record is malformed/invalid - // Skip proxy logic and return null - return { - rawAvatarUrl: avatarTextRecord, - browserSupportedAvatarUrl: null, - usesProxy: false, - }; - } - } - - // Default proxy is to use the ENS Metadata Service - const defaultProxy = (name: Name, rawAvatarUrl: string): BrowserSupportedAssetUrl | null => { - return buildEnsMetadataServiceAvatarUrl(name, namespaceId); - }; - - // Use custom proxy if provided, otherwise use default - const activeProxy: (name: Name, rawAvatarUrl: string) => BrowserSupportedAssetUrl | null = - browserSupportedAvatarUrlProxy ?? defaultProxy; - - // For other protocols (ipfs, data, NFT URIs, etc.), use proxy if available - if (activeProxy) { - try { - const proxyUrl = activeProxy(name, avatarTextRecord); - - // Invariant: BrowserSupportedAssetUrl must pass isHttpProtocol check - if (proxyUrl !== null && !isHttpProtocol(proxyUrl)) { - throw new Error( - `browserSupportedAvatarUrlProxy returned a URL with unsupported protocol: ${proxyUrl.protocol}. BrowserSupportedAssetUrl must use http or https protocol.`, - ); - } - - return { - rawAvatarUrl: avatarTextRecord, - browserSupportedAvatarUrl: proxyUrl, - usesProxy: proxyUrl !== null, - }; - } catch { - return { - rawAvatarUrl: avatarTextRecord, - browserSupportedAvatarUrl: null, - usesProxy: false, - }; - } - } - - // No fallback available - return { - rawAvatarUrl: avatarTextRecord, - browserSupportedAvatarUrl: null, - usesProxy: false, - }; + return resolveAvatarUrl(avatarTextRecord, name, namespaceId, browserSupportedAvatarUrlProxy); }, retry: false, placeholderData: { diff --git a/packages/ensnode-sdk/src/shared/url.ts b/packages/ensnode-sdk/src/shared/url.ts index 4963a2af8..973fbce79 100644 --- a/packages/ensnode-sdk/src/shared/url.ts +++ b/packages/ensnode-sdk/src/shared/url.ts @@ -1,8 +1,8 @@ /** * Type alias for URLs to assets that are supported by browsers for direct rendering. - * Assets must be accessible via the http or https protocol. + * Assets must be accessible via the http, https, or data protocol. * - * Invariant: value guaranteed to pass isHttpProtocol check. + * Invariant: value guaranteed to pass isBrowserSupportedProtocol check. */ export type BrowserSupportedAssetUrl = URL; @@ -33,16 +33,42 @@ export function isWebSocketProtocol(url: URL): boolean { return ["ws:", "wss:"].includes(url.protocol); } +/** + * Checks if a URL uses a protocol that is supported by browsers for direct asset rendering. + * Supported protocols include http, https, and data. + * + * @param url - The URL to check + * @returns true if the URL protocol is http:, https:, or data: + * + * @example + * ```typescript + * const httpUrl = new URL('https://example.com/image.png'); + * isBrowserSupportedProtocol(httpUrl); // true + * + * const dataUrl = new URL('data:image/svg+xml;base64,PHN2Zy8+'); + * isBrowserSupportedProtocol(dataUrl); // true + * + * const ipfsUrl = new URL('ipfs://QmHash'); + * isBrowserSupportedProtocol(ipfsUrl); // false + * ``` + */ +export function isBrowserSupportedProtocol(url: URL): boolean { + return ["http:", "https:", "data:"].includes(url.protocol); +} + /** * Validates and converts a URL string to a BrowserSupportedAssetUrl. * * This function normalizes the URL string (adding 'https://' if no protocol is specified), - * then validates that the resulting URL uses a browser-supported protocol (http/https) + * then validates that the resulting URL uses a browser-supported protocol (http/https/data) * before returning it as a BrowserSupportedAssetUrl type. * + * Special handling for data: URLs - they are parsed directly without normalization to preserve + * the data content integrity. + * * @param urlString - The URL string to validate and convert. If no protocol is specified, 'https://' will be prepended. - * @returns A BrowserSupportedAssetUrl if the protocol is (or becomes) http/https - * @throws if the URL string is invalid or uses a non-http/https protocol + * @returns A BrowserSupportedAssetUrl if the protocol is (or becomes) http/https/data + * @throws if the URL string is invalid or uses a non-browser-supported protocol * * @example * ```typescript @@ -50,6 +76,9 @@ export function isWebSocketProtocol(url: URL): boolean { * toBrowserSupportedUrl('https://example.com') // returns URL with https:// * toBrowserSupportedUrl('http://example.com') // returns URL with http:// * + * // Data URLs - direct parsing without normalization (ENS avatar standard compliant) + * toBrowserSupportedUrl('data:image/svg+xml;base64,PHN2Zy8+') // returns data: URL + * * // Implicit protocol - adds https:// * toBrowserSupportedUrl('example.com') // returns URL with https://example.com * @@ -58,11 +87,18 @@ export function isWebSocketProtocol(url: URL): boolean { * ``` */ export function toBrowserSupportedUrl(urlString: string): BrowserSupportedAssetUrl { - const url = buildUrl(urlString); + // data: URLs should be parsed directly without normalization + // buildUrl() would incorrectly prepend https:// to them + let url: URL; + if (urlString.startsWith("data:")) { + url = new URL(urlString); + } else { + url = buildUrl(urlString); + } - if (!isHttpProtocol(url)) { + if (!isBrowserSupportedProtocol(url)) { throw new Error( - `BrowserSupportedAssetUrl must use http or https protocol, got: ${url.protocol}`, + `BrowserSupportedAssetUrl must use http, https, or data protocol, got: ${url.protocol}`, ); } From efb4806c784df2e48f8950f1f2fb6dd13bb54c76 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Wed, 15 Oct 2025 16:55:36 +0100 Subject: [PATCH 48/73] cleanup --- apps/ensadmin/src/components/ens-avatar.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/ensadmin/src/components/ens-avatar.tsx b/apps/ensadmin/src/components/ens-avatar.tsx index 749c75671..cfacf7ceb 100644 --- a/apps/ensadmin/src/components/ens-avatar.tsx +++ b/apps/ensadmin/src/components/ens-avatar.tsx @@ -2,7 +2,7 @@ import { Avatar, AvatarImage } from "@/components/ui/avatar"; import { useAvatarUrl } from "@ensnode/ensnode-react"; -import { ENSNamespaceId, Name, buildEnsMetadataServiceAvatarUrl } from "@ensnode/ensnode-sdk"; +import { ENSNamespaceId, Name } from "@ensnode/ensnode-sdk"; import BoringAvatar from "boring-avatars"; import * as React from "react"; @@ -48,10 +48,6 @@ export const EnsAvatar = ({ name, namespaceId, className }: EnsAvatarProps) => { const { data: avatarUrlData, isLoading: isAvatarUrlLoading } = useAvatarUrl({ name, - browserSupportedAvatarUrlProxy: React.useCallback( - (name: Name, rawAvatarUrl: string) => buildEnsMetadataServiceAvatarUrl(name, namespaceId), - [namespaceId], - ), }); // Show loading state while fetching avatar URL or if data is not yet available From 72158daff1b40884eaa9b30e1cc91bd14231f813 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 16 Oct 2025 16:30:08 +0100 Subject: [PATCH 49/73] ignore eip155 use avatar url (#1182) --- .../app/@breadcrumbs/mock/ens-avatar/page.tsx | 25 +++ .../ensadmin/src/app/mock/ens-avatar/page.tsx | 171 ++++++++++++++++++ apps/ensadmin/src/app/mock/page.tsx | 3 + .../ensnode-react/src/hooks/useAvatarUrl.ts | 44 +++-- 4 files changed, 226 insertions(+), 17 deletions(-) create mode 100644 apps/ensadmin/src/app/@breadcrumbs/mock/ens-avatar/page.tsx create mode 100644 apps/ensadmin/src/app/mock/ens-avatar/page.tsx diff --git a/apps/ensadmin/src/app/@breadcrumbs/mock/ens-avatar/page.tsx b/apps/ensadmin/src/app/@breadcrumbs/mock/ens-avatar/page.tsx new file mode 100644 index 000000000..44631c2c0 --- /dev/null +++ b/apps/ensadmin/src/app/@breadcrumbs/mock/ens-avatar/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { useRawConnectionUrlParam } from "@/hooks/use-connection-url-param"; + +export default function Page() { + const { retainCurrentRawConnectionUrlParam } = useRawConnectionUrlParam(); + const uiMocksBaseHref = retainCurrentRawConnectionUrlParam("/mock"); + return ( + <> + + UI Mocks + + + + ENS Avatar + + + ); +} diff --git a/apps/ensadmin/src/app/mock/ens-avatar/page.tsx b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx new file mode 100644 index 000000000..fc53469c0 --- /dev/null +++ b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { EnsAvatar } from "@/components/ens-avatar"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useActiveNamespace } from "@/hooks/active/use-active-namespace"; +import { useAvatarUrl } from "@ensnode/ensnode-react"; +import { Name } from "@ensnode/ensnode-sdk"; +import { AlertCircle, Check, X } from "lucide-react"; + +const TEST_NAMES: Name[] = [ + "lightwalker.eth", + "brantly.eth", + "ada.eth", + "nick.eth", + "7⃣7⃣7⃣.eth", + "jesse.base.eth", + "barmstrong.cd.id", + "000.eth", + "vitalik.eth", +]; + +interface AvatarTestCardProps { + name: Name; +} + +function AvatarTestCard({ name }: AvatarTestCardProps) { + const { data, isLoading, error } = useAvatarUrl({ name }); + + if (error) { + return ( + + + + + {name} + + Error loading avatar + + +

{error.message}

+
+
+ ); + } + + if (isLoading || !data) { + return ( + + + {name} + Loading avatar information... + + + +
+ + + +
+
+
+ ); + } + + const hasAvatar = data.browserSupportedAvatarUrl !== null; + const hasRawUrl = data.rawAvatarUrl !== null; + + const namespaceId = useActiveNamespace(); + + return ( + + + + {hasAvatar ? ( + + ) : ( + + )} + {name} + + {hasAvatar ? "Avatar available" : "No avatar available"} + + + {/* Avatar Display */} +
+ +
+ + {/* Avatar Details */} +
+ {/* Raw Avatar URL */} +
+
+ Raw Avatar URL: + {hasRawUrl ? ( + + ) : ( + + )} +
+
+ {data.rawAvatarUrl || "Not set"} +
+
+ + {/* Browser-Supported URL */} +
+
+ Browser-Supported URL: + {hasAvatar ? ( + + ) : ( + + )} +
+
+ {data.browserSupportedAvatarUrl?.toString() || "Not available"} +
+
+ + {/* Uses Proxy Indicator */} +
+ Uses Proxy: +
+ {data.usesProxy ? ( + <> + + Yes + + ) : ( + <> + + No + + )} +
+
+
+
+
+ ); +} + +export default function MockAvatarUrlPage() { + return ( +
+ + + Mock: ENS Avatar + + View and test ENS avatar functionality across multiple names. Displays avatar images, + raw URLs, browser-supported URLs, and proxy usage for each ENS name. + + + +
+
+

Avatar Test Results

+
+ {TEST_NAMES.map((name) => ( + + ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/ensadmin/src/app/mock/page.tsx b/apps/ensadmin/src/app/mock/page.tsx index 2926fdf05..324f7a599 100644 --- a/apps/ensadmin/src/app/mock/page.tsx +++ b/apps/ensadmin/src/app/mock/page.tsx @@ -42,6 +42,9 @@ export default function MockList() { DisplayIdentity +
diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 4c011198b..f55a21377 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -50,29 +50,39 @@ export function resolveAvatarUrl( }; } - try { - const browserSupportedUrl = toBrowserSupportedUrl(avatarTextRecord); + // Check for EIP-155 NFT URIs (CAIP-22 ERC-721 or CAIP-29 ERC-1155) + // These require proxy handling to resolve NFT metadata from blockchain + const isEip155Uri = /^eip155:\d+\/(erc721|erc1155):/i.test(avatarTextRecord); - return { - rawAvatarUrl: avatarTextRecord, - browserSupportedAvatarUrl: browserSupportedUrl, - usesProxy: false, - }; - } catch { - // toBrowserSupportedUrl failed - could be unsupported protocol or malformed URL - // Try to parse as a general URL to determine which case we're in + if (isEip155Uri) { + // Skip toBrowserSupportedUrl normalization and go directly to proxy handling + // This prevents buildUrl from incorrectly prepending https:// to the URI + } else { + // Try to convert to browser-supported URL first try { - buildUrl(avatarTextRecord); - // buildUrl succeeded, so the avatar text record is a valid URL with an unsupported protocol - // Continue to proxy handling below - } catch { - // buildUrl failed, so the avatar text record is malformed/invalid - // Skip proxy logic and return null + const browserSupportedUrl = toBrowserSupportedUrl(avatarTextRecord); + return { rawAvatarUrl: avatarTextRecord, - browserSupportedAvatarUrl: null, + browserSupportedAvatarUrl: browserSupportedUrl, usesProxy: false, }; + } catch { + // toBrowserSupportedUrl failed - could be unsupported protocol or malformed URL + // Try to parse as a general URL to determine which case we're in + try { + buildUrl(avatarTextRecord); + // buildUrl succeeded, so the avatar text record is a valid URL with an unsupported protocol + // Continue to proxy handling below + } catch { + // buildUrl failed, so the avatar text record is malformed/invalid + // Skip proxy logic and return null + return { + rawAvatarUrl: avatarTextRecord, + browserSupportedAvatarUrl: null, + usesProxy: false, + }; + } } } From 415063489bb2b160ddf5afe3bc032fcc0ccf8861 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 16 Oct 2025 17:03:43 +0100 Subject: [PATCH 50/73] Avatar mock input (#1183) --- .../ensadmin/src/app/mock/ens-avatar/page.tsx | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/apps/ensadmin/src/app/mock/ens-avatar/page.tsx b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx index fc53469c0..c05926733 100644 --- a/apps/ensadmin/src/app/mock/ens-avatar/page.tsx +++ b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx @@ -2,11 +2,13 @@ import { EnsAvatar } from "@/components/ens-avatar"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { useActiveNamespace } from "@/hooks/active/use-active-namespace"; import { useAvatarUrl } from "@ensnode/ensnode-react"; import { Name } from "@ensnode/ensnode-sdk"; import { AlertCircle, Check, X } from "lucide-react"; +import { useState } from "react"; const TEST_NAMES: Name[] = [ "lightwalker.eth", @@ -15,16 +17,16 @@ const TEST_NAMES: Name[] = [ "nick.eth", "7⃣7⃣7⃣.eth", "jesse.base.eth", - "barmstrong.cd.id", "000.eth", "vitalik.eth", ]; interface AvatarTestCardProps { - name: Name; + defaultName: Name; } -function AvatarTestCard({ name }: AvatarTestCardProps) { +function AvatarTestCard({ defaultName }: AvatarTestCardProps) { + const [name, setName] = useState(defaultName); const { data, isLoading, error } = useAvatarUrl({ name }); if (error) { @@ -33,7 +35,12 @@ function AvatarTestCard({ name }: AvatarTestCardProps) { - {name} + setName(e.target.value as Name)} + className="flex-1" + /> Error loading avatar @@ -48,7 +55,14 @@ function AvatarTestCard({ name }: AvatarTestCardProps) { return ( - {name} + + setName(e.target.value as Name)} + className="w-full" + /> + Loading avatar information... @@ -73,11 +87,16 @@ function AvatarTestCard({ name }: AvatarTestCardProps) { {hasAvatar ? ( - + ) : ( - + )} - {name} + setName(e.target.value as Name)} + className="flex-1" + /> {hasAvatar ? "Avatar available" : "No avatar available"} @@ -149,19 +168,16 @@ export default function MockAvatarUrlPage() { Mock: ENS Avatar - View and test ENS avatar functionality across multiple names. Displays avatar images, - raw URLs, browser-supported URLs, and proxy usage for each ENS name. + Displays avatar images, raw URLs, browser-supported URLs, and proxy usage for each ENS + name.
-
-

Avatar Test Results

-
- {TEST_NAMES.map((name) => ( - - ))} -
+
+ {TEST_NAMES.map((name) => ( + + ))}
From 741d716323cc88c54bd7aaf16ee30bf8ac98e994 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 17 Oct 2025 09:08:20 +0100 Subject: [PATCH 51/73] build URL --- packages/ensnode-react/README.md | 10 +++---- .../ensnode-react/src/hooks/useAvatarUrl.ts | 28 ++++++++----------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/packages/ensnode-react/README.md b/packages/ensnode-react/README.md index f3de5eda5..12d0f4310 100644 --- a/packages/ensnode-react/README.md +++ b/packages/ensnode-react/README.md @@ -392,12 +392,12 @@ import { Name } from "@ensnode/ensnode-sdk"; function ProfileAvatar({ name }: { name: Name }) { const { data, isLoading } = useAvatarUrl({ name, - browserSupportedAvatarUrlProxy: (name, rawAvatarUrl) => { + browserSupportedAvatarUrlProxy: (name, avatarUrl) => { // Handle IPFS protocol URLs - if (rawAvatarUrl.startsWith("ipfs://")) { + if (avatarUrl.protocol === "ipfs:") { // Extract the CID (Content Identifier) from the IPFS URL // Format: ipfs://{CID} or ipfs://{CID}/{path} - const ipfsPath = rawAvatarUrl.replace("ipfs://", ""); + const ipfsPath = avatarUrl.href.replace("ipfs://", ""); // Option 1: Use ipfs.io public gateway (path-based) // Note: Public gateways are best-effort and not for production @@ -414,8 +414,8 @@ function ProfileAvatar({ name }: { name: Name }) { } // Handle Arweave protocol URLs (ar://) - if (rawAvatarUrl.startsWith("ar://")) { - const arweaveId = rawAvatarUrl.replace("ar://", ""); + if (avatarUrl.protocol === "ar:") { + const arweaveId = avatarUrl.href.replace("ar://", ""); return toBrowserSupportedUrl(`https://arweave.net/${arweaveId}`); } diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index f55a21377..a40552f79 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -36,10 +36,7 @@ export function resolveAvatarUrl( avatarTextRecord: string | null, name: Name, namespaceId: ENSNamespaceId, - browserSupportedAvatarUrlProxy?: ( - name: Name, - rawAvatarUrl: string, - ) => BrowserSupportedAssetUrl | null, + browserSupportedAvatarUrlProxy?: (name: Name, avatarUrl: URL) => BrowserSupportedAssetUrl | null, ): UseAvatarUrlResult { // If no avatar text record, return null values if (!avatarTextRecord) { @@ -87,18 +84,18 @@ export function resolveAvatarUrl( } // Default proxy is to use the ENS Metadata Service - const defaultProxy = (name: Name, rawAvatarUrl: string): BrowserSupportedAssetUrl | null => { + const defaultProxy = (name: Name, avatarUrl: URL): BrowserSupportedAssetUrl | null => { return buildEnsMetadataServiceAvatarUrl(name, namespaceId); }; // Use custom proxy if provided, otherwise use default - const activeProxy: (name: Name, rawAvatarUrl: string) => BrowserSupportedAssetUrl | null = + const activeProxy: (name: Name, avatarUrl: URL) => BrowserSupportedAssetUrl | null = browserSupportedAvatarUrlProxy ?? defaultProxy; // For other protocols (ipfs, data, NFT URIs, etc.), use proxy if available if (activeProxy) { try { - const proxyUrl = activeProxy(name, avatarTextRecord); + const proxyUrl = activeProxy(name, buildUrl(avatarTextRecord)); // Invariant: BrowserSupportedAssetUrl must pass isHttpProtocol check if (proxyUrl !== null && !isHttpProtocol(proxyUrl)) { @@ -148,13 +145,10 @@ export interface UseAvatarUrlParameters extends QueryParameter, C * to ensure it passes the `isHttpProtocol` check. * * @param name - The ENS name to get the browser supported avatar URL for - * @param rawAvatarUrl - The original avatar text record value, allowing protocol-specific logic (e.g., ipfs:// vs ar://) + * @param avatarUrl - The avatar URL parsed as a URL object, allowing protocol-specific logic (e.g., ipfs:// vs ar://) * @returns The browser supported avatar URL, or null if unavailable */ - browserSupportedAvatarUrlProxy?: ( - name: Name, - rawAvatarUrl: string, - ) => BrowserSupportedAssetUrl | null; + browserSupportedAvatarUrlProxy?: (name: Name, avatarUrl: URL) => BrowserSupportedAssetUrl | null; } /** @@ -229,11 +223,11 @@ export interface UseAvatarUrlResult { * function ProfileAvatar({ name }: { name: string }) { * const { data, isLoading } = useAvatarUrl({ * name, - * browserSupportedAvatarUrlProxy: (name, rawAvatarUrl) => { + * browserSupportedAvatarUrlProxy: (name, avatarUrl) => { * // Handle IPFS protocol URLs with a custom gateway - * if (rawAvatarUrl.startsWith('ipfs://')) { + * if (avatarUrl.protocol === 'ipfs:') { * // Extract CID and optional path from ipfs://{CID}/{path} - * const ipfsPath = rawAvatarUrl.replace('ipfs://', ''); + * const ipfsPath = avatarUrl.href.replace('ipfs://', ''); * * // Use ipfs.io public gateway (best-effort, not for production) * return toBrowserSupportedUrl(`https://ipfs.io/ipfs/${ipfsPath}`); @@ -243,8 +237,8 @@ export interface UseAvatarUrlResult { * } * * // Handle Arweave protocol - * if (rawAvatarUrl.startsWith('ar://')) { - * const arweaveId = rawAvatarUrl.replace('ar://', ''); + * if (avatarUrl.protocol === 'ar:') { + * const arweaveId = avatarUrl.href.replace('ar://', ''); * return toBrowserSupportedUrl(`https://arweave.net/${arweaveId}`); * } * From 578a8c55b12e38a56d4f35b75f8664d478694ec7 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 17 Oct 2025 14:14:52 +0100 Subject: [PATCH 52/73] Update packages/ensnode-react/README.md Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- packages/ensnode-react/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensnode-react/README.md b/packages/ensnode-react/README.md index 12d0f4310..6f214231d 100644 --- a/packages/ensnode-react/README.md +++ b/packages/ensnode-react/README.md @@ -290,7 +290,7 @@ interface UseAvatarUrlResult { ``` - `rawAvatarUrl`: The original avatar text record value from ENS, before any normalization or proxy processing. `null` if no avatar text record is set. -- `browserSupportedAvatarUrl`: A browser-supported (http/https) avatar URL ready for use in `` tags. `null` if no avatar is set, if the avatar that is set is an invalid URL, or if the avatar uses a non-http/https protocol and no proxy url is available. +- `browserSupportedAvatarUrl`: A browser-supported (http/https/data) avatar URL ready for use in `` tags. `null` if no avatar is set, if the avatar that is set is an invalid URL, or if the avatar uses a non-http/https/data protocol and no proxy url is available. - `usesProxy `: Indicates if the `browserSupportedAvatarUrl` uses the configured proxy.
From 0165bcec4852e5a493cc5d298e68cac956901e21 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 17 Oct 2025 14:15:35 +0100 Subject: [PATCH 53/73] Update packages/ensnode-react/src/hooks/useAvatarUrl.ts Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index a40552f79..b9e1d6297 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -22,7 +22,7 @@ import { useRecords } from "./useRecords"; const AVATAR_TEXT_RECORD_KEY = "avatar" as const; /** - * Resolves an avatar text record to a browser-supported URL. + * Builds a browser-supported asset URL for a name's avatar image from the name's raw avatar text record value. * This is the core resolution logic extracted for testing. * * @param avatarTextRecord - The raw avatar text record value from ENS From 70f2b438303568a6e940dbcbd3c170fc65f727b1 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 17 Oct 2025 14:15:47 +0100 Subject: [PATCH 54/73] Update packages/ensnode-react/src/hooks/useAvatarUrl.ts Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index b9e1d6297..7c485e844 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -25,7 +25,7 @@ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; * Builds a browser-supported asset URL for a name's avatar image from the name's raw avatar text record value. * This is the core resolution logic extracted for testing. * - * @param avatarTextRecord - The raw avatar text record value from ENS + * @param rawAvatarTextRecord - The raw avatar text record value resolved for `name` on `namespaceId`, or null if `name` has no avatar text record on `namespaceId`. * @param name - The ENS name * @param namespaceId - The ENS namespace ID * @param browserSupportedAvatarUrlProxy - Optional custom proxy function From 98ee6be64de6718912aab52bd3bf3c09d37ad02d Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 17 Oct 2025 14:16:06 +0100 Subject: [PATCH 55/73] Update packages/ensnode-react/src/hooks/useAvatarUrl.ts Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 7c485e844..cca6e3386 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -26,7 +26,7 @@ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; * This is the core resolution logic extracted for testing. * * @param rawAvatarTextRecord - The raw avatar text record value resolved for `name` on `namespaceId`, or null if `name` has no avatar text record on `namespaceId`. - * @param name - The ENS name + * @param name - The ENS name whose avatar text record value was `rawAvatarTextRecord` on `namespaceId`. * @param namespaceId - The ENS namespace ID * @param browserSupportedAvatarUrlProxy - Optional custom proxy function * @returns The resolved avatar URL result From 9e7913517fc654979c55381b1dad6b2257790318 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 17 Oct 2025 14:16:19 +0100 Subject: [PATCH 56/73] Update packages/ensnode-react/src/hooks/useAvatarUrl.ts Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index cca6e3386..3a5fd64f0 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -27,7 +27,7 @@ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; * * @param rawAvatarTextRecord - The raw avatar text record value resolved for `name` on `namespaceId`, or null if `name` has no avatar text record on `namespaceId`. * @param name - The ENS name whose avatar text record value was `rawAvatarTextRecord` on `namespaceId`. - * @param namespaceId - The ENS namespace ID + * @param namespaceId - The ENS namespace where `name` has the avatar text record set to `rawAvatarTextRecord`. * @param browserSupportedAvatarUrlProxy - Optional custom proxy function * @returns The resolved avatar URL result * @internal From e204547dcb67421cb22238c9c4779e9d0840d75f Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 17 Oct 2025 14:20:58 +0100 Subject: [PATCH 57/73] Update packages/ensnode-react/src/hooks/useAvatarUrl.ts Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 3a5fd64f0..f5911422a 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -28,7 +28,7 @@ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; * @param rawAvatarTextRecord - The raw avatar text record value resolved for `name` on `namespaceId`, or null if `name` has no avatar text record on `namespaceId`. * @param name - The ENS name whose avatar text record value was `rawAvatarTextRecord` on `namespaceId`. * @param namespaceId - The ENS namespace where `name` has the avatar text record set to `rawAvatarTextRecord`. - * @param browserSupportedAvatarUrlProxy - Optional custom proxy function + * @param browserSupportedAvatarUrlProxy - Optional function for generating browser support asset urls that route through a custom proxy. * @returns The resolved avatar URL result * @internal */ From ef145e04be972204eeaa690aa18a36e0b86fa849 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 17 Oct 2025 14:21:25 +0100 Subject: [PATCH 58/73] Update packages/ensnode-react/src/hooks/useAvatarUrl.ts Co-authored-by: lightwalker.eth <126201998+lightwalker-eth@users.noreply.github.com> --- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index f5911422a..c6c17ebb6 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -29,7 +29,7 @@ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; * @param name - The ENS name whose avatar text record value was `rawAvatarTextRecord` on `namespaceId`. * @param namespaceId - The ENS namespace where `name` has the avatar text record set to `rawAvatarTextRecord`. * @param browserSupportedAvatarUrlProxy - Optional function for generating browser support asset urls that route through a custom proxy. - * @returns The resolved avatar URL result + * @returns The {@link UseAvatarUrlResult} result * @internal */ export function resolveAvatarUrl( From 4d46cc35ade4e9eba3f120aadd856882c15a3f7f Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 17 Oct 2025 11:10:26 +0100 Subject: [PATCH 59/73] mock page updates --- .../ensadmin/src/app/mock/ens-avatar/page.tsx | 135 ++++++++++++++++-- .../name/[name]/_components/ProfileHeader.tsx | 6 +- apps/ensadmin/src/components/ens-avatar.tsx | 91 ++++++++---- .../src/components/identity/index.tsx | 7 +- 4 files changed, 196 insertions(+), 43 deletions(-) diff --git a/apps/ensadmin/src/app/mock/ens-avatar/page.tsx b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx index c05926733..9765942bb 100644 --- a/apps/ensadmin/src/app/mock/ens-avatar/page.tsx +++ b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx @@ -1,23 +1,21 @@ "use client"; -import { EnsAvatar } from "@/components/ens-avatar"; +import { EnsAvatar, EnsAvatarDisplay } from "@/components/ens-avatar"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { useActiveNamespace } from "@/hooks/active/use-active-namespace"; -import { useAvatarUrl } from "@ensnode/ensnode-react"; +import { resolveAvatarUrl, useAvatarUrl } from "@ensnode/ensnode-react"; import { Name } from "@ensnode/ensnode-sdk"; import { AlertCircle, Check, X } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; const TEST_NAMES: Name[] = [ "lightwalker.eth", "brantly.eth", "ada.eth", - "nick.eth", - "7⃣7⃣7⃣.eth", "jesse.base.eth", - "000.eth", + "norecordset.eth", "vitalik.eth", ]; @@ -80,8 +78,6 @@ function AvatarTestCard({ defaultName }: AvatarTestCardProps) { const hasAvatar = data.browserSupportedAvatarUrl !== null; const hasRawUrl = data.rawAvatarUrl !== null; - const namespaceId = useActiveNamespace(); - return ( @@ -103,7 +99,7 @@ function AvatarTestCard({ defaultName }: AvatarTestCardProps) { {/* Avatar Display */}
- +
{/* Avatar Details */} @@ -161,6 +157,123 @@ function AvatarTestCard({ defaultName }: AvatarTestCardProps) { ); } +/** + * Wrapper component that resolves and renders an avatar using a custom URL. + * Supports http/https and data: URLs directly. + */ +function CustomAvatarWrapper({ customUrl }: { customUrl: string }) { + const testName = "custom-test.eth" as Name; + const namespaceId = useActiveNamespace(); + + // Resolve the avatar URL using the same logic as useAvatarUrl + // This supports http/https and data: URLs directly + const resolvedData = useMemo(() => { + return resolveAvatarUrl(customUrl, testName, namespaceId); + }, [customUrl, testName, namespaceId]); + + const hasAvatar = resolvedData.browserSupportedAvatarUrl !== null; + + return ( +
+
+ +
+ + {/* Display the resolution information */} +
+
+
+ Raw Avatar URL: + {resolvedData.rawAvatarUrl ? ( + + ) : ( + + )} +
+
+ {resolvedData.rawAvatarUrl || "Not set"} +
+
+ +
+
+ Browser-Supported URL: + {hasAvatar ? ( + + ) : ( + + )} +
+
+ {resolvedData.browserSupportedAvatarUrl?.toString() || "Not available"} +
+
+ +
+ Uses Proxy: +
+ {resolvedData.usesProxy ? ( + <> + + Yes + + ) : ( + <> + + No + + )} +
+
+
+
+ ); +} + +function CustomAvatarUrlTestCard() { + const [customUrl, setCustomUrl] = useState(""); + + return ( + + + Custom Avatar URL Test + + Enter a raw avatar URL to test resolution and display. Supports HTTP/HTTPS and data: URLs. + + + + {/* URL Input */} +
+ + setCustomUrl(e.target.value)} + /> +
+ + {/* Avatar Display */} + {customUrl && } + + {!customUrl && ( +
+ Enter a URL above to see how it would be resolved and displayed. +
    +
  • HTTP/HTTPS URLs: e.g., https://example.com/avatar.jpg
  • +
  • Data URLs: e.g., data:image/svg+xml,<svg...></svg>
  • +
+
+ )} +
+
+ ); +} + export default function MockAvatarUrlPage() { return (
@@ -174,6 +287,10 @@ export default function MockAvatarUrlPage() {
+ {/* Custom URL Test Section */} + + + {/* Existing Test Names Grid */}
{TEST_NAMES.map((name) => ( diff --git a/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx b/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx index b38c8a177..679a41d06 100644 --- a/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx +++ b/apps/ensadmin/src/app/name/[name]/_components/ProfileHeader.tsx @@ -66,11 +66,7 @@ export function ProfileHeader({ name, namespaceId, headerImage, websiteUrl }: Pr
- +

diff --git a/apps/ensadmin/src/components/ens-avatar.tsx b/apps/ensadmin/src/components/ens-avatar.tsx index cfacf7ceb..5e4460e9b 100644 --- a/apps/ensadmin/src/components/ens-avatar.tsx +++ b/apps/ensadmin/src/components/ens-avatar.tsx @@ -2,13 +2,18 @@ import { Avatar, AvatarImage } from "@/components/ui/avatar"; import { useAvatarUrl } from "@ensnode/ensnode-react"; -import { ENSNamespaceId, Name } from "@ensnode/ensnode-sdk"; +import { Name } from "@ensnode/ensnode-sdk"; import BoringAvatar from "boring-avatars"; import * as React from "react"; interface EnsAvatarProps { name: Name; - namespaceId: ENSNamespaceId; + className?: string; +} + +interface EnsAvatarDisplayProps { + name: Name; + avatarUrl: URL | null; className?: string; } @@ -16,8 +21,63 @@ type ImageLoadingStatus = Parameters< NonNullable["onLoadingStatusChange"]> >[0]; +/** + * Display component that renders an avatar with proper loading and fallback states. + * This is a pure presentational component that doesn't fetch data. + * + * This component handles three distinct states: + * 1. **Loading**: Shows a pulsing placeholder while loading the avatar image asset + * 2. **Avatar Loaded**: Displays the avatar image once loaded + * 3. **Fallback**: Shows a generated avatar based on the ENS name when no avatar URL is provided + * or if the avatar image fails to load. + * + * The component ensures that the fallback avatar is only shown as a final state, never during loading, + * preventing unwanted visual transitions from fallback to actual avatar. + * + * @param name - The ENS name (used for fallback avatar generation) + * @param avatarUrl - The avatar URL to display, or null to show fallback + * @param className - Optional CSS class name to apply to the avatar container + * + * @example + * ```tsx + * + * ``` + * + * @example + * ```tsx + * + * ``` + */ +export const EnsAvatarDisplay = ({ name, avatarUrl, className }: EnsAvatarDisplayProps) => { + const [imageLoadingStatus, setImageLoadingStatus] = React.useState("idle"); + + // No avatar available - show fallback + if (avatarUrl === null) { + return ( + + + + ); + } + + return ( + + { + setImageLoadingStatus(status); + }} + /> + {imageLoadingStatus === "error" && } + {(imageLoadingStatus === "idle" || imageLoadingStatus === "loading") && } + + ); +}; + /** * Displays an avatar for an ENS name with proper loading and fallback states. + * This component fetches the avatar URL from ENS records and renders it. * * This component handles three distinct states: * 1. **Loading**: Shows a pulsing placeholder while fetching the avatar URL from ENS records @@ -43,9 +103,7 @@ type ImageLoadingStatus = Parameters< * * ``` */ -export const EnsAvatar = ({ name, namespaceId, className }: EnsAvatarProps) => { - const [imageLoadingStatus, setImageLoadingStatus] = React.useState("idle"); - +export const EnsAvatar = ({ name, className }: EnsAvatarProps) => { const { data: avatarUrlData, isLoading: isAvatarUrlLoading } = useAvatarUrl({ name, }); @@ -59,30 +117,9 @@ export const EnsAvatar = ({ name, namespaceId, className }: EnsAvatarProps) => { ); } - // No avatar available - show fallback - if (avatarUrlData.browserSupportedAvatarUrl === null) { - return ( - - - - ); - } - const avatarUrl = avatarUrlData.browserSupportedAvatarUrl; - return ( - - { - setImageLoadingStatus(status); - }} - /> - {imageLoadingStatus === "error" && } - {(imageLoadingStatus === "idle" || imageLoadingStatus === "loading") && } - - ); + return ; }; interface EnsAvatarFallbackProps { diff --git a/apps/ensadmin/src/components/identity/index.tsx b/apps/ensadmin/src/components/identity/index.tsx index 50dd20591..3c27bf753 100644 --- a/apps/ensadmin/src/components/identity/index.tsx +++ b/apps/ensadmin/src/components/identity/index.tsx @@ -46,7 +46,10 @@ export function ResolveAndDisplayIdentity({ // resolve the primary name for `identity` using ENSNode // TODO: extract out the concept of resolving an `Identity` into a provider that child // components can then hook into. - const { identity: identityResult } = useResolvedIdentity({ identity, namespaceId }); + const { identity: identityResult } = useResolvedIdentity({ + identity, + namespaceId, + }); return ( ; } else { - avatar = ; + avatar = ; identitifer = ; } From a30ef90f3b4a1e4679a72a8b63ca929b1d9b11de Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 17 Oct 2025 14:32:13 +0100 Subject: [PATCH 60/73] rename variables again --- .../ensadmin/src/app/mock/ens-avatar/page.tsx | 12 ++--- packages/ensnode-react/README.md | 6 +-- .../ensnode-react/src/hooks/useAvatarUrl.ts | 48 ++++++++++--------- 3 files changed, 35 insertions(+), 31 deletions(-) diff --git a/apps/ensadmin/src/app/mock/ens-avatar/page.tsx b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx index 9765942bb..a6182b64e 100644 --- a/apps/ensadmin/src/app/mock/ens-avatar/page.tsx +++ b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx @@ -5,7 +5,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { useActiveNamespace } from "@/hooks/active/use-active-namespace"; -import { resolveAvatarUrl, useAvatarUrl } from "@ensnode/ensnode-react"; +import { buildBrowserSupportedAvatarUrl, useAvatarUrl } from "@ensnode/ensnode-react"; import { Name } from "@ensnode/ensnode-sdk"; import { AlertCircle, Check, X } from "lucide-react"; import { useMemo, useState } from "react"; @@ -76,7 +76,7 @@ function AvatarTestCard({ defaultName }: AvatarTestCardProps) { } const hasAvatar = data.browserSupportedAvatarUrl !== null; - const hasRawUrl = data.rawAvatarUrl !== null; + const hasRawUrl = data.rawAvatarTextRecord !== null; return ( @@ -115,7 +115,7 @@ function AvatarTestCard({ defaultName }: AvatarTestCardProps) { )}

- {data.rawAvatarUrl || "Not set"} + {data.rawAvatarTextRecord || "Not set"}
@@ -168,7 +168,7 @@ function CustomAvatarWrapper({ customUrl }: { customUrl: string }) { // Resolve the avatar URL using the same logic as useAvatarUrl // This supports http/https and data: URLs directly const resolvedData = useMemo(() => { - return resolveAvatarUrl(customUrl, testName, namespaceId); + return buildBrowserSupportedAvatarUrl(customUrl, testName, namespaceId); }, [customUrl, testName, namespaceId]); const hasAvatar = resolvedData.browserSupportedAvatarUrl !== null; @@ -188,14 +188,14 @@ function CustomAvatarWrapper({ customUrl }: { customUrl: string }) {
Raw Avatar URL: - {resolvedData.rawAvatarUrl ? ( + {resolvedData.rawAvatarTextRecord ? ( ) : ( )}
- {resolvedData.rawAvatarUrl || "Not set"} + {resolvedData.rawAvatarTextRecord || "Not set"}
diff --git a/packages/ensnode-react/README.md b/packages/ensnode-react/README.md index 6f214231d..f94ba17ce 100644 --- a/packages/ensnode-react/README.md +++ b/packages/ensnode-react/README.md @@ -269,7 +269,7 @@ The ENS Metadata Service can be used as a proxy for loading avatar images when t #### Invariants -- **If `rawAvatarUrl` is `null`, then `browserSupportedAvatarUrl` must also be `null`** +- **If `rawAvatarTextRecord` is `null`, then `browserSupportedAvatarUrl` must also be `null`** - The `browserSupportedAvatarUrl` is guaranteed to use http or https protocol when non-null - The `usesProxy ` flag is `true` if `browserSupportedAvatarUrl` will use the configured proxy. @@ -283,13 +283,13 @@ The ENS Metadata Service can be used as a proxy for loading avatar images when t ```tsx interface UseAvatarUrlResult { - rawAvatarUrl: string | null; + rawAvatarTextRecord: string | null; browserSupportedAvatarUrl: BrowserSupportedAssetUrl | null; usesProxy: boolean; } ``` -- `rawAvatarUrl`: The original avatar text record value from ENS, before any normalization or proxy processing. `null` if no avatar text record is set. +- `rawAvatarTextRecord`: The original avatar text record value from ENS, before any normalization or proxy processing. `null` if no avatar text record is set. - `browserSupportedAvatarUrl`: A browser-supported (http/https/data) avatar URL ready for use in `` tags. `null` if no avatar is set, if the avatar that is set is an invalid URL, or if the avatar uses a non-http/https/data protocol and no proxy url is available. - `usesProxy `: Indicates if the `browserSupportedAvatarUrl` uses the configured proxy. diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index c6c17ebb6..f4a9d813c 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -23,7 +23,6 @@ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; /** * Builds a browser-supported asset URL for a name's avatar image from the name's raw avatar text record value. - * This is the core resolution logic extracted for testing. * * @param rawAvatarTextRecord - The raw avatar text record value resolved for `name` on `namespaceId`, or null if `name` has no avatar text record on `namespaceId`. * @param name - The ENS name whose avatar text record value was `rawAvatarTextRecord` on `namespaceId`. @@ -32,16 +31,16 @@ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; * @returns The {@link UseAvatarUrlResult} result * @internal */ -export function resolveAvatarUrl( - avatarTextRecord: string | null, +export function buildBrowserSupportedAvatarUrl( + rawAvatarTextRecord: string | null, name: Name, namespaceId: ENSNamespaceId, browserSupportedAvatarUrlProxy?: (name: Name, avatarUrl: URL) => BrowserSupportedAssetUrl | null, ): UseAvatarUrlResult { // If no avatar text record, return null values - if (!avatarTextRecord) { + if (!rawAvatarTextRecord) { return { - rawAvatarUrl: null, + rawAvatarTextRecord: null, browserSupportedAvatarUrl: null, usesProxy: false, }; @@ -49,7 +48,7 @@ export function resolveAvatarUrl( // Check for EIP-155 NFT URIs (CAIP-22 ERC-721 or CAIP-29 ERC-1155) // These require proxy handling to resolve NFT metadata from blockchain - const isEip155Uri = /^eip155:\d+\/(erc721|erc1155):/i.test(avatarTextRecord); + const isEip155Uri = /^eip155:\d+\/(erc721|erc1155):/i.test(rawAvatarTextRecord); if (isEip155Uri) { // Skip toBrowserSupportedUrl normalization and go directly to proxy handling @@ -57,25 +56,25 @@ export function resolveAvatarUrl( } else { // Try to convert to browser-supported URL first try { - const browserSupportedUrl = toBrowserSupportedUrl(avatarTextRecord); + const browserSupportedAvatarUrl = toBrowserSupportedUrl(rawAvatarTextRecord); return { - rawAvatarUrl: avatarTextRecord, - browserSupportedAvatarUrl: browserSupportedUrl, + rawAvatarTextRecord, + browserSupportedAvatarUrl, usesProxy: false, }; } catch { // toBrowserSupportedUrl failed - could be unsupported protocol or malformed URL // Try to parse as a general URL to determine which case we're in try { - buildUrl(avatarTextRecord); + buildUrl(rawAvatarTextRecord); // buildUrl succeeded, so the avatar text record is a valid URL with an unsupported protocol // Continue to proxy handling below } catch { // buildUrl failed, so the avatar text record is malformed/invalid // Skip proxy logic and return null return { - rawAvatarUrl: avatarTextRecord, + rawAvatarTextRecord, browserSupportedAvatarUrl: null, usesProxy: false, }; @@ -95,7 +94,7 @@ export function resolveAvatarUrl( // For other protocols (ipfs, data, NFT URIs, etc.), use proxy if available if (activeProxy) { try { - const proxyUrl = activeProxy(name, buildUrl(avatarTextRecord)); + const proxyUrl = activeProxy(name, buildUrl(rawAvatarTextRecord)); // Invariant: BrowserSupportedAssetUrl must pass isHttpProtocol check if (proxyUrl !== null && !isHttpProtocol(proxyUrl)) { @@ -105,13 +104,13 @@ export function resolveAvatarUrl( } return { - rawAvatarUrl: avatarTextRecord, + rawAvatarTextRecord, browserSupportedAvatarUrl: proxyUrl, usesProxy: proxyUrl !== null, }; } catch { return { - rawAvatarUrl: avatarTextRecord, + rawAvatarTextRecord, browserSupportedAvatarUrl: null, usesProxy: false, }; @@ -120,7 +119,7 @@ export function resolveAvatarUrl( // No fallback available return { - rawAvatarUrl: avatarTextRecord, + rawAvatarTextRecord, browserSupportedAvatarUrl: null, usesProxy: false, }; @@ -154,17 +153,17 @@ export interface UseAvatarUrlParameters extends QueryParameter, C /** * Result returned by the useAvatarUrl hook. * - * Invariant: If rawAvatarUrl is null, then browserSupportedAvatarUrl must also be null. + * Invariant: If rawAvatarTextRecord is null, then browserSupportedAvatarUrl must also be null. */ export interface UseAvatarUrlResult { /** * The original avatar text record value from ENS, before any normalization or proxy processing. * Null if the avatar text record is not set for the ENS name. */ - rawAvatarUrl: string | null; + rawAvatarTextRecord: string | null; /** * A browser-supported (http/https) avatar URL ready for use in tags. - * Populated when the rawAvatarUrl is a valid URL that uses the http/https protocol or when a url is available to load the avatar using a proxy. + * Populated when the rawAvatarTextRecord is a valid URL that uses the http/https protocol or when a url is available to load the avatar using a proxy. * Null if the avatar text record is not set, if the avatar text record is malformed/invalid, * or if the avatar uses a non-http/https protocol and no url is known for how to load the avatar using a proxy. */ @@ -298,7 +297,7 @@ export function useAvatarUrl( queryFn: async (): Promise => { if (!name || !recordsQuery.data || !configQuery.data) { return { - rawAvatarUrl: null, + rawAvatarTextRecord: null, browserSupportedAvatarUrl: null, usesProxy: false, }; @@ -307,13 +306,18 @@ export function useAvatarUrl( // Invariant: configQuery.data.namespace is guaranteed to be defined when configQuery.data exists const namespaceId = configQuery.data.namespace; - const avatarTextRecord = recordsQuery.data.records?.texts?.avatar ?? null; + const rawAvatarTextRecord = recordsQuery.data.records?.texts?.avatar ?? null; - return resolveAvatarUrl(avatarTextRecord, name, namespaceId, browserSupportedAvatarUrlProxy); + return buildBrowserSupportedAvatarUrl( + rawAvatarTextRecord, + name, + namespaceId, + browserSupportedAvatarUrlProxy, + ); }, retry: false, placeholderData: { - rawAvatarUrl: null, + rawAvatarTextRecord: null, browserSupportedAvatarUrl: null, usesProxy: false, } as const, From 839a3287e4dcff12701a14b3395ec9d53711f54a Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 17 Oct 2025 15:31:39 +0100 Subject: [PATCH 61/73] move files --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 144 +++-------------- .../ensnode-sdk/src/ens/metadata-service.ts | 149 +++++++++++++++++- 2 files changed, 170 insertions(+), 123 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index f4a9d813c..250c8d2ae 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -2,12 +2,9 @@ import { type BrowserSupportedAssetUrl, - ENSNamespaceId, + type BrowserSupportedAssetUrlProxy, type Name, - buildEnsMetadataServiceAvatarUrl, - buildUrl, - isHttpProtocol, - toBrowserSupportedUrl, + buildBrowserSupportedAvatarUrl, } from "@ensnode/ensnode-sdk"; import { type UseQueryResult, useQuery } from "@tanstack/react-query"; @@ -21,110 +18,6 @@ import { useRecords } from "./useRecords"; */ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; -/** - * Builds a browser-supported asset URL for a name's avatar image from the name's raw avatar text record value. - * - * @param rawAvatarTextRecord - The raw avatar text record value resolved for `name` on `namespaceId`, or null if `name` has no avatar text record on `namespaceId`. - * @param name - The ENS name whose avatar text record value was `rawAvatarTextRecord` on `namespaceId`. - * @param namespaceId - The ENS namespace where `name` has the avatar text record set to `rawAvatarTextRecord`. - * @param browserSupportedAvatarUrlProxy - Optional function for generating browser support asset urls that route through a custom proxy. - * @returns The {@link UseAvatarUrlResult} result - * @internal - */ -export function buildBrowserSupportedAvatarUrl( - rawAvatarTextRecord: string | null, - name: Name, - namespaceId: ENSNamespaceId, - browserSupportedAvatarUrlProxy?: (name: Name, avatarUrl: URL) => BrowserSupportedAssetUrl | null, -): UseAvatarUrlResult { - // If no avatar text record, return null values - if (!rawAvatarTextRecord) { - return { - rawAvatarTextRecord: null, - browserSupportedAvatarUrl: null, - usesProxy: false, - }; - } - - // Check for EIP-155 NFT URIs (CAIP-22 ERC-721 or CAIP-29 ERC-1155) - // These require proxy handling to resolve NFT metadata from blockchain - const isEip155Uri = /^eip155:\d+\/(erc721|erc1155):/i.test(rawAvatarTextRecord); - - if (isEip155Uri) { - // Skip toBrowserSupportedUrl normalization and go directly to proxy handling - // This prevents buildUrl from incorrectly prepending https:// to the URI - } else { - // Try to convert to browser-supported URL first - try { - const browserSupportedAvatarUrl = toBrowserSupportedUrl(rawAvatarTextRecord); - - return { - rawAvatarTextRecord, - browserSupportedAvatarUrl, - usesProxy: false, - }; - } catch { - // toBrowserSupportedUrl failed - could be unsupported protocol or malformed URL - // Try to parse as a general URL to determine which case we're in - try { - buildUrl(rawAvatarTextRecord); - // buildUrl succeeded, so the avatar text record is a valid URL with an unsupported protocol - // Continue to proxy handling below - } catch { - // buildUrl failed, so the avatar text record is malformed/invalid - // Skip proxy logic and return null - return { - rawAvatarTextRecord, - browserSupportedAvatarUrl: null, - usesProxy: false, - }; - } - } - } - - // Default proxy is to use the ENS Metadata Service - const defaultProxy = (name: Name, avatarUrl: URL): BrowserSupportedAssetUrl | null => { - return buildEnsMetadataServiceAvatarUrl(name, namespaceId); - }; - - // Use custom proxy if provided, otherwise use default - const activeProxy: (name: Name, avatarUrl: URL) => BrowserSupportedAssetUrl | null = - browserSupportedAvatarUrlProxy ?? defaultProxy; - - // For other protocols (ipfs, data, NFT URIs, etc.), use proxy if available - if (activeProxy) { - try { - const proxyUrl = activeProxy(name, buildUrl(rawAvatarTextRecord)); - - // Invariant: BrowserSupportedAssetUrl must pass isHttpProtocol check - if (proxyUrl !== null && !isHttpProtocol(proxyUrl)) { - throw new Error( - `browserSupportedAvatarUrlProxy returned a URL with unsupported protocol: ${proxyUrl.protocol}. BrowserSupportedAssetUrl must use http or https protocol.`, - ); - } - - return { - rawAvatarTextRecord, - browserSupportedAvatarUrl: proxyUrl, - usesProxy: proxyUrl !== null, - }; - } catch { - return { - rawAvatarTextRecord, - browserSupportedAvatarUrl: null, - usesProxy: false, - }; - } - } - - // No fallback available - return { - rawAvatarTextRecord, - browserSupportedAvatarUrl: null, - usesProxy: false, - }; -} - /** * Parameters for the useAvatarUrl hook. */ @@ -135,19 +28,20 @@ export interface UseAvatarUrlParameters extends QueryParameter, C name: Name | null; /** * Optional function to build a BrowserSupportedAssetUrl for a name's avatar image - * when the avatar text record uses a non-http/https protocol (e.g., ipfs://, ar://, eip155:/). + * when the avatar text record uses a non-browser-supported protocol (e.g., ipfs://, ar://, eip155:/). * * If undefined, defaults to using the ENS Metadata Service as a proxy for browser-supported avatar urls. * * IMPORTANT: Custom implementations MUST use `toBrowserSupportedUrl()` to create BrowserSupportedAssetUrl values, * or return the result from `buildEnsMetadataServiceAvatarUrl()`. The returned URL is validated at runtime - * to ensure it passes the `isHttpProtocol` check. + * to ensure it passes the `isBrowserSupportedProtocol` check (http, https, or data protocols). * * @param name - The ENS name to get the browser supported avatar URL for * @param avatarUrl - The avatar URL parsed as a URL object, allowing protocol-specific logic (e.g., ipfs:// vs ar://) + * @param namespaceId - The ENS namespace identifier for the name * @returns The browser supported avatar URL, or null if unavailable */ - browserSupportedAvatarUrlProxy?: (name: Name, avatarUrl: URL) => BrowserSupportedAssetUrl | null; + browserSupportedAvatarUrlProxy?: BrowserSupportedAssetUrlProxy; } /** @@ -162,10 +56,10 @@ export interface UseAvatarUrlResult { */ rawAvatarTextRecord: string | null; /** - * A browser-supported (http/https) avatar URL ready for use in tags. - * Populated when the rawAvatarTextRecord is a valid URL that uses the http/https protocol or when a url is available to load the avatar using a proxy. + * A browser-supported (http/https/data) avatar URL ready for use in tags. + * Populated when the rawAvatarTextRecord is a valid URL that uses a browser-supported protocol (http, https, or data) or when a url is available to load the avatar using a proxy. * Null if the avatar text record is not set, if the avatar text record is malformed/invalid, - * or if the avatar uses a non-http/https protocol and no url is known for how to load the avatar using a proxy. + * or if the avatar uses a non-browser-supported protocol and no url is known for how to load the avatar using a proxy. */ browserSupportedAvatarUrl: BrowserSupportedAssetUrl | null; /** @@ -183,8 +77,8 @@ export interface UseAvatarUrlResult { * This hook attempts to get the avatar URL by: * 1. Fetching the avatar text record using useRecords * 2. Normalizing the avatar text record as a URL - * 3. Returning the URL if it uses http or https protocol - * 4. For valid URLs with unsupported protocols (e.g., ipfs://, ar://), using the ENS Metadata Service + * 3. Returning the URL if it uses a browser-supported protocol (http, https, or data) + * 4. For valid URLs with non-browser-supported protocols (e.g., ipfs://, ar://), using the ENS Metadata Service * (or a custom proxy) to convert them to browser-accessible URLs * 5. For malformed/invalid URLs, returning null without attempting proxy conversion * @@ -217,12 +111,12 @@ export interface UseAvatarUrlResult { * @example * ```typescript * // With custom IPFS gateway proxy - * import { useAvatarUrl, toBrowserSupportedUrl } from "@ensnode/ensnode-react"; + * import { useAvatarUrl, toBrowserSupportedUrl, defaultBrowserSupportedAssetUrlProxy } from "@ensnode/ensnode-sdk"; * * function ProfileAvatar({ name }: { name: string }) { * const { data, isLoading } = useAvatarUrl({ * name, - * browserSupportedAvatarUrlProxy: (name, avatarUrl) => { + * browserSupportedAvatarUrlProxy: (name, avatarUrl, namespaceId) => { * // Handle IPFS protocol URLs with a custom gateway * if (avatarUrl.protocol === 'ipfs:') { * // Extract CID and optional path from ipfs://{CID}/{path} @@ -241,8 +135,8 @@ export interface UseAvatarUrlResult { * return toBrowserSupportedUrl(`https://arweave.net/${arweaveId}`); * } * - * // Fall back to ENS Metadata Service for other protocols - * return null; + * // For other protocols, fall back to the ENS Metadata Service + * return defaultBrowserSupportedAssetUrlProxy(name, avatarUrl, namespaceId); * } * }); * @@ -308,12 +202,18 @@ export function useAvatarUrl( const rawAvatarTextRecord = recordsQuery.data.records?.texts?.avatar ?? null; - return buildBrowserSupportedAvatarUrl( + const result = buildBrowserSupportedAvatarUrl( rawAvatarTextRecord, name, namespaceId, browserSupportedAvatarUrlProxy, ); + + return { + rawAvatarTextRecord: result.rawAssetTextRecord, + browserSupportedAvatarUrl: result.browserSupportedAssetUrl, + usesProxy: result.usesProxy, + }; }, retry: false, placeholderData: { diff --git a/packages/ensnode-sdk/src/ens/metadata-service.ts b/packages/ensnode-sdk/src/ens/metadata-service.ts index beeb0da37..720bff8b0 100644 --- a/packages/ensnode-sdk/src/ens/metadata-service.ts +++ b/packages/ensnode-sdk/src/ens/metadata-service.ts @@ -2,9 +2,156 @@ import type { ENSNamespaceId } from "@ensnode/datasources"; import { ENSNamespaceIds } from "@ensnode/datasources"; import type { BrowserSupportedAssetUrl } from "../shared/url"; -import { toBrowserSupportedUrl } from "../shared/url"; +import { buildUrl, isBrowserSupportedProtocol, toBrowserSupportedUrl } from "../shared/url"; import type { Name } from "./types"; +/** + * Function type for generating browser-supported asset URLs through a custom proxy. + * + * @param name - The ENS name to get the browser supported asset URL for + * @param assetUrl - The asset URL parsed as a URL object, allowing protocol-specific logic (e.g., ipfs:// vs ar://) + * @param namespaceId - The ENS namespace identifier for the name + * @returns The browser supported asset URL, or null if unavailable + */ +export type BrowserSupportedAssetUrlProxy = ( + name: Name, + assetUrl: URL, + namespaceId: ENSNamespaceId, +) => BrowserSupportedAssetUrl | null; + +/** + * Default proxy implementation that uses the ENS Metadata Service. + * + * @param name - The ENS name to get the browser supported asset URL for + * @param assetUrl - The asset URL (not used in default implementation) + * @param namespaceId - The ENS namespace identifier for the name + * @returns The browser supported asset URL from ENS Metadata Service, or null if unavailable + */ +export const defaultBrowserSupportedAssetUrlProxy: BrowserSupportedAssetUrlProxy = ( + name: Name, + assetUrl: URL, + namespaceId: ENSNamespaceId, +): BrowserSupportedAssetUrl | null => { + return buildEnsMetadataServiceAvatarUrl(name, namespaceId); +}; + +/** + * Result returned by buildBrowserSupportedAvatarUrl. + * + * Invariant: If rawAssetTextRecord is null, then browserSupportedAssetUrl must also be null. + */ +export interface BrowserSupportedAssetUrlResult { + /** + * The original asset text record value from ENS, before any normalization or proxy processing. + * Null if the asset text record is not set for the ENS name. + */ + rawAssetTextRecord: string | null; + /** + * A browser-supported (http/https/data) asset URL ready for use in tags or other browser contexts. + * Populated when the rawAssetTextRecord is a valid URL that uses a browser-supported protocol (http, https, or data) or when a url is available to load the asset using a proxy. + * Null if the asset text record is not set, if the asset text record is malformed/invalid, + * or if the asset uses a non-browser-supported protocol and no url is known for how to load the asset using a proxy. + */ + browserSupportedAssetUrl: BrowserSupportedAssetUrl | null; + /** + * Indicates whether the browserSupportedAssetUrl uses a proxy service. + * True if the url uses a proxy (either the default ENS Metadata Service or a custom proxy). + * False if the URL comes directly from the asset text record, or if there's no asset text record, + * or if the asset text record has an invalid format, or if no url is known for loading the asset using a proxy. + */ + usesProxy: boolean; +} + +/** + * Builds a browser-supported asset URL for a name's asset image from the name's raw asset text record value. + * + * @param rawAssetTextRecord - The raw asset text record value resolved for `name` on `namespaceId`, or null if `name` has no asset text record on `namespaceId`. + * @param name - The ENS name whose asset text record value was `rawAssetTextRecord` on `namespaceId`. + * @param namespaceId - The ENS namespace where `name` has the asset text record set to `rawAssetTextRecord`. + * @param browserSupportedAssetUrlProxy - Optional function for generating browser support asset urls that route through a custom proxy. If not provided, uses {@link defaultBrowserSupportedAssetUrlProxy}. + * @returns The {@link BrowserSupportedAssetUrlResult} result + * @internal + */ +export function buildBrowserSupportedAvatarUrl( + rawAssetTextRecord: string | null, + name: Name, + namespaceId: ENSNamespaceId, + browserSupportedAssetUrlProxy?: BrowserSupportedAssetUrlProxy, +): BrowserSupportedAssetUrlResult { + // If no asset text record, return null values + if (!rawAssetTextRecord) { + return { + rawAssetTextRecord: null, + browserSupportedAssetUrl: null, + usesProxy: false, + }; + } + + // Check for EIP-155 NFT URIs (CAIP-22 ERC-721 or CAIP-29 ERC-1155) + // These require proxy handling to resolve NFT metadata from blockchain + const isEip155Uri = /^eip155:\d+\/(erc721|erc1155):/i.test(rawAssetTextRecord); + + if (isEip155Uri) { + // Skip toBrowserSupportedUrl normalization and go directly to proxy handling + // This prevents buildUrl from incorrectly prepending https:// to the URI + } else { + // Try to convert to browser-supported URL first + try { + const browserSupportedAssetUrl = toBrowserSupportedUrl(rawAssetTextRecord); + + return { + rawAssetTextRecord, + browserSupportedAssetUrl, + usesProxy: false, + }; + } catch { + // toBrowserSupportedUrl failed - could be non-browser-supported protocol or malformed URL + // Try to parse as a general URL to determine which case we're in + try { + buildUrl(rawAssetTextRecord); + // buildUrl succeeded, so the asset text record is a valid URL with a non-browser-supported protocol + // Continue to proxy handling below + } catch { + // buildUrl failed, so the asset text record is malformed/invalid + // Skip proxy logic and return null + return { + rawAssetTextRecord, + browserSupportedAssetUrl: null, + usesProxy: false, + }; + } + } + } + + // Use custom proxy if provided, otherwise use default + const activeProxy: BrowserSupportedAssetUrlProxy = + browserSupportedAssetUrlProxy ?? defaultBrowserSupportedAssetUrlProxy; + + // For non-browser-supported protocols (ipfs, ar, NFT URIs, etc.), use proxy to convert to browser-supported URL + try { + const proxyUrl = activeProxy(name, buildUrl(rawAssetTextRecord), namespaceId); + + // Invariant: BrowserSupportedAssetUrl must pass isBrowserSupportedProtocol check + if (proxyUrl !== null && !isBrowserSupportedProtocol(proxyUrl)) { + throw new Error( + `browserSupportedAssetUrlProxy returned a URL with unsupported protocol: ${proxyUrl.protocol}. BrowserSupportedAssetUrl must use http, https, or data protocol.`, + ); + } + + return { + rawAssetTextRecord, + browserSupportedAssetUrl: proxyUrl, + usesProxy: proxyUrl !== null, + }; + } catch { + return { + rawAssetTextRecord, + browserSupportedAssetUrl: null, + usesProxy: false, + }; + } +} + /** * Builds a browser-supported avatar image URL for an ENS name using the ENS Metadata Service * (https://metadata.ens.domains/docs). From cb03bf8276275c51920b14a220fac6134cd2932f Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sat, 18 Oct 2025 09:11:24 +0100 Subject: [PATCH 62/73] caip --- packages/ensnode-sdk/package.json | 1 + .../ensnode-sdk/src/ens/metadata-service.ts | 82 +++++++++++++++++-- pnpm-lock.yaml | 3 + 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/packages/ensnode-sdk/package.json b/packages/ensnode-sdk/package.json index 1d5c9b05e..7b07af8d9 100644 --- a/packages/ensnode-sdk/package.json +++ b/packages/ensnode-sdk/package.json @@ -56,6 +56,7 @@ "@adraffy/ens-normalize": "catalog:", "@ensdomains/address-encoder": "^1.1.2", "@ensnode/datasources": "workspace:*", + "caip": "^1.1.1", "zod": "catalog:" } } diff --git a/packages/ensnode-sdk/src/ens/metadata-service.ts b/packages/ensnode-sdk/src/ens/metadata-service.ts index 720bff8b0..02cb9007b 100644 --- a/packages/ensnode-sdk/src/ens/metadata-service.ts +++ b/packages/ensnode-sdk/src/ens/metadata-service.ts @@ -1,10 +1,75 @@ import type { ENSNamespaceId } from "@ensnode/datasources"; import { ENSNamespaceIds } from "@ensnode/datasources"; +import { AssetId } from "caip"; import type { BrowserSupportedAssetUrl } from "../shared/url"; import { buildUrl, isBrowserSupportedProtocol, toBrowserSupportedUrl } from "../shared/url"; import type { Name } from "./types"; +/** + * Validates if a string is a valid IPFS URL. + * + * @param value - The string to validate + * @returns True if the value is a valid IPFS URL, false otherwise + */ +function isValidIpfsUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "ipfs:"; + } catch { + return false; + } +} + +/** + * Validates if a string is a valid CAIP-22 (ERC-721) or CAIP-29 (ERC-1155) identifier. + * + * Uses the caip package's AssetId parser to validate the identifier format + * and checks that it follows the eip155 namespace with erc721 or erc1155 asset types. + * + * @param value - The string to validate as a CAIP-22/29 identifier + * @returns True if the value is a valid CAIP-22 or CAIP-29 identifier, false otherwise + * + * @example + * // Valid CAIP-22 (ERC-721) + * isValidCaipNftIdentifier("eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/771769") + * // => true + * + * @example + * // Valid CAIP-29 (ERC-1155) + * isValidCaipNftIdentifier("eip155:1/erc1155:0xfaafdc07907ff5120a76b34b731b278c38d6043c/1") + * // => true + */ +function isValidCaipNftIdentifier(value: string): boolean { + try { + // Use caip package to parse the identifier + const parsed = AssetId.parse(value); + + // Verify it's an eip155 chain + if ( + typeof parsed.chainId === "object" && + "namespace" in parsed.chainId && + parsed.chainId.namespace !== "eip155" + ) { + return false; + } + + // Verify it's erc721 or erc1155 + if ( + typeof parsed.assetName === "object" && + "namespace" in parsed.assetName && + parsed.assetName.namespace !== "erc721" && + parsed.assetName.namespace !== "erc1155" + ) { + return false; + } + + return true; + } catch { + return false; + } +} + /** * Function type for generating browser-supported asset URLs through a custom proxy. * @@ -87,14 +152,12 @@ export function buildBrowserSupportedAvatarUrl( }; } - // Check for EIP-155 NFT URIs (CAIP-22 ERC-721 or CAIP-29 ERC-1155) - // These require proxy handling to resolve NFT metadata from blockchain - const isEip155Uri = /^eip155:\d+\/(erc721|erc1155):/i.test(rawAssetTextRecord); + // Check for valid IPFS URLs or CAIP-22/29 identifiers that require proxy handling + const requiresProxy = + isValidIpfsUrl(rawAssetTextRecord) || isValidCaipNftIdentifier(rawAssetTextRecord); - if (isEip155Uri) { - // Skip toBrowserSupportedUrl normalization and go directly to proxy handling - // This prevents buildUrl from incorrectly prepending https:// to the URI - } else { + // If the asset text record doesn't require a proxy, attempt to use it directly + if (!requiresProxy) { // Try to convert to browser-supported URL first try { const browserSupportedAssetUrl = toBrowserSupportedUrl(rawAssetTextRecord); @@ -123,6 +186,11 @@ export function buildBrowserSupportedAvatarUrl( } } + // Invariant: At this point, the asset text record either: + // 1. Requires a proxy (IPFS URL or CAIP-22/29 NFT identifier), OR + // 2. Is a valid URL with a non-browser-supported protocol (e.g., ar://) + // In both cases, we attempt to use a proxy to convert to a browser-supported URL. + // Use custom proxy if provided, otherwise use default const activeProxy: BrowserSupportedAssetUrlProxy = browserSupportedAssetUrlProxy ?? defaultBrowserSupportedAssetUrlProxy; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03706fc4c..ceaf4f9c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -688,6 +688,9 @@ importers: '@ensnode/datasources': specifier: workspace:* version: link:../datasources + caip: + specifier: ^1.1.1 + version: 1.1.1 zod: specifier: 'catalog:' version: 3.25.7 From ebe9df77a9f5e17978efbef8bc8a4ed2b87322a0 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sat, 18 Oct 2025 09:12:46 +0100 Subject: [PATCH 63/73] terminology --- .../ensnode-sdk/src/ens/metadata-service.ts | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/ensnode-sdk/src/ens/metadata-service.ts b/packages/ensnode-sdk/src/ens/metadata-service.ts index 02cb9007b..43719e3ff 100644 --- a/packages/ensnode-sdk/src/ens/metadata-service.ts +++ b/packages/ensnode-sdk/src/ens/metadata-service.ts @@ -22,30 +22,31 @@ function isValidIpfsUrl(value: string): boolean { } /** - * Validates if a string is a valid CAIP-22 (ERC-721) or CAIP-29 (ERC-1155) identifier. + * Validates if a string is a valid NFT URI using the eip155 protocol. * + * NFT URIs follow the CAIP-22 (ERC-721) or CAIP-29 (ERC-1155) standard format. * Uses the caip package's AssetId parser to validate the identifier format * and checks that it follows the eip155 namespace with erc721 or erc1155 asset types. * - * @param value - The string to validate as a CAIP-22/29 identifier - * @returns True if the value is a valid CAIP-22 or CAIP-29 identifier, false otherwise + * @param value - The string to validate as an NFT URI (eip155:/ protocol) + * @returns True if the value is a valid NFT URI using the eip155 protocol, false otherwise * * @example - * // Valid CAIP-22 (ERC-721) - * isValidCaipNftIdentifier("eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/771769") + * // Valid NFT URI - CAIP-22 (ERC-721) + * isValidNftUri("eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/771769") * // => true * * @example - * // Valid CAIP-29 (ERC-1155) - * isValidCaipNftIdentifier("eip155:1/erc1155:0xfaafdc07907ff5120a76b34b731b278c38d6043c/1") + * // Valid NFT URI - CAIP-29 (ERC-1155) + * isValidNftUri("eip155:1/erc1155:0xfaafdc07907ff5120a76b34b731b278c38d6043c/1") * // => true */ -function isValidCaipNftIdentifier(value: string): boolean { +function isValidNftUri(value: string): boolean { try { - // Use caip package to parse the identifier + // Use caip package to parse the NFT URI identifier const parsed = AssetId.parse(value); - // Verify it's an eip155 chain + // Verify it uses the eip155 chain namespace if ( typeof parsed.chainId === "object" && "namespace" in parsed.chainId && @@ -54,7 +55,7 @@ function isValidCaipNftIdentifier(value: string): boolean { return false; } - // Verify it's erc721 or erc1155 + // Verify it's an ERC-721 or ERC-1155 token if ( typeof parsed.assetName === "object" && "namespace" in parsed.assetName && @@ -152,9 +153,8 @@ export function buildBrowserSupportedAvatarUrl( }; } - // Check for valid IPFS URLs or CAIP-22/29 identifiers that require proxy handling - const requiresProxy = - isValidIpfsUrl(rawAssetTextRecord) || isValidCaipNftIdentifier(rawAssetTextRecord); + // Check for valid IPFS URLs or NFT URIs (eip155:/) that require proxy handling + const requiresProxy = isValidIpfsUrl(rawAssetTextRecord) || isValidNftUri(rawAssetTextRecord); // If the asset text record doesn't require a proxy, attempt to use it directly if (!requiresProxy) { @@ -187,7 +187,7 @@ export function buildBrowserSupportedAvatarUrl( } // Invariant: At this point, the asset text record either: - // 1. Requires a proxy (IPFS URL or CAIP-22/29 NFT identifier), OR + // 1. Requires a proxy (IPFS URL or NFT URI using eip155:/), OR // 2. Is a valid URL with a non-browser-supported protocol (e.g., ar://) // In both cases, we attempt to use a proxy to convert to a browser-supported URL. @@ -195,7 +195,7 @@ export function buildBrowserSupportedAvatarUrl( const activeProxy: BrowserSupportedAssetUrlProxy = browserSupportedAssetUrlProxy ?? defaultBrowserSupportedAssetUrlProxy; - // For non-browser-supported protocols (ipfs, ar, NFT URIs, etc.), use proxy to convert to browser-supported URL + // For non-browser-supported protocols (ipfs://, ar://, eip155:/, etc.), use proxy to convert to browser-supported URL try { const proxyUrl = activeProxy(name, buildUrl(rawAssetTextRecord), namespaceId); @@ -225,7 +225,7 @@ export function buildBrowserSupportedAvatarUrl( * (https://metadata.ens.domains/docs). * * ENS avatar text records can specify URLs using protocols that browsers don't natively support - * for direct image rendering, such as ipfs://, ar://, or NFT URIs (eip155:). The ENS Metadata + * for direct image rendering, such as ipfs://, ar://, or NFT URIs (eip155:/). The ENS Metadata * Service acts as a proxy to resolve these non-browser-supported protocols and serve the avatar * images via standard HTTP/HTTPS, making them directly usable in tags and other browser * contexts. From adc847ebe11c32e4da17ce23bacf0b1d32bfe094 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sat, 18 Oct 2025 09:41:06 +0100 Subject: [PATCH 64/73] update mocks --- .../ensadmin/src/app/mock/ens-avatar/page.tsx | 170 +++++++++--------- 1 file changed, 86 insertions(+), 84 deletions(-) diff --git a/apps/ensadmin/src/app/mock/ens-avatar/page.tsx b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx index a6182b64e..e1da15cae 100644 --- a/apps/ensadmin/src/app/mock/ens-avatar/page.tsx +++ b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx @@ -3,10 +3,18 @@ import { EnsAvatar, EnsAvatarDisplay } from "@/components/ens-avatar"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; -import { useActiveNamespace } from "@/hooks/active/use-active-namespace"; -import { buildBrowserSupportedAvatarUrl, useAvatarUrl } from "@ensnode/ensnode-react"; -import { Name } from "@ensnode/ensnode-sdk"; +import { ENSNamespaceIds } from "@ensnode/datasources"; +import { useAvatarUrl } from "@ensnode/ensnode-react"; +import { ENSNamespaceId, Name, buildBrowserSupportedAvatarUrl } from "@ensnode/ensnode-sdk"; import { AlertCircle, Check, X } from "lucide-react"; import { useMemo, useState } from "react"; @@ -20,11 +28,10 @@ const TEST_NAMES: Name[] = [ ]; interface AvatarTestCardProps { - defaultName: Name; + name: Name; } -function AvatarTestCard({ defaultName }: AvatarTestCardProps) { - const [name, setName] = useState(defaultName); +function AvatarTestCard({ name }: AvatarTestCardProps) { const { data, isLoading, error } = useAvatarUrl({ name }); if (error) { @@ -33,12 +40,7 @@ function AvatarTestCard({ defaultName }: AvatarTestCardProps) { - setName(e.target.value as Name)} - className="flex-1" - /> + {name} Error loading avatar @@ -53,14 +55,7 @@ function AvatarTestCard({ defaultName }: AvatarTestCardProps) { return ( - - setName(e.target.value as Name)} - className="w-full" - /> - + {name} Loading avatar information... @@ -87,24 +82,15 @@ function AvatarTestCard({ defaultName }: AvatarTestCardProps) { ) : ( )} - setName(e.target.value as Name)} - className="flex-1" - /> + {name} - {hasAvatar ? "Avatar available" : "No avatar available"} - {/* Avatar Display */}
- {/* Avatar Details */}
- {/* Raw Avatar URL */}
Raw Avatar URL: @@ -119,7 +105,6 @@ function AvatarTestCard({ defaultName }: AvatarTestCardProps) {
- {/* Browser-Supported URL */}
Browser-Supported URL: @@ -134,7 +119,6 @@ function AvatarTestCard({ defaultName }: AvatarTestCardProps) {
- {/* Uses Proxy Indicator */}
Uses Proxy:
@@ -158,27 +142,32 @@ function AvatarTestCard({ defaultName }: AvatarTestCardProps) { } /** - * Wrapper component that resolves and renders an avatar using a custom URL. - * Supports http/https and data: URLs directly. + * Wrapper component that resolves and renders an avatar using a custom raw avatar text record. + * Does not make any requests - only uses the provided inputs for resolution. */ -function CustomAvatarWrapper({ customUrl }: { customUrl: string }) { - const testName = "custom-test.eth" as Name; - const namespaceId = useActiveNamespace(); - +function CustomAvatarWrapper({ + rawAssetTextRecord, + name, + namespaceId, +}: { + rawAssetTextRecord: string; + name: Name; + namespaceId: ENSNamespaceId; +}) { // Resolve the avatar URL using the same logic as useAvatarUrl - // This supports http/https and data: URLs directly + // This does NOT make any network requests - it only processes the provided raw text record const resolvedData = useMemo(() => { - return buildBrowserSupportedAvatarUrl(customUrl, testName, namespaceId); - }, [customUrl, testName, namespaceId]); + return buildBrowserSupportedAvatarUrl(rawAssetTextRecord, name, namespaceId); + }, [rawAssetTextRecord, name, namespaceId]); - const hasAvatar = resolvedData.browserSupportedAvatarUrl !== null; + const hasAvatar = resolvedData.browserSupportedAssetUrl !== null; return (
@@ -187,15 +176,15 @@ function CustomAvatarWrapper({ customUrl }: { customUrl: string }) {
- Raw Avatar URL: - {resolvedData.rawAvatarTextRecord ? ( + Raw Avatar Text Record: + {resolvedData.rawAssetTextRecord ? ( ) : ( )}
- {resolvedData.rawAvatarTextRecord || "Not set"} + {resolvedData.rawAssetTextRecord || "Not set"}
@@ -209,24 +198,7 @@ function CustomAvatarWrapper({ customUrl }: { customUrl: string }) { )}
- {resolvedData.browserSupportedAvatarUrl?.toString() || "Not available"} -
-
- -
- Uses Proxy: -
- {resolvedData.usesProxy ? ( - <> - - Yes - - ) : ( - <> - - No - - )} + {resolvedData.browserSupportedAssetUrl?.toString() || "Not available"}
@@ -235,39 +207,69 @@ function CustomAvatarWrapper({ customUrl }: { customUrl: string }) { } function CustomAvatarUrlTestCard() { - const [customUrl, setCustomUrl] = useState(""); + const [namespaceId, setNamespaceId] = useState(ENSNamespaceIds.Mainnet); + const [name, setName] = useState("" as Name); + const [rawAssetTextRecord, setRawAssetTextRecord] = useState(""); return ( - Custom Avatar URL Test + Custom Avatar Text Record - Enter a raw avatar URL to test resolution and display. Supports HTTP/HTTPS and data: URLs. + Enter an ENS namespace, name, and raw avatar text record to test resolution and display. - {/* URL Input */} + {/* Namespace Selection */} +
+ + +
+ + {/* Name Input */}
- + setCustomUrl(e.target.value)} + placeholder="vitalik.eth" + value={name} + onChange={(e) => setName(e.target.value as Name)} />
- {/* Avatar Display */} - {customUrl && } + {/* Avatar URL Input */} +
+ + setRawAssetTextRecord(e.target.value)} + /> +
- {!customUrl && ( -
- Enter a URL above to see how it would be resolved and displayed. -
    -
  • HTTP/HTTPS URLs: e.g., https://example.com/avatar.jpg
  • -
  • Data URLs: e.g., data:image/svg+xml,<svg...></svg>
  • -
-
+ {/* Avatar Display */} + {rawAssetTextRecord && name && ( + )}
@@ -293,7 +295,7 @@ export default function MockAvatarUrlPage() { {/* Existing Test Names Grid */}
{TEST_NAMES.map((name) => ( - + ))}
From 21c91ef8e0dd91abfb999abb0591f741f4681cfd Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Sun, 19 Oct 2025 17:18:46 +0100 Subject: [PATCH 65/73] old tests --- .../src/ens/metadata-service.test.ts | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 packages/ensnode-sdk/src/ens/metadata-service.test.ts diff --git a/packages/ensnode-sdk/src/ens/metadata-service.test.ts b/packages/ensnode-sdk/src/ens/metadata-service.test.ts new file mode 100644 index 000000000..7fd6be923 --- /dev/null +++ b/packages/ensnode-sdk/src/ens/metadata-service.test.ts @@ -0,0 +1,140 @@ +import { ENSNamespaceIds } from "@ensnode/datasources"; +import { describe, expect, it } from "vitest"; +import type { BrowserSupportedAssetUrlProxy } from "./metadata-service"; +import { buildBrowserSupportedAvatarUrl } from "./metadata-service"; + +describe("buildBrowserSupportedAvatarUrl", () => { + describe("nulll rawAssetTextRecord implies null browserSupportedAssetUrl", () => { + it("returns null browserSupportedAssetUrl when rawAssetTextRecord is null", () => { + const result = buildBrowserSupportedAvatarUrl(null, "vitalik.eth", ENSNamespaceIds.Mainnet); + + expect(result.rawAssetTextRecord).toBe(null); + expect(result.browserSupportedAssetUrl).toBe(null); + expect(result.usesProxy).toBe(false); + }); + }); + + describe("URL construction: browser-supported protocols used directly", () => { + it("returns https URL directly without proxy when rawAssetTextRecord uses https protocol", () => { + const httpsUrl = "https://example.com/avatar.png"; + const result = buildBrowserSupportedAvatarUrl( + httpsUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(httpsUrl); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); + expect(result.browserSupportedAssetUrl?.hostname).toBe("example.com"); + expect(result.browserSupportedAssetUrl?.pathname).toBe("/avatar.png"); + expect(result.usesProxy).toBe(false); + }); + + it("returns data URL directly without proxy when rawAssetTextRecord uses data protocol", () => { + const dataUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCI+PC9zdmc+"; + const result = buildBrowserSupportedAvatarUrl( + dataUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(dataUrl); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.protocol).toBe("data:"); + expect(result.usesProxy).toBe(false); + }); + }); + + describe("URL construction for IPFS protocol requires proxy", () => { + it("routes IPFS URLs through default proxy and constructs correct ENS Metadata Service URL", () => { + const ipfsUrl = "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; + const ensName = "lightwalker.eth"; + const result = buildBrowserSupportedAvatarUrl(ipfsUrl, ensName, ENSNamespaceIds.Mainnet); + + expect(result.rawAssetTextRecord).toBe(ipfsUrl); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); + expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); + expect(result.browserSupportedAssetUrl?.pathname).toBe( + `/mainnet/avatar/${encodeURIComponent(ensName)}`, + ); + expect(result.usesProxy).toBe(true); + }); + }); + + describe("URL construction (eip155) require proxy", () => { + it("routes CAIP-22 ERC-721 NFT URIs through proxy with correct URL construction", () => { + // CAIP-22: ERC-721 NFT URI format + + const nftUri = "eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/771769"; + + const ensName = "nft.eth"; + + const result = buildBrowserSupportedAvatarUrl(nftUri, ensName, ENSNamespaceIds.Sepolia); + + expect(result.rawAssetTextRecord).toBe(nftUri); + + expect(result.browserSupportedAssetUrl).not.toBe(null); + + expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); + + expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); + expect(result.browserSupportedAssetUrl?.pathname).toBe( + `/sepolia/avatar/${encodeURIComponent(ensName)}`, + ); + expect(result.usesProxy).toBe(true); + }); + }); + + describe("custom proxy overrides default", () => { + it("uses custom proxy when provided and validates returned URL has browser-supported protocol", () => { + const ipfsUrl = "ipfs://QmCustomHash123"; + const ensName = "lightwalker.eth"; + + // Custom proxy that returns a different HTTPS URL + const customProxy: BrowserSupportedAssetUrlProxy = (name, assetUrl, namespaceId) => { + expect(name).toBe(ensName); + expect(assetUrl.protocol).toBe("ipfs:"); + expect(namespaceId).toBe(ENSNamespaceIds.Mainnet); + + return new URL(`https://my-custom-proxy.com/${name}/avatar`); + }; + + const result = buildBrowserSupportedAvatarUrl( + ipfsUrl, + ensName, + ENSNamespaceIds.Mainnet, + customProxy, + ); + + expect(result.rawAssetTextRecord).toBe(ipfsUrl); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.hostname).toBe("my-custom-proxy.com"); + expect(result.browserSupportedAssetUrl?.pathname).toBe(`/${ensName}/avatar`); + expect(result.usesProxy).toBe(true); + }); + + it("throws error when custom proxy returns URL with non-browser-supported protocol", () => { + const ipfsUrl = "ipfs://QmHash"; + + const badProxy: BrowserSupportedAssetUrlProxy = () => { + return new URL("ftp://bad-proxy.com/avatar.png"); + }; + + const result = buildBrowserSupportedAvatarUrl( + ipfsUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + badProxy, + ); + + // Should catch the error + // 2. and return null browserSupportedAssetUrl + + expect(result.rawAssetTextRecord).toBe(ipfsUrl); + expect(result.browserSupportedAssetUrl).toBe(null); + expect(result.usesProxy).toBe(false); + }); + }); +}); From 4151338986763bf408cddc5773abcbe995634240 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 20 Oct 2025 09:22:43 +0100 Subject: [PATCH 66/73] replace old tests --- .../ensadmin/src/app/mock/ens-avatar/page.tsx | 2 +- .../src/ens/metadata-service.test.ts | 427 ++++++++++++------ 2 files changed, 296 insertions(+), 133 deletions(-) diff --git a/apps/ensadmin/src/app/mock/ens-avatar/page.tsx b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx index e1da15cae..35e0ceeaf 100644 --- a/apps/ensadmin/src/app/mock/ens-avatar/page.tsx +++ b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx @@ -23,7 +23,7 @@ const TEST_NAMES: Name[] = [ "brantly.eth", "ada.eth", "jesse.base.eth", - "norecordset.eth", + "skeleton.mfpurrs.eth", "vitalik.eth", ]; diff --git a/packages/ensnode-sdk/src/ens/metadata-service.test.ts b/packages/ensnode-sdk/src/ens/metadata-service.test.ts index 7fd6be923..db5c821f0 100644 --- a/packages/ensnode-sdk/src/ens/metadata-service.test.ts +++ b/packages/ensnode-sdk/src/ens/metadata-service.test.ts @@ -4,137 +4,300 @@ import type { BrowserSupportedAssetUrlProxy } from "./metadata-service"; import { buildBrowserSupportedAvatarUrl } from "./metadata-service"; describe("buildBrowserSupportedAvatarUrl", () => { - describe("nulll rawAssetTextRecord implies null browserSupportedAssetUrl", () => { - it("returns null browserSupportedAssetUrl when rawAssetTextRecord is null", () => { - const result = buildBrowserSupportedAvatarUrl(null, "vitalik.eth", ENSNamespaceIds.Mainnet); - - expect(result.rawAssetTextRecord).toBe(null); - expect(result.browserSupportedAssetUrl).toBe(null); - expect(result.usesProxy).toBe(false); - }); - }); - - describe("URL construction: browser-supported protocols used directly", () => { - it("returns https URL directly without proxy when rawAssetTextRecord uses https protocol", () => { - const httpsUrl = "https://example.com/avatar.png"; - const result = buildBrowserSupportedAvatarUrl( - httpsUrl, - "lightwalker.eth", - ENSNamespaceIds.Mainnet, - ); - - expect(result.rawAssetTextRecord).toBe(httpsUrl); - expect(result.browserSupportedAssetUrl).not.toBe(null); - expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); - expect(result.browserSupportedAssetUrl?.hostname).toBe("example.com"); - expect(result.browserSupportedAssetUrl?.pathname).toBe("/avatar.png"); - expect(result.usesProxy).toBe(false); - }); - - it("returns data URL directly without proxy when rawAssetTextRecord uses data protocol", () => { - const dataUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCI+PC9zdmc+"; - const result = buildBrowserSupportedAvatarUrl( - dataUrl, - "lightwalker.eth", - ENSNamespaceIds.Mainnet, - ); - - expect(result.rawAssetTextRecord).toBe(dataUrl); - expect(result.browserSupportedAssetUrl).not.toBe(null); - expect(result.browserSupportedAssetUrl?.protocol).toBe("data:"); - expect(result.usesProxy).toBe(false); - }); - }); - - describe("URL construction for IPFS protocol requires proxy", () => { - it("routes IPFS URLs through default proxy and constructs correct ENS Metadata Service URL", () => { - const ipfsUrl = "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; - const ensName = "lightwalker.eth"; - const result = buildBrowserSupportedAvatarUrl(ipfsUrl, ensName, ENSNamespaceIds.Mainnet); - - expect(result.rawAssetTextRecord).toBe(ipfsUrl); - expect(result.browserSupportedAssetUrl).not.toBe(null); - expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); - expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); - expect(result.browserSupportedAssetUrl?.pathname).toBe( - `/mainnet/avatar/${encodeURIComponent(ensName)}`, - ); - expect(result.usesProxy).toBe(true); - }); - }); - - describe("URL construction (eip155) require proxy", () => { - it("routes CAIP-22 ERC-721 NFT URIs through proxy with correct URL construction", () => { - // CAIP-22: ERC-721 NFT URI format - - const nftUri = "eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/771769"; - - const ensName = "nft.eth"; - - const result = buildBrowserSupportedAvatarUrl(nftUri, ensName, ENSNamespaceIds.Sepolia); - - expect(result.rawAssetTextRecord).toBe(nftUri); - - expect(result.browserSupportedAssetUrl).not.toBe(null); - - expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); - - expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); - expect(result.browserSupportedAssetUrl?.pathname).toBe( - `/sepolia/avatar/${encodeURIComponent(ensName)}`, - ); - expect(result.usesProxy).toBe(true); - }); - }); - - describe("custom proxy overrides default", () => { - it("uses custom proxy when provided and validates returned URL has browser-supported protocol", () => { - const ipfsUrl = "ipfs://QmCustomHash123"; - const ensName = "lightwalker.eth"; - - // Custom proxy that returns a different HTTPS URL - const customProxy: BrowserSupportedAssetUrlProxy = (name, assetUrl, namespaceId) => { - expect(name).toBe(ensName); - expect(assetUrl.protocol).toBe("ipfs:"); - expect(namespaceId).toBe(ENSNamespaceIds.Mainnet); - - return new URL(`https://my-custom-proxy.com/${name}/avatar`); - }; - - const result = buildBrowserSupportedAvatarUrl( - ipfsUrl, - ensName, - ENSNamespaceIds.Mainnet, - customProxy, - ); - - expect(result.rawAssetTextRecord).toBe(ipfsUrl); - expect(result.browserSupportedAssetUrl).not.toBe(null); - expect(result.browserSupportedAssetUrl?.hostname).toBe("my-custom-proxy.com"); - expect(result.browserSupportedAssetUrl?.pathname).toBe(`/${ensName}/avatar`); - expect(result.usesProxy).toBe(true); - }); - - it("throws error when custom proxy returns URL with non-browser-supported protocol", () => { - const ipfsUrl = "ipfs://QmHash"; - - const badProxy: BrowserSupportedAssetUrlProxy = () => { - return new URL("ftp://bad-proxy.com/avatar.png"); - }; - - const result = buildBrowserSupportedAvatarUrl( - ipfsUrl, - "lightwalker.eth", - ENSNamespaceIds.Mainnet, - badProxy, - ); - - // Should catch the error - // 2. and return null browserSupportedAssetUrl - - expect(result.rawAssetTextRecord).toBe(ipfsUrl); - expect(result.browserSupportedAssetUrl).toBe(null); - expect(result.usesProxy).toBe(false); - }); + it("returns null browserSupportedAssetUrl when rawAssetTextRecord is null", () => { + const result = buildBrowserSupportedAvatarUrl(null, "lightwalker.eth", ENSNamespaceIds.Mainnet); + + expect(result.rawAssetTextRecord).toBe(null); + expect(result.browserSupportedAssetUrl).toBe(null); + expect(result.usesProxy).toBe(false); + }); + + it("returns https URL directly without proxy", () => { + const httpsUrl = "https://example.com/avatar.png"; + const result = buildBrowserSupportedAvatarUrl( + httpsUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(httpsUrl); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); + expect(result.browserSupportedAssetUrl?.hostname).toBe("example.com"); + expect(result.browserSupportedAssetUrl?.pathname).toBe("/avatar.png"); + expect(result.usesProxy).toBe(false); + }); + + it("returns http URL directly without proxy", () => { + const httpUrl = "http://example.com/avatar.png"; + const result = buildBrowserSupportedAvatarUrl( + httpUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(httpUrl); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.protocol).toBe("http:"); + expect(result.browserSupportedAssetUrl?.hostname).toBe("example.com"); + expect(result.browserSupportedAssetUrl?.pathname).toBe("/avatar.png"); + expect(result.usesProxy).toBe(false); + }); + + it("returns data URL with SVG directly without proxy", () => { + const dataUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCI+PC9zdmc+"; + const result = buildBrowserSupportedAvatarUrl( + dataUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(dataUrl); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.protocol).toBe("data:"); + expect(result.usesProxy).toBe(false); + }); + + it("returns data URL with PNG directly withotu proxy (ENSIP-12)", () => { + const dataUrl = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + const result = buildBrowserSupportedAvatarUrl( + dataUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(dataUrl); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.protocol).toBe("data:"); + expect(result.usesProxy).toBe(false); + }); + + it("returns data URL with JPG directly without proxy (ENSIP-12)", () => { + const dataUrl = + "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwABmQ//Z"; + const result = buildBrowserSupportedAvatarUrl( + dataUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(dataUrl); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.protocol).toBe("data:"); + expect(result.usesProxy).toBe(false); + }); + + it("routes IPFS URLs through proxy to ENS Metadata Service", () => { + const ipfsUrl = "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; + const ensName = "lightwalker.eth"; + const result = buildBrowserSupportedAvatarUrl(ipfsUrl, ensName, ENSNamespaceIds.Mainnet); + + expect(result.rawAssetTextRecord).toBe(ipfsUrl); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); + expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); + expect(result.browserSupportedAssetUrl?.pathname).toBe( + `/mainnet/avatar/${encodeURIComponent(ensName)}`, + ); + expect(result.usesProxy).toBe(true); + }); + + it("routes IPFS URLs through proxy (ENSIP-12 BAYC)", () => { + const ipfsUrl = "ipfs://QmRRPWG96cmgTn2qSzjwr2qvfNEuhunv6FNeMFGa9bx6mQ"; + const result = buildBrowserSupportedAvatarUrl( + ipfsUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(ipfsUrl); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); + expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); + expect(result.usesProxy).toBe(true); + }); + + // TODO: + it("routes CAIP-22 ERC-721 NFT URIs through proxy", () => { + const nftUri = "eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/771769"; + const ensName = "lightwalker.eth"; + const result = buildBrowserSupportedAvatarUrl(nftUri, ensName, ENSNamespaceIds.Sepolia); + + expect(result.rawAssetTextRecord).toBe(nftUri); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); + expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); + expect(result.browserSupportedAssetUrl?.pathname).toBe( + `/sepolia/avatar/${encodeURIComponent(ensName)}`, + ); + expect(result.usesProxy).toBe(true); + }); + + it("routes CAIP-22 ERC-721 NFT URIs through proxy (ENSIP-12 BAYC example)", () => { + const nftUri = "eip155:1/erc721:0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d/0"; + const result = buildBrowserSupportedAvatarUrl( + nftUri, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(nftUri); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); + expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); + expect(result.usesProxy).toBe(true); + }); + + it("routes CAIP-29 ERC-1155 NFT URIs through proxy", () => { + const nftUri = "eip155:1/erc1155:0xfaafdc07907ff5120a76b34b731b278c38d6043c/1"; + const result = buildBrowserSupportedAvatarUrl( + nftUri, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(nftUri); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); + expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); + expect(result.usesProxy).toBe(true); + }); + + it("routes CAIP-22 NFT URI on Polygon through proxy", () => { + const nftUri = "eip155:137/erc721:0x2953399124F0cBB46d2CbACD8A89cF0599974963/1"; + const result = buildBrowserSupportedAvatarUrl( + nftUri, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(nftUri); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.usesProxy).toBe(true); + }); + + it("returns null for malformed URL", () => { + const malformedUrl = "not-a-valid-url"; + const result = buildBrowserSupportedAvatarUrl( + malformedUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(malformedUrl); + expect(result.browserSupportedAssetUrl).toBe(null); + expect(result.usesProxy).toBe(false); + }); + + it("returns null for empty string", () => { + const emptyString = ""; + const result = buildBrowserSupportedAvatarUrl( + emptyString, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(emptyString); + expect(result.browserSupportedAssetUrl).toBe(null); + expect(result.usesProxy).toBe(false); + }); + + // TODO: CAPI Formats + it("returns null for invalid CAIP format (not eip155 namespace)", () => { + const invalidCaip = "cosmos:cosmoshub-4/nft:0x123/1"; + const result = buildBrowserSupportedAvatarUrl( + invalidCaip, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(invalidCaip); + expect(result.browserSupportedAssetUrl).toBe(null); + expect(result.usesProxy).toBe(false); + }); + + it("returns null for invalid NFT asset type (not erc721 or erc1155)", () => { + const invalidAssetType = "eip155:1/erc20:0x123/1"; + const result = buildBrowserSupportedAvatarUrl( + invalidAssetType, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.rawAssetTextRecord).toBe(invalidAssetType); + expect(result.browserSupportedAssetUrl).toBe(null); + expect(result.usesProxy).toBe(false); + }); + + it("uses custom proxy for IPFS URLs", () => { + const ipfsUrl = "ipfs://QmCustomHash123"; + const ensName = "lightwalker.eth"; + + const customProxy: BrowserSupportedAssetUrlProxy = (name, assetUrl, namespaceId) => { + expect(name).toBe(ensName); + expect(assetUrl.protocol).toBe("ipfs:"); + expect(namespaceId).toBe(ENSNamespaceIds.Mainnet); + + return new URL(`https://my-custom-proxy.com/${name}/avatar`); + }; + + const result = buildBrowserSupportedAvatarUrl( + ipfsUrl, + ensName, + ENSNamespaceIds.Mainnet, + customProxy, + ); + + expect(result.rawAssetTextRecord).toBe(ipfsUrl); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.hostname).toBe("my-custom-proxy.com"); + expect(result.browserSupportedAssetUrl?.pathname).toBe(`/${ensName}/avatar`); + expect(result.usesProxy).toBe(true); + }); + + it("uses custom proxy for NFT URIs", () => { + const nftUri = "eip155:1/erc721:0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d/0"; + const ensName = "lightwalker.eth"; + + const customProxy: BrowserSupportedAssetUrlProxy = (name, assetUrl, namespaceId) => { + expect(name).toBe(ensName); + expect(assetUrl.protocol).toBe("eip155:"); + expect(namespaceId).toBe(ENSNamespaceIds.Mainnet); + + return new URL(`https://nft-proxy.example.com/${name}`); + }; + + const result = buildBrowserSupportedAvatarUrl( + nftUri, + ensName, + ENSNamespaceIds.Mainnet, + customProxy, + ); + + expect(result.rawAssetTextRecord).toBe(nftUri); + expect(result.browserSupportedAssetUrl).not.toBe(null); + expect(result.browserSupportedAssetUrl?.hostname).toBe("nft-proxy.example.com"); + expect(result.usesProxy).toBe(true); + }); + + // TODO: Non supported browser protocols to return null + it("returns null when custom proxy returns non-browser-supported protocol", () => { + const ipfsUrl = "ipfs://QmHash"; + + const badProxy: BrowserSupportedAssetUrlProxy = () => { + return new URL("ftp://bad-proxy.com/avatar.png"); + }; + + const result = buildBrowserSupportedAvatarUrl( + ipfsUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + badProxy, + ); + + expect(result.rawAssetTextRecord).toBe(ipfsUrl); + expect(result.browserSupportedAssetUrl).toBe(null); + expect(result.usesProxy).toBe(false); }); }); From 9912ed21a63e1ed467933f554e28f1e516641858 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 20 Oct 2025 09:27:32 +0100 Subject: [PATCH 67/73] replace old tests --- .../src/ens/metadata-service.test.ts | 183 ++---------------- 1 file changed, 19 insertions(+), 164 deletions(-) diff --git a/packages/ensnode-sdk/src/ens/metadata-service.test.ts b/packages/ensnode-sdk/src/ens/metadata-service.test.ts index db5c821f0..46dd93e54 100644 --- a/packages/ensnode-sdk/src/ens/metadata-service.test.ts +++ b/packages/ensnode-sdk/src/ens/metadata-service.test.ts @@ -4,7 +4,7 @@ import type { BrowserSupportedAssetUrlProxy } from "./metadata-service"; import { buildBrowserSupportedAvatarUrl } from "./metadata-service"; describe("buildBrowserSupportedAvatarUrl", () => { - it("returns null browserSupportedAssetUrl when rawAssetTextRecord is null", () => { + it("returns null when rawAssetTextRecord is null", () => { const result = buildBrowserSupportedAvatarUrl(null, "lightwalker.eth", ENSNamespaceIds.Mainnet); expect(result.rawAssetTextRecord).toBe(null); @@ -12,7 +12,7 @@ describe("buildBrowserSupportedAvatarUrl", () => { expect(result.usesProxy).toBe(false); }); - it("returns https URL directly without proxy", () => { + it("uses https URLs directly without proxy", () => { const httpsUrl = "https://example.com/avatar.png"; const result = buildBrowserSupportedAvatarUrl( httpsUrl, @@ -20,15 +20,11 @@ describe("buildBrowserSupportedAvatarUrl", () => { ENSNamespaceIds.Mainnet, ); - expect(result.rawAssetTextRecord).toBe(httpsUrl); - expect(result.browserSupportedAssetUrl).not.toBe(null); expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); - expect(result.browserSupportedAssetUrl?.hostname).toBe("example.com"); - expect(result.browserSupportedAssetUrl?.pathname).toBe("/avatar.png"); expect(result.usesProxy).toBe(false); }); - it("returns http URL directly without proxy", () => { + it("uses http URLs directly without proxy", () => { const httpUrl = "http://example.com/avatar.png"; const result = buildBrowserSupportedAvatarUrl( httpUrl, @@ -36,15 +32,11 @@ describe("buildBrowserSupportedAvatarUrl", () => { ENSNamespaceIds.Mainnet, ); - expect(result.rawAssetTextRecord).toBe(httpUrl); - expect(result.browserSupportedAssetUrl).not.toBe(null); expect(result.browserSupportedAssetUrl?.protocol).toBe("http:"); - expect(result.browserSupportedAssetUrl?.hostname).toBe("example.com"); - expect(result.browserSupportedAssetUrl?.pathname).toBe("/avatar.png"); expect(result.usesProxy).toBe(false); }); - it("returns data URL with SVG directly without proxy", () => { + it("uses data URLs directly without proxy", () => { const dataUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCI+PC9zdmc+"; const result = buildBrowserSupportedAvatarUrl( dataUrl, @@ -52,89 +44,24 @@ describe("buildBrowserSupportedAvatarUrl", () => { ENSNamespaceIds.Mainnet, ); - expect(result.rawAssetTextRecord).toBe(dataUrl); - expect(result.browserSupportedAssetUrl).not.toBe(null); expect(result.browserSupportedAssetUrl?.protocol).toBe("data:"); expect(result.usesProxy).toBe(false); }); - it("returns data URL with PNG directly withotu proxy (ENSIP-12)", () => { - const dataUrl = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; - const result = buildBrowserSupportedAvatarUrl( - dataUrl, - "lightwalker.eth", - ENSNamespaceIds.Mainnet, - ); - - expect(result.rawAssetTextRecord).toBe(dataUrl); - expect(result.browserSupportedAssetUrl).not.toBe(null); - expect(result.browserSupportedAssetUrl?.protocol).toBe("data:"); - expect(result.usesProxy).toBe(false); - }); - - it("returns data URL with JPG directly without proxy (ENSIP-12)", () => { - const dataUrl = - "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwABmQ//Z"; - const result = buildBrowserSupportedAvatarUrl( - dataUrl, - "lightwalker.eth", - ENSNamespaceIds.Mainnet, - ); - - expect(result.rawAssetTextRecord).toBe(dataUrl); - expect(result.browserSupportedAssetUrl).not.toBe(null); - expect(result.browserSupportedAssetUrl?.protocol).toBe("data:"); - expect(result.usesProxy).toBe(false); - }); - - it("routes IPFS URLs through proxy to ENS Metadata Service", () => { + it("uses proxy for IPFS URLs", () => { const ipfsUrl = "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; - const ensName = "lightwalker.eth"; - const result = buildBrowserSupportedAvatarUrl(ipfsUrl, ensName, ENSNamespaceIds.Mainnet); - - expect(result.rawAssetTextRecord).toBe(ipfsUrl); - expect(result.browserSupportedAssetUrl).not.toBe(null); - expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); - expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); - expect(result.browserSupportedAssetUrl?.pathname).toBe( - `/mainnet/avatar/${encodeURIComponent(ensName)}`, - ); - expect(result.usesProxy).toBe(true); - }); - - it("routes IPFS URLs through proxy (ENSIP-12 BAYC)", () => { - const ipfsUrl = "ipfs://QmRRPWG96cmgTn2qSzjwr2qvfNEuhunv6FNeMFGa9bx6mQ"; const result = buildBrowserSupportedAvatarUrl( ipfsUrl, "lightwalker.eth", ENSNamespaceIds.Mainnet, ); - expect(result.rawAssetTextRecord).toBe(ipfsUrl); - expect(result.browserSupportedAssetUrl).not.toBe(null); expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); expect(result.usesProxy).toBe(true); }); - // TODO: - it("routes CAIP-22 ERC-721 NFT URIs through proxy", () => { - const nftUri = "eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/771769"; - const ensName = "lightwalker.eth"; - const result = buildBrowserSupportedAvatarUrl(nftUri, ensName, ENSNamespaceIds.Sepolia); - - expect(result.rawAssetTextRecord).toBe(nftUri); - expect(result.browserSupportedAssetUrl).not.toBe(null); - expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); - expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); - expect(result.browserSupportedAssetUrl?.pathname).toBe( - `/sepolia/avatar/${encodeURIComponent(ensName)}`, - ); - expect(result.usesProxy).toBe(true); - }); - - it("routes CAIP-22 ERC-721 NFT URIs through proxy (ENSIP-12 BAYC example)", () => { + it("uses proxy for CAIP-22 ERC-721 NFT URIs", () => { const nftUri = "eip155:1/erc721:0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d/0"; const result = buildBrowserSupportedAvatarUrl( nftUri, @@ -142,14 +69,12 @@ describe("buildBrowserSupportedAvatarUrl", () => { ENSNamespaceIds.Mainnet, ); - expect(result.rawAssetTextRecord).toBe(nftUri); - expect(result.browserSupportedAssetUrl).not.toBe(null); expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); expect(result.usesProxy).toBe(true); }); - it("routes CAIP-29 ERC-1155 NFT URIs through proxy", () => { + it("uses proxy for CAIP-29 ERC-1155 NFT URIs", () => { const nftUri = "eip155:1/erc1155:0xfaafdc07907ff5120a76b34b731b278c38d6043c/1"; const result = buildBrowserSupportedAvatarUrl( nftUri, @@ -157,134 +82,65 @@ describe("buildBrowserSupportedAvatarUrl", () => { ENSNamespaceIds.Mainnet, ); - expect(result.rawAssetTextRecord).toBe(nftUri); - expect(result.browserSupportedAssetUrl).not.toBe(null); expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); - expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); expect(result.usesProxy).toBe(true); }); - it("routes CAIP-22 NFT URI on Polygon through proxy", () => { - const nftUri = "eip155:137/erc721:0x2953399124F0cBB46d2CbACD8A89cF0599974963/1"; - const result = buildBrowserSupportedAvatarUrl( - nftUri, - "lightwalker.eth", - ENSNamespaceIds.Mainnet, - ); - - expect(result.rawAssetTextRecord).toBe(nftUri); - expect(result.browserSupportedAssetUrl).not.toBe(null); - expect(result.usesProxy).toBe(true); - }); - - it("returns null for malformed URL", () => { - const malformedUrl = "not-a-valid-url"; - const result = buildBrowserSupportedAvatarUrl( - malformedUrl, - "lightwalker.eth", - ENSNamespaceIds.Mainnet, - ); - - expect(result.rawAssetTextRecord).toBe(malformedUrl); - expect(result.browserSupportedAssetUrl).toBe(null); - expect(result.usesProxy).toBe(false); - }); - - it("returns null for empty string", () => { - const emptyString = ""; + it("returns null for invaldi CAIP namespace (not eip155)", () => { + const invalidCaip = "cosmos:cosmoshub-4/nft:0x123/1"; const result = buildBrowserSupportedAvatarUrl( - emptyString, + invalidCaip, "lightwalker.eth", ENSNamespaceIds.Mainnet, ); - expect(result.rawAssetTextRecord).toBe(emptyString); expect(result.browserSupportedAssetUrl).toBe(null); expect(result.usesProxy).toBe(false); }); - // TODO: CAPI Formats - it("returns null for invalid CAIP format (not eip155 namespace)", () => { - const invalidCaip = "cosmos:cosmoshub-4/nft:0x123/1"; + it("returns null for invalid asset type (not erc721 or erc1155)", () => { + const invalidAssetType = "eip155:1/erc20:0x123/1"; const result = buildBrowserSupportedAvatarUrl( - invalidCaip, + invalidAssetType, "lightwalker.eth", ENSNamespaceIds.Mainnet, ); - expect(result.rawAssetTextRecord).toBe(invalidCaip); expect(result.browserSupportedAssetUrl).toBe(null); expect(result.usesProxy).toBe(false); }); - it("returns null for invalid NFT asset type (not erc721 or erc1155)", () => { - const invalidAssetType = "eip155:1/erc20:0x123/1"; + it("returns null for malformed URLs", () => { + const malformedUrl = "not-a-valid-url"; const result = buildBrowserSupportedAvatarUrl( - invalidAssetType, + malformedUrl, "lightwalker.eth", ENSNamespaceIds.Mainnet, ); - expect(result.rawAssetTextRecord).toBe(invalidAssetType); expect(result.browserSupportedAssetUrl).toBe(null); expect(result.usesProxy).toBe(false); }); - it("uses custom proxy for IPFS URLs", () => { + it("uses custom proxy when provided", () => { const ipfsUrl = "ipfs://QmCustomHash123"; - const ensName = "lightwalker.eth"; - - const customProxy: BrowserSupportedAssetUrlProxy = (name, assetUrl, namespaceId) => { - expect(name).toBe(ensName); - expect(assetUrl.protocol).toBe("ipfs:"); - expect(namespaceId).toBe(ENSNamespaceIds.Mainnet); - + const customProxy: BrowserSupportedAssetUrlProxy = (name) => { return new URL(`https://my-custom-proxy.com/${name}/avatar`); }; const result = buildBrowserSupportedAvatarUrl( ipfsUrl, - ensName, + "lightwalker.eth", ENSNamespaceIds.Mainnet, customProxy, ); - expect(result.rawAssetTextRecord).toBe(ipfsUrl); - expect(result.browserSupportedAssetUrl).not.toBe(null); expect(result.browserSupportedAssetUrl?.hostname).toBe("my-custom-proxy.com"); - expect(result.browserSupportedAssetUrl?.pathname).toBe(`/${ensName}/avatar`); - expect(result.usesProxy).toBe(true); - }); - - it("uses custom proxy for NFT URIs", () => { - const nftUri = "eip155:1/erc721:0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d/0"; - const ensName = "lightwalker.eth"; - - const customProxy: BrowserSupportedAssetUrlProxy = (name, assetUrl, namespaceId) => { - expect(name).toBe(ensName); - expect(assetUrl.protocol).toBe("eip155:"); - expect(namespaceId).toBe(ENSNamespaceIds.Mainnet); - - return new URL(`https://nft-proxy.example.com/${name}`); - }; - - const result = buildBrowserSupportedAvatarUrl( - nftUri, - ensName, - ENSNamespaceIds.Mainnet, - customProxy, - ); - - expect(result.rawAssetTextRecord).toBe(nftUri); - expect(result.browserSupportedAssetUrl).not.toBe(null); - expect(result.browserSupportedAssetUrl?.hostname).toBe("nft-proxy.example.com"); expect(result.usesProxy).toBe(true); }); - // TODO: Non supported browser protocols to return null it("returns null when custom proxy returns non-browser-supported protocol", () => { const ipfsUrl = "ipfs://QmHash"; - const badProxy: BrowserSupportedAssetUrlProxy = () => { return new URL("ftp://bad-proxy.com/avatar.png"); }; @@ -296,7 +152,6 @@ describe("buildBrowserSupportedAvatarUrl", () => { badProxy, ); - expect(result.rawAssetTextRecord).toBe(ipfsUrl); expect(result.browserSupportedAssetUrl).toBe(null); expect(result.usesProxy).toBe(false); }); From b4b91d3857a051d0376733c3064d50980dd9e3db Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 20 Oct 2025 16:41:38 +0100 Subject: [PATCH 68/73] handle invalid URLs --- .../ensnode-sdk/src/ens/metadata-service.ts | 13 +++++++- packages/ensnode-sdk/src/shared/url.ts | 30 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/ensnode-sdk/src/ens/metadata-service.ts b/packages/ensnode-sdk/src/ens/metadata-service.ts index 43719e3ff..b936bacf0 100644 --- a/packages/ensnode-sdk/src/ens/metadata-service.ts +++ b/packages/ensnode-sdk/src/ens/metadata-service.ts @@ -167,8 +167,19 @@ export function buildBrowserSupportedAvatarUrl( browserSupportedAssetUrl, usesProxy: false, }; - } catch { + } catch (error) { // toBrowserSupportedUrl failed - could be non-browser-supported protocol or malformed URL + // Check if it's a hostname validation error + if (error instanceof Error && error.message.includes("Invalid hostname")) { + // Hostname validation failed, so the asset text record is malformed/invalid + // Skip proxy logic and return null + return { + rawAssetTextRecord, + browserSupportedAssetUrl: null, + usesProxy: false, + }; + } + // Try to parse as a general URL to determine which case we're in try { buildUrl(rawAssetTextRecord); diff --git a/packages/ensnode-sdk/src/shared/url.ts b/packages/ensnode-sdk/src/shared/url.ts index 973fbce79..5e2007e9a 100644 --- a/packages/ensnode-sdk/src/shared/url.ts +++ b/packages/ensnode-sdk/src/shared/url.ts @@ -61,14 +61,18 @@ export function isBrowserSupportedProtocol(url: URL): boolean { * * This function normalizes the URL string (adding 'https://' if no protocol is specified), * then validates that the resulting URL uses a browser-supported protocol (http/https/data) - * before returning it as a BrowserSupportedAssetUrl type. + * and has a valid hostname structure before returning it as a BrowserSupportedAssetUrl type. * * Special handling for data: URLs - they are parsed directly without normalization to preserve * the data content integrity. * + * Hostname validation for http/https protocols: + * - Must contain at least one dot (.) OR be "localhost" + * - Must not have empty labels (no leading, trailing, or consecutive dots) + * * @param urlString - The URL string to validate and convert. If no protocol is specified, 'https://' will be prepended. - * @returns A BrowserSupportedAssetUrl if the protocol is (or becomes) http/https/data - * @throws if the URL string is invalid or uses a non-browser-supported protocol + * @returns A BrowserSupportedAssetUrl if the protocol is (or becomes) http/https/data and hostname is valid + * @throws if the URL string is invalid, uses a non-browser-supported protocol, or has an invalid hostname * * @example * ```typescript @@ -84,6 +88,10 @@ export function isBrowserSupportedProtocol(url: URL): boolean { * * // Non-browser-supported protocols - throws error * toBrowserSupportedUrl('ipfs://QmHash') // throws Error + * + * // Invalid hostnames - throws error + * toBrowserSupportedUrl('not-a-valid-url') // throws Error (no dot in hostname) + * toBrowserSupportedUrl('https://.com') // throws Error (empty label) * ``` */ export function toBrowserSupportedUrl(urlString: string): BrowserSupportedAssetUrl { @@ -102,5 +110,21 @@ export function toBrowserSupportedUrl(urlString: string): BrowserSupportedAssetU ); } + // Validate hostname structure for http/https protocols + // data: URLs don't have hostnames, so skip this validation for them + if (isHttpProtocol(url)) { + // Hostname must contain at least one dot or be "localhost" + if (!url.hostname.includes(".") && url.hostname !== "localhost") { + throw new Error( + `Invalid hostname: ${url.hostname}. Hostname must contain at least one dot or be "localhost"`, + ); + } + + // Hostname must not have empty labels (no leading, trailing, or consecutive dots) + if (url.hostname.startsWith(".") || url.hostname.includes("..") || url.hostname.endsWith(".")) { + throw new Error(`Invalid hostname: ${url.hostname}. Hostname must not have empty labels`); + } + } + return url; } From 050c8a6b519cee46934edadf7f591249cc97c5a5 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 31 Oct 2025 11:49:23 +0000 Subject: [PATCH 69/73] merge conflicts --- .../ensnode-react/src/hooks/useAvatarUrl.ts | 20 +++++++++++-------- pnpm-lock.yaml | 10 +++++++++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index de7bb0f2f..532a204a5 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -6,12 +6,14 @@ import { type BrowserSupportedAssetUrl, type BrowserSupportedAssetUrlProxy, buildBrowserSupportedAvatarUrl, + type ENSNamespaceId, + ENSNamespaceIds, type Name, } from "@ensnode/ensnode-sdk"; -import type { ConfigParameter, QueryParameter } from "../types"; -import { useENSIndexerConfig } from "./useENSIndexerConfig"; +import type { QueryParameter, WithSDKConfigParameter } from "../types"; import { useENSNodeConfig } from "./useENSNodeConfig"; +import { useENSNodeSDKConfig } from "./useENSNodeSDKConfig"; import { useRecords } from "./useRecords"; /** @@ -22,7 +24,9 @@ const AVATAR_TEXT_RECORD_KEY = "avatar" as const; /** * Parameters for the useAvatarUrl hook. */ -export interface UseAvatarUrlParameters extends QueryParameter, ConfigParameter { +export interface UseAvatarUrlParameters + extends QueryParameter, + WithSDKConfigParameter { /** * If null, the query will not be executed. */ @@ -161,7 +165,7 @@ export function useAvatarUrl( parameters: UseAvatarUrlParameters, ): UseQueryResult { const { name, config, query: queryOptions, browserSupportedAvatarUrlProxy } = parameters; - const _config = useENSNodeConfig(config); + const _config = useENSNodeSDKConfig(config); const canEnable = name !== null; @@ -172,7 +176,7 @@ export function useAvatarUrl( query: { enabled: canEnable }, }); - const configQuery = useENSIndexerConfig({ config: _config }); + const configQuery = useENSNodeConfig({ config: _config }); // Construct query options object const baseQueryOptions: { @@ -185,7 +189,7 @@ export function useAvatarUrl( "avatarUrl", name, _config.client.url.href, - configQuery.data?.namespace, + configQuery.data?.ensIndexerPublicConfig?.namespace, !!browserSupportedAvatarUrlProxy, recordsQuery.data?.records?.texts?.avatar ?? null, ] as const, @@ -198,8 +202,8 @@ export function useAvatarUrl( }; } - // Invariant: configQuery.data.namespace is guaranteed to be defined when configQuery.data exists - const namespaceId = configQuery.data.namespace; + const namespaceId: ENSNamespaceId = + configQuery.data.ensIndexerPublicConfig?.namespace ?? ENSNamespaceIds.Mainnet; const rawAvatarTextRecord = recordsQuery.data.records?.texts?.avatar ?? null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8693f946..980d5823e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10735,6 +10735,14 @@ snapshots: chai: 6.2.0 tinyrainbow: 3.0.3 + '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.5 @@ -15387,7 +15395,7 @@ snapshots: vitest@4.0.5(@types/debug@4.1.12)(@types/node@20.19.24)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.5 - '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.5 '@vitest/runner': 4.0.5 '@vitest/snapshot': 4.0.5 From 7ab5cb8b92a61ae94ffb77c5d8b52ce4ceebf646 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 31 Oct 2025 11:52:51 +0000 Subject: [PATCH 70/73] revert changes to buildEnsMetadataServiceAvatarUrl --- .../async/use-ens-metadata-service-avatar-url.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts b/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts index ec23bc950..48af552dc 100644 --- a/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts +++ b/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts @@ -2,9 +2,10 @@ import { useQuery } from "@tanstack/react-query"; -import type { Name } from "@ensnode/ensnode-sdk"; - -import { buildEnsMetadataServiceAvatarUrl } from "@/lib/namespace-utils"; +import { + buildEnsMetadataServiceAvatarUrl, + type Name, +} from "@ensnode/ensnode-sdk"; import { useNamespace } from "./use-namespace"; @@ -39,13 +40,16 @@ export interface UseEnsMetadataServiceAvatarUrlParameters { * } * ``` */ -export function useEnsMetadataServiceAvatarUrl({ name }: UseEnsMetadataServiceAvatarUrlParameters) { +export function useEnsMetadataServiceAvatarUrl({ + name, +}: UseEnsMetadataServiceAvatarUrlParameters) { const { data: namespaceId } = useNamespace(); return useQuery({ queryKey: ["avatarUrl", name, namespaceId], queryFn: () => { - if (namespaceId === null) throw new Error("namespaceId required to execute this query"); + if (namespaceId === null) + throw new Error("namespaceId required to execute this query"); return buildEnsMetadataServiceAvatarUrl(name, namespaceId); }, enabled: namespaceId !== null, From 112c20ebd1a3d42384f019c8bf8858c670f038e0 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Fri, 31 Oct 2025 11:54:16 +0000 Subject: [PATCH 71/73] lint --- .../async/use-ens-metadata-service-avatar-url.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts b/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts index 48af552dc..368cd9fdf 100644 --- a/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts +++ b/apps/ensadmin/src/hooks/async/use-ens-metadata-service-avatar-url.ts @@ -2,10 +2,7 @@ import { useQuery } from "@tanstack/react-query"; -import { - buildEnsMetadataServiceAvatarUrl, - type Name, -} from "@ensnode/ensnode-sdk"; +import { buildEnsMetadataServiceAvatarUrl, type Name } from "@ensnode/ensnode-sdk"; import { useNamespace } from "./use-namespace"; @@ -40,16 +37,13 @@ export interface UseEnsMetadataServiceAvatarUrlParameters { * } * ``` */ -export function useEnsMetadataServiceAvatarUrl({ - name, -}: UseEnsMetadataServiceAvatarUrlParameters) { +export function useEnsMetadataServiceAvatarUrl({ name }: UseEnsMetadataServiceAvatarUrlParameters) { const { data: namespaceId } = useNamespace(); return useQuery({ queryKey: ["avatarUrl", name, namespaceId], queryFn: () => { - if (namespaceId === null) - throw new Error("namespaceId required to execute this query"); + if (namespaceId === null) throw new Error("namespaceId required to execute this query"); return buildEnsMetadataServiceAvatarUrl(name, namespaceId); }, enabled: namespaceId !== null, From a0c58de49fd8b3e915322e6d9a068493cf7663b4 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 4 Nov 2025 20:33:30 +0000 Subject: [PATCH 72/73] simplify mock loading state --- .../ensadmin/src/app/mock/ens-avatar/page.tsx | 109 ++++++++---------- 1 file changed, 48 insertions(+), 61 deletions(-) diff --git a/apps/ensadmin/src/app/mock/ens-avatar/page.tsx b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx index 169c47ab7..f44cfc6dd 100644 --- a/apps/ensadmin/src/app/mock/ens-avatar/page.tsx +++ b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx @@ -36,50 +36,19 @@ interface AvatarTestCardProps { function AvatarTestCard({ name }: AvatarTestCardProps) { const { data, isLoading, error } = useAvatarUrl({ name }); - if (error) { - return ( - - - - - {name} - - Error loading avatar - - -

{error.message}

-
-
- ); - } - - if (isLoading || !data) { - return ( - - - {name} - Loading avatar information... - - - -
- - - -
-
-
- ); - } - - const hasAvatar = data.browserSupportedAvatarUrl !== null; - const hasRawUrl = data.rawAvatarTextRecord !== null; + const hasAvatar = data?.browserSupportedAvatarUrl !== null; + const hasRawUrl = data?.rawAvatarTextRecord !== null; + const hasError = !!error; return ( - + - {hasAvatar ? ( + {hasError ? ( + + ) : hasAvatar ? ( ) : ( @@ -92,50 +61,68 @@ function AvatarTestCard({ name }: AvatarTestCardProps) {
+ {error && ( +
+

{error.message}

+
+ )} +
Raw Avatar URL: - {hasRawUrl ? ( + {isLoading ? null : hasRawUrl ? ( ) : ( )}
-
- {data.rawAvatarTextRecord || "Not set"} -
+ {isLoading || !data ? ( + + ) : ( +
+ {data.rawAvatarTextRecord || "Not set"} +
+ )}
Browser-Supported URL: - {hasAvatar ? ( + {isLoading ? null : hasAvatar ? ( ) : ( )}
-
- {data.browserSupportedAvatarUrl?.toString() || "Not available"} -
+ {isLoading || !data ? ( + + ) : ( +
+ {data.browserSupportedAvatarUrl?.toString() || "Not available"} +
+ )}
Uses Proxy: -
- {data.usesProxy ? ( - <> - - Yes - - ) : ( - <> - - No - - )} -
+ {isLoading || !data ? ( + + ) : ( +
+ {data.usesProxy ? ( + <> + + Yes + + ) : ( + <> + + No + + )} +
+ )}
From b7e971e6ffe027859f3a8dc60150d3dd1253eaf6 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Tue, 4 Nov 2025 20:35:37 +0000 Subject: [PATCH 73/73] fix loading overloads --- apps/ensadmin/src/app/name/_components/ProfileHeader.tsx | 2 +- packages/ensnode-react/src/hooks/useAvatarUrl.ts | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx b/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx index 4f0575eb7..078a1015f 100644 --- a/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx +++ b/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx @@ -67,7 +67,7 @@ export function ProfileHeader({ name, namespaceId, headerImage, websiteUrl }: Pr
- +

diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts index 532a204a5..a9405f300 100644 --- a/packages/ensnode-react/src/hooks/useAvatarUrl.ts +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -183,7 +183,6 @@ export function useAvatarUrl( queryKey: readonly unknown[]; queryFn: () => Promise; retry: boolean; - placeholderData: UseAvatarUrlResult; } = { queryKey: [ "avatarUrl", @@ -221,11 +220,6 @@ export function useAvatarUrl( }; }, retry: false, - placeholderData: { - rawAvatarTextRecord: null, - browserSupportedAvatarUrl: null, - usesProxy: false, - } as const, }; const options = {