diff --git a/.changeset/cold-donuts-look.md b/.changeset/cold-donuts-look.md new file mode 100644 index 000000000..1af66b0f2 --- /dev/null +++ b/.changeset/cold-donuts-look.md @@ -0,0 +1,7 @@ +--- +"@ensnode/ensnode-sdk": minor +--- + +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 new file mode 100644 index 000000000..96472532a --- /dev/null +++ b/.changeset/evil-numbers-shine.md @@ -0,0 +1,7 @@ +--- +"ensadmin": minor +--- + +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 new file mode 100644 index 000000000..7c42f44fe --- /dev/null +++ b/.changeset/stale-dots-reply.md @@ -0,0 +1,10 @@ +--- +"@ensnode/ensnode-react": minor +--- + +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 diff --git a/apps/ensadmin/biome.jsonc b/apps/ensadmin/biome.jsonc index 9a2ce4db6..96424b64b 100644 --- a/apps/ensadmin/biome.jsonc +++ b/apps/ensadmin/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.1/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.2/schema.json", "extends": "//", "css": { "parser": { 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/inspect/_components/render-requests-output.tsx b/apps/ensadmin/src/app/inspect/_components/render-requests-output.tsx index 2e3257124..da70529f5 100644 --- a/apps/ensadmin/src/app/inspect/_components/render-requests-output.tsx +++ b/apps/ensadmin/src/app/inspect/_components/render-requests-output.tsx @@ -102,7 +102,9 @@ export function RenderRequestsOutput({ { message: focused.error.message, ...(focused.error instanceof ClientError && - !!focused.error.details && { details: focused.error.details }), + !!focused.error.details && { + details: focused.error.details, + }), }, null, 2, 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..f44cfc6dd --- /dev/null +++ b/apps/ensadmin/src/app/mock/ens-avatar/page.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { AlertCircle, Check, X } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { ENSNamespaceIds } from "@ensnode/datasources"; +import { useAvatarUrl } from "@ensnode/ensnode-react"; +import { buildBrowserSupportedAvatarUrl, ENSNamespaceId, Name } from "@ensnode/ensnode-sdk"; + +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"; + +const TEST_NAMES: Name[] = [ + "lightwalker.eth", + "brantly.eth", + "ada.eth", + "jesse.base.eth", + "skeleton.mfpurrs.eth", + "vitalik.eth", +]; + +interface AvatarTestCardProps { + name: Name; +} + +function AvatarTestCard({ name }: AvatarTestCardProps) { + const { data, isLoading, error } = useAvatarUrl({ name }); + + const hasAvatar = data?.browserSupportedAvatarUrl !== null; + const hasRawUrl = data?.rawAvatarTextRecord !== null; + const hasError = !!error; + + return ( + + + + {hasError ? ( + + ) : hasAvatar ? ( + + ) : ( + + )} + {name} + + + +
+ +
+ + {error && ( +
+

{error.message}

+
+ )} + +
+
+
+ Raw Avatar URL: + {isLoading ? null : hasRawUrl ? ( + + ) : ( + + )} +
+ {isLoading || !data ? ( + + ) : ( +
+ {data.rawAvatarTextRecord || "Not set"} +
+ )} +
+ +
+
+ Browser-Supported URL: + {isLoading ? null : hasAvatar ? ( + + ) : ( + + )} +
+ {isLoading || !data ? ( + + ) : ( +
+ {data.browserSupportedAvatarUrl?.toString() || "Not available"} +
+ )} +
+ +
+ Uses Proxy: + {isLoading || !data ? ( + + ) : ( +
+ {data.usesProxy ? ( + <> + + Yes + + ) : ( + <> + + No + + )} +
+ )} +
+
+
+
+ ); +} + +/** + * 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({ + rawAssetTextRecord, + name, + namespaceId, +}: { + rawAssetTextRecord: string; + name: Name; + namespaceId: ENSNamespaceId; +}) { + // Resolve the avatar URL using the same logic as useAvatarUrl + // This does NOT make any network requests - it only processes the provided raw text record + const resolvedData = useMemo(() => { + return buildBrowserSupportedAvatarUrl(rawAssetTextRecord, name, namespaceId); + }, [rawAssetTextRecord, name, namespaceId]); + + const hasAvatar = resolvedData.browserSupportedAssetUrl !== null; + + return ( +
+
+ +
+ + {/* Display the resolution information */} +
+
+
+ Raw Avatar Text Record: + {resolvedData.rawAssetTextRecord ? ( + + ) : ( + + )} +
+
+ {resolvedData.rawAssetTextRecord || "Not set"} +
+
+ +
+
+ Browser-Supported URL: + {hasAvatar ? ( + + ) : ( + + )} +
+
+ {resolvedData.browserSupportedAssetUrl?.toString() || "Not available"} +
+
+
+
+ ); +} + +function CustomAvatarUrlTestCard() { + const [namespaceId, setNamespaceId] = useState(ENSNamespaceIds.Mainnet); + const [name, setName] = useState("" as Name); + const [rawAssetTextRecord, setRawAssetTextRecord] = useState(""); + + return ( + + + Custom Avatar Text Record + + Enter an ENS namespace, name, and raw avatar text record to test resolution and display. + + + + {/* Namespace Selection */} +
+ + +
+ + {/* Name Input */} +
+ + setName(e.target.value as Name)} + /> +
+ + {/* Avatar URL Input */} +
+ + setRawAssetTextRecord(e.target.value)} + /> +
+ + {/* Avatar Display */} + {rawAssetTextRecord && name && ( + + )} +
+
+ ); +} + +export default function MockAvatarUrlPage() { + return ( +
+ + + Mock: ENS Avatar + + Displays avatar images, raw URLs, browser-supported URLs, and proxy usage for each ENS + name. + + + +
+ {/* Custom URL Test Section */} + + + {/* Existing Test Names Grid */} +
+ {TEST_NAMES.map((name) => ( + + ))} +
+
+
+
+
+ ); +} diff --git a/apps/ensadmin/src/app/mock/page.tsx b/apps/ensadmin/src/app/mock/page.tsx index eb337a856..35f17ea90 100644 --- a/apps/ensadmin/src/app/mock/page.tsx +++ b/apps/ensadmin/src/app/mock/page.tsx @@ -43,6 +43,9 @@ export default function MockList() { DisplayIdentity + diff --git a/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx b/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx index dbe613799..078a1015f 100644 --- a/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx +++ b/apps/ensadmin/src/app/name/_components/ProfileHeader.tsx @@ -67,11 +67,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 9404f146a..184d9a47a 100644 --- a/apps/ensadmin/src/components/ens-avatar.tsx +++ b/apps/ensadmin/src/components/ens-avatar.tsx @@ -4,14 +4,19 @@ import BoringAvatar from "boring-avatars"; import * as React from "react"; import type { ENSNamespaceId } from "@ensnode/datasources"; +import { useAvatarUrl } from "@ensnode/ensnode-react"; import type { Name } from "@ensnode/ensnode-sdk"; import { Avatar, AvatarImage } from "@/components/ui/avatar"; -import { buildEnsMetadataServiceAvatarUrl } from "@/lib/namespace-utils"; interface EnsAvatarProps { name: Name; - namespaceId: ENSNamespaceId; + className?: string; +} + +interface EnsAvatarDisplayProps { + name: Name; + avatarUrl: URL | null; className?: string; } @@ -19,10 +24,37 @@ type ImageLoadingStatus = Parameters< NonNullable["onLoadingStatusChange"]> >[0]; -export const EnsAvatar = ({ name, namespaceId, className }: EnsAvatarProps) => { - const [loadingStatus, setLoadingStatus] = React.useState("idle"); - const avatarUrl = buildEnsMetadataServiceAvatarUrl(name, namespaceId); +/** + * 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 ( @@ -34,18 +66,65 @@ export const EnsAvatar = ({ name, namespaceId, className }: EnsAvatarProps) => { return ( { - setLoadingStatus(status); + setImageLoadingStatus(status); }} /> - {loadingStatus === "error" && } - {(loadingStatus === "idle" || loadingStatus === "loading") && } + {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 + * 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. + * + * @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 { data: avatarUrlData, isLoading: isAvatarUrlLoading } = useAvatarUrl({ + name, + }); + + // Show loading state while fetching avatar URL or if data is not yet available + if (isAvatarUrlLoading || !avatarUrlData) { + return ( + + + + ); + } + + const avatarUrl = avatarUrlData.browserSupportedAvatarUrl; + + return ; +}; + interface EnsAvatarFallbackProps { name: Name; } diff --git a/apps/ensadmin/src/components/identity/index.tsx b/apps/ensadmin/src/components/identity/index.tsx index e4107f496..6345fee22 100644 --- a/apps/ensadmin/src/components/identity/index.tsx +++ b/apps/ensadmin/src/components/identity/index.tsx @@ -49,7 +49,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 = ; } diff --git a/apps/ensadmin/src/components/providers/selected-ensnode-provider.tsx b/apps/ensadmin/src/components/providers/selected-ensnode-provider.tsx index dff46ac85..fe7bc6815 100644 --- a/apps/ensadmin/src/components/providers/selected-ensnode-provider.tsx +++ b/apps/ensadmin/src/components/providers/selected-ensnode-provider.tsx @@ -23,7 +23,9 @@ export function SelectedENSNodeProvider({ children }: PropsWithChildren) { if (selectedConnection.validatedSelectedConnection.isValid) { return ( {children} diff --git a/apps/ensadmin/src/components/visualizer/custom-components/LabeledGroupNode.tsx b/apps/ensadmin/src/components/visualizer/custom-components/LabeledGroupNode.tsx index fa5367e39..190dc2793 100644 --- a/apps/ensadmin/src/components/visualizer/custom-components/LabeledGroupNode.tsx +++ b/apps/ensadmin/src/components/visualizer/custom-components/LabeledGroupNode.tsx @@ -62,7 +62,11 @@ export const GroupNode = forwardRef( > {label && {label}} diff --git a/apps/ensadmin/src/components/visualizer/custom-components/ParallelogramNode.tsx b/apps/ensadmin/src/components/visualizer/custom-components/ParallelogramNode.tsx index 85811d7d4..cc62cd626 100644 --- a/apps/ensadmin/src/components/visualizer/custom-components/ParallelogramNode.tsx +++ b/apps/ensadmin/src/components/visualizer/custom-components/ParallelogramNode.tsx @@ -17,7 +17,11 @@ export default function ParallelogramNode({ data }: NodeProps) { {nodeHandles.map((handle, idx) => ( { - 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/apps/ensapi/src/handlers/subgraph-api.ts b/apps/ensapi/src/handlers/subgraph-api.ts index 491d4e6fd..5c2c85acf 100644 --- a/apps/ensapi/src/handlers/subgraph-api.ts +++ b/apps/ensapi/src/handlers/subgraph-api.ts @@ -43,7 +43,11 @@ app.use(thegraphFallbackMiddleware); app.use(fixContentLengthMiddleware); // inject api documentation into graphql introspection requests -app.use(createDocumentationMiddleware(makeSubgraphApiDocumentation(), { path: "/subgraph" })); +app.use( + createDocumentationMiddleware(makeSubgraphApiDocumentation(), { + path: "/subgraph", + }), +); // inject _meta into the hono (and yoga) context for the subgraph middleware app.use(subgraphMetaMiddleware); diff --git a/apps/ensapi/src/lib/handlers/error-response.ts b/apps/ensapi/src/lib/handlers/error-response.ts index 3eebee1ee..719a3c332 100644 --- a/apps/ensapi/src/lib/handlers/error-response.ts +++ b/apps/ensapi/src/lib/handlers/error-response.ts @@ -17,7 +17,10 @@ import type { ErrorResponse } from "@ensnode/ensnode-sdk"; export const errorResponse = (c: Context, input: ZodError | Error | string | unknown) => { if (input instanceof ZodError) { return c.json( - { message: "Invalid Input", details: treeifyError(input) } satisfies ErrorResponse, + { + message: "Invalid Input", + details: treeifyError(input), + } satisfies ErrorResponse, 400, ); } diff --git a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts index 16d85fa55..956dd3f77 100644 --- a/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts +++ b/apps/ensapi/src/lib/protocol-acceleration/find-resolver.ts @@ -31,7 +31,11 @@ type FindResolverResult = activeResolver: null; requiresWildcardSupport: undefined; } - | { activeName: Name; requiresWildcardSupport: boolean; activeResolver: Address }; + | { + activeName: Name; + requiresWildcardSupport: boolean; + activeResolver: Address; + }; const NULL_RESULT: FindResolverResult = { activeName: null, @@ -119,7 +123,10 @@ async function findResolverWithUniversalResolver( // 3. Interpret results if (isAddressEqual(activeResolver, zeroAddress)) { - span.setStatus({ code: SpanStatusCode.ERROR, message: "activeResolver is zeroAddress" }); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: "activeResolver is zeroAddress", + }); return NULL_RESULT; } diff --git a/apps/ensapi/src/lib/resolution/forward-resolution.ts b/apps/ensapi/src/lib/resolution/forward-resolution.ts index 251d5c1e8..e9291c837 100644 --- a/apps/ensapi/src/lib/resolution/forward-resolution.ts +++ b/apps/ensapi/src/lib/resolution/forward-resolution.ts @@ -87,7 +87,10 @@ export async function resolveForward ): Promise> { // NOTE: `resolveForward` is just `_resolveForward` with the enforcement that `registry` must // initially be ENS Root Chain's Registry: see `_resolveForward` for additional context. - return _resolveForward(name, selection, { ...options, registry: ENS_ROOT_REGISTRY }); + return _resolveForward(name, selection, { + ...options, + registry: ENS_ROOT_REGISTRY, + }); } /** @@ -253,7 +256,9 @@ async function _resolveForward( ); // NOTE: typecast is ok because of sanity checks above - return { name: nameRecordValue } as ResolverRecordsResponse; + return { + name: nameRecordValue, + } as ResolverRecordsResponse; }, ); } @@ -279,7 +284,11 @@ async function _resolveForward( TraceableENSProtocol.ForwardResolution, ForwardResolutionProtocolStep.AccelerateKnownOffchainLookupResolver, {}, - () => _resolveForward(name, selection, { ...options, registry: defersToRegistry }), + () => + _resolveForward(name, selection, { + ...options, + registry: defersToRegistry, + }), ); } @@ -365,7 +374,11 @@ async function _resolveForward( tracer, "supportsENSIP10Interface", { chainId, address: activeResolver }, - () => supportsENSIP10Interface({ address: activeResolver, publicClient }), + () => + supportsENSIP10Interface({ + address: activeResolver, + publicClient, + }), ); span.setAttribute("isExtendedResolver", isExtendedResolver); diff --git a/apps/ensapi/src/lib/resolution/make-records-response.test.ts b/apps/ensapi/src/lib/resolution/make-records-response.test.ts index ebe2e064b..ab99c95d4 100644 --- a/apps/ensapi/src/lib/resolution/make-records-response.test.ts +++ b/apps/ensapi/src/lib/resolution/make-records-response.test.ts @@ -39,7 +39,9 @@ describe("lib-resolution", () => { }); it("should return text records when requested", () => { - const selection: ResolverRecordsSelection = { texts: ["com.twitter", "avatar"] }; + const selection: ResolverRecordsSelection = { + texts: ["com.twitter", "avatar"], + }; const result = makeRecordsResponseFromIndexedRecords(selection, mockRecords); expect(result).toEqual({ texts: { diff --git a/apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts b/apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts index 629f59146..db9153a5e 100644 --- a/apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts +++ b/apps/ensapi/src/lib/resolution/resolve-calls-and-results.ts @@ -115,7 +115,10 @@ export async function executeResolveCalls> { return withActiveSpanAsync(tracer, "executeResolveCalls", { name }, async (span) => { - const ResolverContract = { abi: ResolverABI, address: resolverAddress } as const; + const ResolverContract = { + abi: ResolverABI, + address: resolverAddress, + } as const; return await Promise.all( calls.map(async (call) => { @@ -128,7 +131,10 @@ export async function executeResolveCalls { const encodedName = toHex(packetToBytes(name)); // DNS-encode `name` for resolve() - const encodedMethod = encodeFunctionData({ abi: ResolverABI, ...call }); + const encodedMethod = encodeFunctionData({ + abi: ResolverABI, + ...call, + }); span.setAttribute("encodedName", encodedName); span.setAttribute("encodedMethod", encodedMethod); @@ -142,13 +148,20 @@ export async function executeResolveCalls { expect(filtered.config).toEqual({ setting: "value" }); expect(filtered.data).toEqual([1, 2, 3]); - expectTypeOf(filtered.config).toEqualTypeOf<{ readonly setting: "value" }>(); + expectTypeOf(filtered.config).toEqualTypeOf<{ + readonly setting: "value"; + }>(); expectTypeOf(filtered.data).toEqualTypeOf(); }); }); diff --git a/apps/ensindexer/src/config/config.test.ts b/apps/ensindexer/src/config/config.test.ts index e89c8c7b2..a4e1a8625 100644 --- a/apps/ensindexer/src/config/config.test.ts +++ b/apps/ensindexer/src/config/config.test.ts @@ -46,7 +46,10 @@ describe("config (with base env)", () => { it("returns a valid config object using environment variables", async () => { const config = await getConfig(); expect(config.namespace).toBe("mainnet"); - expect(config.globalBlockrange).toEqual({ startBlock: undefined, endBlock: undefined }); + expect(config.globalBlockrange).toEqual({ + startBlock: undefined, + endBlock: undefined, + }); expect(config.databaseSchemaName).toBe("ensnode"); expect(config.plugins).toEqual(["subgraph"]); expect(config.ensRainbowUrl).toStrictEqual(new URL("http://localhost:3223")); @@ -432,7 +435,9 @@ describe("config (with base env)", () => { }); it("is true when compatible", async () => { - await expect(getConfig()).resolves.toMatchObject({ isSubgraphCompatible: true }); + await expect(getConfig()).resolves.toMatchObject({ + isSubgraphCompatible: true, + }); }); it("throws when PLUGINS does not include subgraph", async () => { @@ -615,7 +620,9 @@ describe("config (minimal base env)", () => { RPC_URL_10: VALID_RPC_URL, }); - await expect(getConfig()).resolves.toMatchObject({ plugins: [PluginName.TokenScope] }); + await expect(getConfig()).resolves.toMatchObject({ + plugins: [PluginName.TokenScope], + }); }); describe("with ALCHEMY_API_KEY", async () => { diff --git a/apps/ensindexer/src/config/environment-defaults.test.ts b/apps/ensindexer/src/config/environment-defaults.test.ts index 47a02de67..10aca386f 100644 --- a/apps/ensindexer/src/config/environment-defaults.test.ts +++ b/apps/ensindexer/src/config/environment-defaults.test.ts @@ -58,7 +58,9 @@ describe("environment-defaults", () => { const PROVIDED: any = { labelSet: { labelSetVersion: "1" } }; // full default set - const DEFAULTS: any = { labelSet: { labelSetId: "subgraph", labelSetVersion: "0" } }; + const DEFAULTS: any = { + labelSet: { labelSetId: "subgraph", labelSetVersion: "0" }, + }; // applyDefaults correctly provides the nested value without clobbering user-provided nested value expect(applyDefaults(PROVIDED, DEFAULTS)).toStrictEqual({ diff --git a/apps/ensindexer/src/lib/dns-helpers.test.ts b/apps/ensindexer/src/lib/dns-helpers.test.ts index 6ac10f464..ba3236958 100644 --- a/apps/ensindexer/src/lib/dns-helpers.test.ts +++ b/apps/ensindexer/src/lib/dns-helpers.test.ts @@ -136,7 +136,10 @@ describe("dns-helpers", () => { }); it("should parse args correctly", () => { - expect(parseDnsTxtRecordArgs(args)).toEqual({ key: PARSED_KEY, value: PARSED_VALUE }); + expect(parseDnsTxtRecordArgs(args)).toEqual({ + key: PARSED_KEY, + value: PARSED_VALUE, + }); }); }); }); diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/chains.ts b/apps/ensindexer/src/lib/indexing-status/ponder-metadata/chains.ts index 76887f495..c212d9968 100644 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/chains.ts +++ b/apps/ensindexer/src/lib/indexing-status/ponder-metadata/chains.ts @@ -266,11 +266,17 @@ export function createSerializedChainSnapshots( historicalTotalBlocks: metrics.getValue("ponder_historical_total_blocks", { chain: chainName, }), - isSyncComplete: metrics.getValue("ponder_sync_is_complete", { chain: chainName }), - isSyncRealtime: metrics.getValue("ponder_sync_is_realtime", { chain: chainName }), + isSyncComplete: metrics.getValue("ponder_sync_is_complete", { + chain: chainName, + }), + isSyncRealtime: metrics.getValue("ponder_sync_is_realtime", { + chain: chainName, + }), syncBlock: { number: metrics.getValue("ponder_sync_block", { chain: chainName }), - timestamp: metrics.getValue("ponder_sync_block_timestamp", { chain: chainName }), + timestamp: metrics.getValue("ponder_sync_block_timestamp", { + chain: chainName, + }), }, statusBlock: { number: status[chainName]?.block.number, diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/ponder-metadata.test.ts b/apps/ensindexer/src/lib/indexing-status/ponder-metadata/ponder-metadata.test.ts index 21e1de4ca..7c2467aec 100644 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/ponder-metadata.test.ts +++ b/apps/ensindexer/src/lib/indexing-status/ponder-metadata/ponder-metadata.test.ts @@ -14,7 +14,10 @@ import { type ChainMetadata, createChainIndexingSnapshot } from "./chains"; import { getChainsBlockrange, type PonderConfigType } from "./config"; // Minimal helpers to simulate BlockRef -const blockRef = (number: number, timestamp: number = 0): BlockRef => ({ number, timestamp }); +const blockRef = (number: number, timestamp: number = 0): BlockRef => ({ + number, + timestamp, +}); describe("getChainsBlockrange", () => { it("allows endBlock if all datasources for a chain define their respective endBlock", () => { @@ -30,17 +33,29 @@ describe("getChainsBlockrange", () => { contracts: { "subgraph/Registrar": { chain: { - mainnet: { address: "0x1", startBlock: 444_444_444, endBlock: 999_999_990 }, + mainnet: { + address: "0x1", + startBlock: 444_444_444, + endBlock: 999_999_990, + }, }, }, "subgraph/Registry": { chain: { - mainnet: { address: "0x2", startBlock: 444_444_333, endBlock: 999_999_991 }, + mainnet: { + address: "0x2", + startBlock: 444_444_333, + endBlock: 999_999_991, + }, }, }, "subgraph/UpgradableRegistry": { chain: { - mainnet: { address: "0x2", startBlock: 444_555_333, endBlock: 999_999_999 }, + mainnet: { + address: "0x2", + startBlock: 444_555_333, + endBlock: 999_999_999, + }, }, }, }, @@ -69,7 +84,11 @@ describe("getChainsBlockrange", () => { contracts: { "subgraph/Registrar": { chain: { - mainnet: { address: "0x1", startBlock: 444_444_444, endBlock: 999_999_990 }, + mainnet: { + address: "0x1", + startBlock: 444_444_444, + endBlock: 999_999_990, + }, }, }, "subgraph/Registry": { @@ -79,7 +98,11 @@ describe("getChainsBlockrange", () => { }, "subgraph/UpgradableRegistry": { chain: { - mainnet: { address: "0x2", startBlock: 444_555_333, endBlock: 999_999_999 }, + mainnet: { + address: "0x2", + startBlock: 444_555_333, + endBlock: 999_999_999, + }, }, }, }, @@ -105,8 +128,18 @@ describe("getChainsBlockrange", () => { }, }, accounts: { - "vitalik.eth": { chain: "mainnet", address: "0x1", startBlock: 100, endBlock: 200 }, - "nick.eth": { chain: "mainnet", address: "0x2", startBlock: 50, endBlock: 300 }, + "vitalik.eth": { + chain: "mainnet", + address: "0x1", + startBlock: 100, + endBlock: 200, + }, + "nick.eth": { + chain: "mainnet", + address: "0x2", + startBlock: 50, + endBlock: 300, + }, }, contracts: { "subgraph/Registrar": { diff --git a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/rpc.ts b/apps/ensindexer/src/lib/indexing-status/ponder-metadata/rpc.ts index 69b88c9f9..b7ed3fac4 100644 --- a/apps/ensindexer/src/lib/indexing-status/ponder-metadata/rpc.ts +++ b/apps/ensindexer/src/lib/indexing-status/ponder-metadata/rpc.ts @@ -24,7 +24,9 @@ export async function fetchBlockRef( publicClient: PublicClient, blockNumber: BlockNumber, ): Promise { - const block = await publicClient.getBlock({ blockNumber: BigInt(blockNumber) }); + const block = await publicClient.getBlock({ + blockNumber: BigInt(blockNumber), + }); if (!block) { throw new Error(`Could not fetch block ${blockNumber}`); } diff --git a/apps/ensindexer/src/lib/merge-ponder-configs.test.ts b/apps/ensindexer/src/lib/merge-ponder-configs.test.ts index a223e958b..496e6ec10 100644 --- a/apps/ensindexer/src/lib/merge-ponder-configs.test.ts +++ b/apps/ensindexer/src/lib/merge-ponder-configs.test.ts @@ -6,7 +6,11 @@ describe("mergePonderConfigs", () => { it("should deeply merge two objects", () => { const target = { a: 1, b: { c: 2 } }; const source = { b: { d: 3 }, e: 4 }; - expect(mergePonderConfigs(target, source)).toEqual({ a: 1, b: { c: 2, d: 3 }, e: 4 }); + expect(mergePonderConfigs(target, source)).toEqual({ + a: 1, + b: { c: 2, d: 3 }, + e: 4, + }); }); it("should de-duplicate abis instead of concatenating them", () => { @@ -30,7 +34,10 @@ describe("mergePonderConfigs", () => { type: "event", }; const target = { abi: [EXAMPLE_ABI_ITEM], array: [{ key: "a" }] }; - const source = { abi: [EXAMPLE_ABI_ITEM], array: [{ key: "a" }, { key: "b" }] }; + const source = { + abi: [EXAMPLE_ABI_ITEM], + array: [{ key: "a" }, { key: "b" }], + }; expect(mergePonderConfigs(target, source)).toEqual({ abi: [EXAMPLE_ABI_ITEM], // de-duped array: [{ key: "a" }, { key: "a" }, { key: "b" }], // concatenated diff --git a/apps/ensindexer/src/lib/ponder-helpers.test.ts b/apps/ensindexer/src/lib/ponder-helpers.test.ts index e2db67e64..886efbedc 100644 --- a/apps/ensindexer/src/lib/ponder-helpers.test.ts +++ b/apps/ensindexer/src/lib/ponder-helpers.test.ts @@ -4,8 +4,14 @@ import type { Blockrange } from "@ensnode/ensnode-sdk"; import { constrainBlockrange, createStartBlockByChainIdMap } from "./ponder-helpers"; -const UNDEFINED_BLOCKRANGE = { startBlock: undefined, endBlock: undefined } satisfies Blockrange; -const BLOCKRANGE_WITH_END = { startBlock: undefined, endBlock: 1234 } satisfies Blockrange; +const UNDEFINED_BLOCKRANGE = { + startBlock: undefined, + endBlock: undefined, +} satisfies Blockrange; +const BLOCKRANGE_WITH_END = { + startBlock: undefined, + endBlock: 1234, +} satisfies Blockrange; describe("ponder helpers", () => { describe("constrainBlockrange", () => { diff --git a/apps/ensindexer/src/lib/protocol-acceleration/node-resolver-relationship-db-helpers.ts b/apps/ensindexer/src/lib/protocol-acceleration/node-resolver-relationship-db-helpers.ts index b36f8f155..234af7930 100644 --- a/apps/ensindexer/src/lib/protocol-acceleration/node-resolver-relationship-db-helpers.ts +++ b/apps/ensindexer/src/lib/protocol-acceleration/node-resolver-relationship-db-helpers.ts @@ -7,7 +7,11 @@ import type { Node } from "@ensnode/ensnode-sdk"; export async function removeNodeResolverRelation(context: Context, registry: Address, node: Node) { const chainId = context.chain.id; - await context.db.delete(schema.nodeResolverRelation, { chainId, registry, node }); + await context.db.delete(schema.nodeResolverRelation, { + chainId, + registry, + node, + }); } export async function upsertNodeResolverRelation( diff --git a/apps/ensindexer/src/plugins/subgraph/plugins/basenames/handlers/Registrar.ts b/apps/ensindexer/src/plugins/subgraph/plugins/basenames/handlers/Registrar.ts index 933e6f917..12b151d2a 100644 --- a/apps/ensindexer/src/plugins/subgraph/plugins/basenames/handlers/Registrar.ts +++ b/apps/ensindexer/src/plugins/subgraph/plugins/basenames/handlers/Registrar.ts @@ -43,7 +43,10 @@ export default function () { async ({ context, event }) => { await handleNameRegistered({ context, - event: { ...event, args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.id) } }, + event: { + ...event, + args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.id) }, + }, }); }, ); @@ -53,7 +56,10 @@ export default function () { async ({ context, event }) => { await handleNameRegistered({ context, - event: { ...event, args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.id) } }, + event: { + ...event, + args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.id) }, + }, }); }, ); @@ -63,7 +69,10 @@ export default function () { async ({ context, event }) => { await handleNameRenewed({ context, - event: { ...event, args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.id) } }, + event: { + ...event, + args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.id) }, + }, }); }, ); @@ -71,7 +80,10 @@ export default function () { ponder.on(namespaceContract(pluginName, "BaseRegistrar:Transfer"), async ({ context, event }) => { await handleNameTransferred({ context, - event: { ...event, args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.id) } }, + event: { + ...event, + args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.id) }, + }, }); }); diff --git a/apps/ensindexer/src/plugins/subgraph/plugins/lineanames/handlers/Registrar.ts b/apps/ensindexer/src/plugins/subgraph/plugins/lineanames/handlers/Registrar.ts index a80682d22..85bfce77a 100644 --- a/apps/ensindexer/src/plugins/subgraph/plugins/lineanames/handlers/Registrar.ts +++ b/apps/ensindexer/src/plugins/subgraph/plugins/lineanames/handlers/Registrar.ts @@ -42,7 +42,10 @@ export default function () { async ({ context, event }) => { await handleNameRegistered({ context, - event: { ...event, args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.id) } }, + event: { + ...event, + args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.id) }, + }, }); }, ); @@ -52,7 +55,10 @@ export default function () { async ({ context, event }) => { await handleNameRenewed({ context, - event: { ...event, args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.id) } }, + event: { + ...event, + args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.id) }, + }, }); }, ); @@ -62,7 +68,10 @@ export default function () { context, event: { ...event, - args: { ...event.args, labelHash: tokenIdToLabelHash(event.args.tokenId) }, + args: { + ...event.args, + labelHash: tokenIdToLabelHash(event.args.tokenId), + }, }, }); }); diff --git a/apps/ensindexer/src/plugins/subgraph/shared-handlers/NameWrapper.ts b/apps/ensindexer/src/plugins/subgraph/shared-handlers/NameWrapper.ts index 76120936c..a3b4aa571 100644 --- a/apps/ensindexer/src/plugins/subgraph/shared-handlers/NameWrapper.ts +++ b/apps/ensindexer/src/plugins/subgraph/shared-handlers/NameWrapper.ts @@ -84,7 +84,9 @@ function decodeSubgraphInterpretedNameWrapperName( // if the WrappedDomain entity has PCC set in fuses, set Domain entity's expiryDate to the greater of the two async function materializeDomainExpiryDate(context: Context, node: Node) { - const wrappedDomain = await context.db.find(schema.subgraph_wrappedDomain, { id: node }); + const wrappedDomain = await context.db.find(schema.subgraph_wrappedDomain, { + id: node, + }); if (!wrappedDomain) throw new Error(`Expected WrappedDomain(${node})`); // NOTE: the subgraph has a helper function called [checkPccBurned](https://github.com/ensdomains/ens-subgraph/blob/c844791/src/nameWrapper.ts#L63) @@ -183,7 +185,9 @@ export const makeNameWrapperHandlers = ({ ? decodeSubgraphInterpretedNameWrapperName(event.args.name as DNSEncodedLiteralName) : decodeInterpretedNameWrapperName(event.args.name as DNSEncodedLiteralName); - const domain = await context.db.find(schema.subgraph_domain, { id: node }); + const domain = await context.db.find(schema.subgraph_domain, { + id: node, + }); if (!domain) throw new Error("domain is guaranteed to already exist"); // upsert the healed name iff !domain.labelName && label diff --git a/apps/ensindexer/src/plugins/subgraph/shared-handlers/Registrar.ts b/apps/ensindexer/src/plugins/subgraph/shared-handlers/Registrar.ts index be51fd305..82439fd36 100644 --- a/apps/ensindexer/src/plugins/subgraph/shared-handlers/Registrar.ts +++ b/apps/ensindexer/src/plugins/subgraph/shared-handlers/Registrar.ts @@ -81,7 +81,9 @@ export const makeRegistrarHandlers = ({ // update the registration's labelName await context.db - .update(schema.subgraph_registration, { id: makeRegistrationId(labelHash, node) }) + .update(schema.subgraph_registration, { + id: makeRegistrationId(labelHash, node), + }) .set({ labelName: interpretedLabel, cost }); } @@ -127,7 +129,9 @@ export const makeRegistrarHandlers = ({ // Therefore, if a Domain does not exist in Registrar#NameRegistered, it _must_ be a 'preminted' // name, tracked only in the Registrar. If/when these 'preminted' names are _actually_ registered // in the future, they will emit NewOwner as expected. - const domain = await context.db.find(schema.subgraph_domain, { id: node }); + const domain = await context.db.find(schema.subgraph_domain, { + id: node, + }); if (!domain) { // invariant: if the domain does not exist and the plugin does not support preminted names, panic if (!pluginSupportsPremintedNames(pluginName)) { @@ -288,7 +292,11 @@ export const makeRegistrarHandlers = ({ event, }: { context: Context; - event: EventWithArgs<{ labelHash: LabelHash; from: Address; to: Address }>; + event: EventWithArgs<{ + labelHash: LabelHash; + from: Address; + to: Address; + }>; }) { const { labelHash, to } = event.args; @@ -300,7 +308,9 @@ export const makeRegistrarHandlers = ({ // if the Transfer event occurs before the Registration entity exists (i.e. the initial // registration, which is Transfer -> NewOwner -> NameRegistered), no-op - const registration = await context.db.find(schema.subgraph_registration, { id }); + const registration = await context.db.find(schema.subgraph_registration, { + id, + }); if (!registration) return; // update registration registrant diff --git a/apps/ensindexer/src/plugins/subgraph/shared-handlers/Registry.ts b/apps/ensindexer/src/plugins/subgraph/shared-handlers/Registry.ts index bd9ffec99..0eb4018d0 100644 --- a/apps/ensindexer/src/plugins/subgraph/shared-handlers/Registry.ts +++ b/apps/ensindexer/src/plugins/subgraph/shared-handlers/Registry.ts @@ -81,7 +81,9 @@ export const handleNewOwner = // if the domain doesn't yet have a name, attempt to construct it here if (domain.name === null) { - const parent = await context.db.find(schema.subgraph_domain, { id: parentNode }); + const parent = await context.db.find(schema.subgraph_domain, { + id: parentNode, + }); let healedLabel: LiteralLabel | null = null; diff --git a/apps/ensindexer/src/plugins/subgraph/shared-handlers/ThreeDNSToken.ts b/apps/ensindexer/src/plugins/subgraph/shared-handlers/ThreeDNSToken.ts index 8221a9101..4e59379a6 100644 --- a/apps/ensindexer/src/plugins/subgraph/shared-handlers/ThreeDNSToken.ts +++ b/apps/ensindexer/src/plugins/subgraph/shared-handlers/ThreeDNSToken.ts @@ -157,7 +157,9 @@ export async function handleNewOwner({ // always emit `RegistrationCreated`, including Domain's `name`, before this `NewOwner` event // is indexed. if (domain.name === null) { - const parent = await context.db.find(schema.subgraph_domain, { id: parentNode }); + const parent = await context.db.find(schema.subgraph_domain, { + id: parentNode, + }); // 1. attempt metadata retrieval const tokenId = getThreeDNSTokenId(node); diff --git a/biome.jsonc b/biome.jsonc index 68e2cf067..32b9d5e8d 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.1/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.2/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/docs/ensnode.io/biome.jsonc b/docs/ensnode.io/biome.jsonc index 30c5f014c..c964cdef8 100644 --- a/docs/ensnode.io/biome.jsonc +++ b/docs/ensnode.io/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.1/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.2/schema.json", "extends": "//", "assist": { "actions": { diff --git a/docs/ensrainbow.io/biome.jsonc b/docs/ensrainbow.io/biome.jsonc index 30c5f014c..c964cdef8 100644 --- a/docs/ensrainbow.io/biome.jsonc +++ b/docs/ensrainbow.io/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.1/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.2/schema.json", "extends": "//", "assist": { "actions": { diff --git a/packages/datasources/src/abis/basenames/UpgradeableRegistrarController.ts b/packages/datasources/src/abis/basenames/UpgradeableRegistrarController.ts index a0af90336..b276a2659 100644 --- a/packages/datasources/src/abis/basenames/UpgradeableRegistrarController.ts +++ b/packages/datasources/src/abis/basenames/UpgradeableRegistrarController.ts @@ -61,8 +61,18 @@ export const UpgradeableRegistrarController = [ { anonymous: false, inputs: [ - { indexed: true, internalType: "address", name: "registrant", type: "address" }, - { indexed: true, internalType: "bytes32", name: "discountKey", type: "bytes32" }, + { + indexed: true, + internalType: "address", + name: "registrant", + type: "address", + }, + { + indexed: true, + internalType: "bytes32", + name: "discountKey", + type: "bytes32", + }, ], name: "DiscountApplied", type: "event", @@ -70,11 +80,20 @@ export const UpgradeableRegistrarController = [ { anonymous: false, inputs: [ - { indexed: true, internalType: "bytes32", name: "discountKey", type: "bytes32" }, + { + indexed: true, + internalType: "bytes32", + name: "discountKey", + type: "bytes32", + }, { components: [ { internalType: "bool", name: "active", type: "bool" }, - { internalType: "address", name: "discountValidator", type: "address" }, + { + internalType: "address", + name: "discountValidator", + type: "address", + }, { internalType: "bytes32", name: "key", type: "bytes32" }, { internalType: "uint256", name: "discount", type: "uint256" }, ], @@ -90,22 +109,44 @@ export const UpgradeableRegistrarController = [ { anonymous: false, inputs: [ - { indexed: true, internalType: "address", name: "payee", type: "address" }, - { indexed: false, internalType: "uint256", name: "price", type: "uint256" }, + { + indexed: true, + internalType: "address", + name: "payee", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "price", + type: "uint256", + }, ], name: "ETHPaymentProcessed", type: "event", }, { anonymous: false, - inputs: [{ indexed: false, internalType: "uint64", name: "version", type: "uint64" }], + inputs: [ + { + indexed: false, + internalType: "uint64", + name: "version", + type: "uint64", + }, + ], name: "Initialized", type: "event", }, { anonymous: false, inputs: [ - { indexed: false, internalType: "address", name: "newL2ReverseRegistrar", type: "address" }, + { + indexed: false, + internalType: "address", + name: "newL2ReverseRegistrar", + type: "address", + }, ], name: "L2ReverseRegistrarUpdated", type: "event", @@ -114,9 +155,24 @@ export const UpgradeableRegistrarController = [ anonymous: false, inputs: [ { indexed: false, internalType: "string", name: "name", type: "string" }, - { indexed: true, internalType: "bytes32", name: "label", type: "bytes32" }, - { indexed: true, internalType: "address", name: "owner", type: "address" }, - { indexed: false, internalType: "uint256", name: "expires", type: "uint256" }, + { + indexed: true, + internalType: "bytes32", + name: "label", + type: "bytes32", + }, + { + indexed: true, + internalType: "address", + name: "owner", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "expires", + type: "uint256", + }, ], name: "NameRegistered", type: "event", @@ -125,8 +181,18 @@ export const UpgradeableRegistrarController = [ anonymous: false, inputs: [ { indexed: false, internalType: "string", name: "name", type: "string" }, - { indexed: true, internalType: "bytes32", name: "label", type: "bytes32" }, - { indexed: false, internalType: "uint256", name: "expires", type: "uint256" }, + { + indexed: true, + internalType: "bytes32", + name: "label", + type: "bytes32", + }, + { + indexed: false, + internalType: "uint256", + name: "expires", + type: "uint256", + }, ], name: "NameRenewed", type: "event", @@ -134,8 +200,18 @@ export const UpgradeableRegistrarController = [ { anonymous: false, inputs: [ - { indexed: true, internalType: "address", name: "previousOwner", type: "address" }, - { indexed: true, internalType: "address", name: "newOwner", type: "address" }, + { + indexed: true, + internalType: "address", + name: "previousOwner", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "newOwner", + type: "address", + }, ], name: "OwnershipTransferStarted", type: "event", @@ -143,8 +219,18 @@ export const UpgradeableRegistrarController = [ { anonymous: false, inputs: [ - { indexed: true, internalType: "address", name: "previousOwner", type: "address" }, - { indexed: true, internalType: "address", name: "newOwner", type: "address" }, + { + indexed: true, + internalType: "address", + name: "previousOwner", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "newOwner", + type: "address", + }, ], name: "OwnershipTransferred", type: "event", @@ -152,21 +238,38 @@ export const UpgradeableRegistrarController = [ { anonymous: false, inputs: [ - { indexed: false, internalType: "address", name: "newPaymentReceiver", type: "address" }, + { + indexed: false, + internalType: "address", + name: "newPaymentReceiver", + type: "address", + }, ], name: "PaymentReceiverUpdated", type: "event", }, { anonymous: false, - inputs: [{ indexed: false, internalType: "address", name: "newPrices", type: "address" }], + inputs: [ + { + indexed: false, + internalType: "address", + name: "newPrices", + type: "address", + }, + ], name: "PriceOracleUpdated", type: "event", }, { anonymous: false, inputs: [ - { indexed: false, internalType: "address", name: "newReverseRegistrar", type: "address" }, + { + indexed: false, + internalType: "address", + name: "newReverseRegistrar", + type: "address", + }, ], name: "ReverseRegistrarUpdated", type: "event", @@ -243,7 +346,11 @@ export const UpgradeableRegistrarController = [ { components: [ { internalType: "bool", name: "active", type: "bool" }, - { internalType: "address", name: "discountValidator", type: "address" }, + { + internalType: "address", + name: "discountValidator", + type: "address", + }, { internalType: "bytes32", name: "key", type: "bytes32" }, { internalType: "uint256", name: "discount", type: "uint256" }, ], @@ -262,7 +369,11 @@ export const UpgradeableRegistrarController = [ { components: [ { internalType: "bool", name: "active", type: "bool" }, - { internalType: "address", name: "discountValidator", type: "address" }, + { + internalType: "address", + name: "discountValidator", + type: "address", + }, { internalType: "bytes32", name: "key", type: "bytes32" }, { internalType: "uint256", name: "discount", type: "uint256" }, ], @@ -283,14 +394,30 @@ export const UpgradeableRegistrarController = [ }, { inputs: [ - { internalType: "contract IBaseRegistrar", name: "base_", type: "address" }, - { internalType: "contract IPriceOracle", name: "prices_", type: "address" }, - { internalType: "contract IReverseRegistrar", name: "reverseRegistrar_", type: "address" }, + { + internalType: "contract IBaseRegistrar", + name: "base_", + type: "address", + }, + { + internalType: "contract IPriceOracle", + name: "prices_", + type: "address", + }, + { + internalType: "contract IReverseRegistrar", + name: "reverseRegistrar_", + type: "address", + }, { internalType: "address", name: "owner_", type: "address" }, { internalType: "bytes32", name: "rootNode_", type: "bytes32" }, { internalType: "string", name: "rootName_", type: "string" }, { internalType: "address", name: "paymentReceiver_", type: "address" }, - { internalType: "address", name: "legacyRegistrarController_", type: "address" }, + { + internalType: "address", + name: "legacyRegistrarController_", + type: "address", + }, { internalType: "address", name: "legacyL2Resolver_", type: "address" }, { internalType: "address", name: "l2ReverseRegistrar_", type: "address" }, ], @@ -410,7 +537,11 @@ export const UpgradeableRegistrarController = [ { components: [ { internalType: "bool", name: "active", type: "bool" }, - { internalType: "address", name: "discountValidator", type: "address" }, + { + internalType: "address", + name: "discountValidator", + type: "address", + }, { internalType: "bytes32", name: "key", type: "bytes32" }, { internalType: "uint256", name: "discount", type: "uint256" }, ], @@ -439,7 +570,13 @@ export const UpgradeableRegistrarController = [ type: "function", }, { - inputs: [{ internalType: "contract IPriceOracle", name: "prices_", type: "address" }], + inputs: [ + { + internalType: "contract IPriceOracle", + name: "prices_", + type: "address", + }, + ], name: "setPriceOracle", outputs: [], stateMutability: "nonpayable", @@ -458,7 +595,13 @@ export const UpgradeableRegistrarController = [ type: "function", }, { - inputs: [{ internalType: "contract IReverseRegistrar", name: "reverse_", type: "address" }], + inputs: [ + { + internalType: "contract IReverseRegistrar", + name: "reverse_", + type: "address", + }, + ], name: "setReverseRegistrar", outputs: [], stateMutability: "nonpayable", @@ -478,5 +621,11 @@ export const UpgradeableRegistrarController = [ stateMutability: "pure", type: "function", }, - { inputs: [], name: "withdrawETH", outputs: [], stateMutability: "nonpayable", type: "function" }, + { + inputs: [], + name: "withdrawETH", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, ] as const; diff --git a/packages/datasources/src/abis/seaport/Seaport1.5.ts b/packages/datasources/src/abis/seaport/Seaport1.5.ts index 6d4e14858..12b0a0128 100644 --- a/packages/datasources/src/abis/seaport/Seaport1.5.ts +++ b/packages/datasources/src/abis/seaport/Seaport1.5.ts @@ -200,7 +200,11 @@ export const Seaport = [ name: "NoSpecifiedOrdersAvailable", type: "error", }, - { inputs: [], name: "OfferAndConsiderationRequiredOnFulfillment", type: "error" }, + { + inputs: [], + name: "OfferAndConsiderationRequiredOnFulfillment", + type: "error", + }, { inputs: [], name: "OfferCriteriaResolverOutOfRange", @@ -278,7 +282,12 @@ export const Seaport = [ { anonymous: false, inputs: [ - { indexed: false, internalType: "uint256", name: "newCounter", type: "uint256" }, + { + indexed: false, + internalType: "uint256", + name: "newCounter", + type: "uint256", + }, { indexed: true, internalType: "address", @@ -292,7 +301,12 @@ export const Seaport = [ { anonymous: false, inputs: [ - { indexed: false, internalType: "bytes32", name: "orderHash", type: "bytes32" }, + { + indexed: false, + internalType: "bytes32", + name: "orderHash", + type: "bytes32", + }, { indexed: true, internalType: "address", @@ -307,7 +321,12 @@ export const Seaport = [ { anonymous: false, inputs: [ - { indexed: false, internalType: "bytes32", name: "orderHash", type: "bytes32" }, + { + indexed: false, + internalType: "bytes32", + name: "orderHash", + type: "bytes32", + }, { indexed: true, internalType: "address", @@ -373,7 +392,12 @@ export const Seaport = [ { anonymous: false, inputs: [ - { indexed: false, internalType: "bytes32", name: "orderHash", type: "bytes32" }, + { + indexed: false, + internalType: "bytes32", + name: "orderHash", + type: "bytes32", + }, { components: [ { internalType: "address", name: "offerer", type: "address" }, @@ -425,7 +449,11 @@ export const Seaport = [ name: "endAmount", type: "uint256", }, - { internalType: "address payable", name: "recipient", type: "address" }, + { + internalType: "address payable", + name: "recipient", + type: "address", + }, ], internalType: "struct ConsiderationItem[]", name: "consideration", @@ -449,7 +477,11 @@ export const Seaport = [ name: "conduitKey", type: "bytes32", }, - { internalType: "uint256", name: "totalOriginalConsiderationItems", type: "uint256" }, + { + internalType: "uint256", + name: "totalOriginalConsiderationItems", + type: "uint256", + }, ], indexed: false, internalType: "struct OrderParameters", @@ -462,7 +494,14 @@ export const Seaport = [ }, { anonymous: false, - inputs: [{ indexed: false, internalType: "bytes32[]", name: "orderHashes", type: "bytes32[]" }], + inputs: [ + { + indexed: false, + internalType: "bytes32[]", + name: "orderHashes", + type: "bytes32[]", + }, + ], name: "OrdersMatched", type: "event", }, @@ -519,7 +558,11 @@ export const Seaport = [ name: "endAmount", type: "uint256", }, - { internalType: "address payable", name: "recipient", type: "address" }, + { + internalType: "address payable", + name: "recipient", + type: "address", + }, ], internalType: "struct ConsiderationItem[]", name: "consideration", @@ -584,7 +627,11 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", @@ -608,19 +655,31 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", type: "uint256", }, - { internalType: "address payable", name: "recipient", type: "address" }, + { + internalType: "address payable", + name: "recipient", + type: "address", + }, ], internalType: "struct ConsiderationItem[]", name: "consideration", type: "tuple[]", }, - { internalType: "enum OrderType", name: "orderType", type: "uint8" }, + { + internalType: "enum OrderType", + name: "orderType", + type: "uint8", + }, { internalType: "uint256", name: "startTime", @@ -638,7 +697,11 @@ export const Seaport = [ name: "conduitKey", type: "bytes32", }, - { internalType: "uint256", name: "totalOriginalConsiderationItems", type: "uint256" }, + { + internalType: "uint256", + name: "totalOriginalConsiderationItems", + type: "uint256", + }, ], internalType: "struct OrderParameters", name: "parameters", @@ -726,7 +789,11 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", @@ -750,19 +817,31 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", type: "uint256", }, - { internalType: "address payable", name: "recipient", type: "address" }, + { + internalType: "address payable", + name: "recipient", + type: "address", + }, ], internalType: "struct ConsiderationItem[]", name: "consideration", type: "tuple[]", }, - { internalType: "enum OrderType", name: "orderType", type: "uint8" }, + { + internalType: "enum OrderType", + name: "orderType", + type: "uint8", + }, { internalType: "uint256", name: "startTime", @@ -780,7 +859,11 @@ export const Seaport = [ name: "conduitKey", type: "bytes32", }, - { internalType: "uint256", name: "totalOriginalConsiderationItems", type: "uint256" }, + { + internalType: "uint256", + name: "totalOriginalConsiderationItems", + type: "uint256", + }, ], internalType: "struct OrderParameters", name: "parameters", @@ -938,7 +1021,11 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", @@ -962,19 +1049,31 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", type: "uint256", }, - { internalType: "address payable", name: "recipient", type: "address" }, + { + internalType: "address payable", + name: "recipient", + type: "address", + }, ], internalType: "struct ConsiderationItem[]", name: "consideration", type: "tuple[]", }, - { internalType: "enum OrderType", name: "orderType", type: "uint8" }, + { + internalType: "enum OrderType", + name: "orderType", + type: "uint8", + }, { internalType: "uint256", name: "startTime", @@ -992,7 +1091,11 @@ export const Seaport = [ name: "conduitKey", type: "bytes32", }, - { internalType: "uint256", name: "totalOriginalConsiderationItems", type: "uint256" }, + { + internalType: "uint256", + name: "totalOriginalConsiderationItems", + type: "uint256", + }, ], internalType: "struct OrderParameters", name: "parameters", @@ -1138,7 +1241,11 @@ export const Seaport = [ name: "salt", type: "uint256", }, - { internalType: "bytes32", name: "offererConduitKey", type: "bytes32" }, + { + internalType: "bytes32", + name: "offererConduitKey", + type: "bytes32", + }, { internalType: "bytes32", name: "fulfillerConduitKey", @@ -1156,7 +1263,11 @@ export const Seaport = [ name: "amount", type: "uint256", }, - { internalType: "address payable", name: "recipient", type: "address" }, + { + internalType: "address payable", + name: "recipient", + type: "address", + }, ], internalType: "struct AdditionalRecipient[]", name: "additionalRecipients", @@ -1227,7 +1338,11 @@ export const Seaport = [ name: "salt", type: "uint256", }, - { internalType: "bytes32", name: "offererConduitKey", type: "bytes32" }, + { + internalType: "bytes32", + name: "offererConduitKey", + type: "bytes32", + }, { internalType: "bytes32", name: "fulfillerConduitKey", @@ -1245,7 +1360,11 @@ export const Seaport = [ name: "amount", type: "uint256", }, - { internalType: "address payable", name: "recipient", type: "address" }, + { + internalType: "address payable", + name: "recipient", + type: "address", + }, ], internalType: "struct AdditionalRecipient[]", name: "additionalRecipients", @@ -1292,7 +1411,11 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", @@ -1316,19 +1439,31 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", type: "uint256", }, - { internalType: "address payable", name: "recipient", type: "address" }, + { + internalType: "address payable", + name: "recipient", + type: "address", + }, ], internalType: "struct ConsiderationItem[]", name: "consideration", type: "tuple[]", }, - { internalType: "enum OrderType", name: "orderType", type: "uint8" }, + { + internalType: "enum OrderType", + name: "orderType", + type: "uint8", + }, { internalType: "uint256", name: "startTime", @@ -1346,7 +1481,11 @@ export const Seaport = [ name: "conduitKey", type: "bytes32", }, - { internalType: "uint256", name: "totalOriginalConsiderationItems", type: "uint256" }, + { + internalType: "uint256", + name: "totalOriginalConsiderationItems", + type: "uint256", + }, ], internalType: "struct OrderParameters", name: "parameters", @@ -1432,7 +1571,11 @@ export const Seaport = [ name: "endAmount", type: "uint256", }, - { internalType: "address payable", name: "recipient", type: "address" }, + { + internalType: "address payable", + name: "recipient", + type: "address", + }, ], internalType: "struct ConsiderationItem[]", name: "consideration", @@ -1539,7 +1682,11 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", @@ -1563,19 +1710,31 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", type: "uint256", }, - { internalType: "address payable", name: "recipient", type: "address" }, + { + internalType: "address payable", + name: "recipient", + type: "address", + }, ], internalType: "struct ConsiderationItem[]", name: "consideration", type: "tuple[]", }, - { internalType: "enum OrderType", name: "orderType", type: "uint8" }, + { + internalType: "enum OrderType", + name: "orderType", + type: "uint8", + }, { internalType: "uint256", name: "startTime", @@ -1593,7 +1752,11 @@ export const Seaport = [ name: "conduitKey", type: "bytes32", }, - { internalType: "uint256", name: "totalOriginalConsiderationItems", type: "uint256" }, + { + internalType: "uint256", + name: "totalOriginalConsiderationItems", + type: "uint256", + }, ], internalType: "struct OrderParameters", name: "parameters", @@ -1747,7 +1910,11 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", @@ -1771,19 +1938,31 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", type: "uint256", }, - { internalType: "address payable", name: "recipient", type: "address" }, + { + internalType: "address payable", + name: "recipient", + type: "address", + }, ], internalType: "struct ConsiderationItem[]", name: "consideration", type: "tuple[]", }, - { internalType: "enum OrderType", name: "orderType", type: "uint8" }, + { + internalType: "enum OrderType", + name: "orderType", + type: "uint8", + }, { internalType: "uint256", name: "startTime", @@ -1801,7 +1980,11 @@ export const Seaport = [ name: "conduitKey", type: "bytes32", }, - { internalType: "uint256", name: "totalOriginalConsiderationItems", type: "uint256" }, + { + internalType: "uint256", + name: "totalOriginalConsiderationItems", + type: "uint256", + }, ], internalType: "struct OrderParameters", name: "parameters", @@ -1926,7 +2109,11 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", @@ -1950,19 +2137,31 @@ export const Seaport = [ name: "identifierOrCriteria", type: "uint256", }, - { internalType: "uint256", name: "startAmount", type: "uint256" }, + { + internalType: "uint256", + name: "startAmount", + type: "uint256", + }, { internalType: "uint256", name: "endAmount", type: "uint256", }, - { internalType: "address payable", name: "recipient", type: "address" }, + { + internalType: "address payable", + name: "recipient", + type: "address", + }, ], internalType: "struct ConsiderationItem[]", name: "consideration", type: "tuple[]", }, - { internalType: "enum OrderType", name: "orderType", type: "uint8" }, + { + internalType: "enum OrderType", + name: "orderType", + type: "uint8", + }, { internalType: "uint256", name: "startTime", @@ -1980,7 +2179,11 @@ export const Seaport = [ name: "conduitKey", type: "bytes32", }, - { internalType: "uint256", name: "totalOriginalConsiderationItems", type: "uint256" }, + { + internalType: "uint256", + name: "totalOriginalConsiderationItems", + type: "uint256", + }, ], internalType: "struct OrderParameters", name: "parameters", diff --git a/packages/datasources/src/abis/shared/Resolver.ts b/packages/datasources/src/abis/shared/Resolver.ts index 0978aaf88..0b45c3452 100644 --- a/packages/datasources/src/abis/shared/Resolver.ts +++ b/packages/datasources/src/abis/shared/Resolver.ts @@ -991,7 +991,12 @@ export const Resolver = [ inputs: [ { name: "node", type: "bytes32", indexed: true, internalType: "bytes32" }, { name: "name", type: "bytes", indexed: false, internalType: "bytes" }, - { name: "resource", type: "uint16", indexed: false, internalType: "uint16" }, + { + name: "resource", + type: "uint16", + indexed: false, + internalType: "uint16", + }, { name: "ttl", type: "uint32", indexed: false, internalType: "uint32" }, { name: "record", type: "bytes", indexed: false, internalType: "bytes" }, ], @@ -1003,7 +1008,12 @@ export const Resolver = [ inputs: [ { name: "node", type: "bytes32", indexed: true, internalType: "bytes32" }, { name: "name", type: "bytes", indexed: false, internalType: "bytes" }, - { name: "resource", type: "uint16", indexed: false, internalType: "uint16" }, + { + name: "resource", + type: "uint16", + indexed: false, + internalType: "uint16", + }, ], anonymous: false, }, @@ -1012,7 +1022,12 @@ export const Resolver = [ name: "DNSZoneUpdated", inputs: [ { name: "node", type: "bytes32", indexed: true, internalType: "bytes32" }, - { name: "serial", type: "uint32", indexed: false, internalType: "uint32" }, + { + name: "serial", + type: "uint32", + indexed: false, + internalType: "uint32", + }, ], anonymous: false, }, @@ -1021,7 +1036,12 @@ export const Resolver = [ name: "ZoneCreated", inputs: [ { name: "node", type: "bytes32", indexed: true, internalType: "bytes32" }, - { name: "version", type: "uint64", indexed: true, internalType: "uint64" }, + { + name: "version", + type: "uint64", + indexed: true, + internalType: "uint64", + }, ], anonymous: false, }, diff --git a/packages/ensnode-react/README.md b/packages/ensnode-react/README.md index 21f996fda..f94ba17ce 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,34 @@ function DisplayPrimaryNames() { } ``` +#### Avatar URL Resolution — `useAvatarUrl` + +```tsx +import { useAvatarUrl } from "@ensnode/ensnode-react"; +import { Name } from "@ensnode/ensnode-sdk"; + +function ProfileAvatar({ name }: { name: Name }) { + const { data, isLoading } = useAvatarUrl({ name }); + + if (isLoading || !data) { + return
; + } + + return ( +
+ {!data.browserSupportedAvatarUrl ? ( +
+ ) : ( + {`${name} + )} +
+ ); +} +``` + ## API Reference ### ENSNodeProvider @@ -224,6 +252,203 @@ 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 (potentially using a proxy). + +#### 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 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 can be used as a proxy for loading avatar images when the related avatar text records use non-browser-supported protocols. + +#### Invariants + +- **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. + +#### Parameters + +- `name`: The ENS Name whose avatar URL to resolve (set to `null` to disable the query) +- `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 + +```tsx +interface UseAvatarUrlResult { + rawAvatarTextRecord: string | null; + browserSupportedAvatarUrl: BrowserSupportedAssetUrl | null; + usesProxy: boolean; +} +``` + +- `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. + +
+Basic Example + +```tsx +import { useAvatarUrl } from "@ensnode/ensnode-react"; +import { Name } from "@ensnode/ensnode-sdk"; + +function ProfileAvatar({ name }: { name: Name }) { + const { data, isLoading } = useAvatarUrl({ name }); + + if (isLoading || !data) { + return
; + } + + return ( +
+ {!data.browserSupportedAvatarUrl ? ( +
+ ) : ( + {`${name} + )} +
+ ); +} +``` + +
+ +
+Advanced Example with Loading States + +```tsx +import { useAvatarUrl } from "@ensnode/ensnode-react"; +import { Name } from "@ensnode/ensnode-sdk"; +import { useState } from "react"; + +function EnsAvatar({ name }: { name: Name }) { + 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 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"; +import { Name } from "@ensnode/ensnode-sdk"; + +function ProfileAvatar({ name }: { name: Name }) { + const { data, isLoading } = useAvatarUrl({ + name, + browserSupportedAvatarUrlProxy: (name, avatarUrl) => { + // Handle IPFS protocol URLs + if (avatarUrl.protocol === "ipfs:") { + // Extract the CID (Content Identifier) from the IPFS URL + // Format: ipfs://{CID} or ipfs://{CID}/{path} + 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 + 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}`); + } + + // Handle Arweave protocol URLs (ar://) + if (avatarUrl.protocol === "ar:") { + const arweaveId = avatarUrl.href.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; + }, + }); + + if (isLoading || !data) { + return
; + } + + return ( +
+ {!data.browserSupportedAvatarUrl ? ( +
+ ) : ( + <> + {`${name} + {data.usesProxy && Uses Proxy} + + )} +
+ ); +} +``` + +
+ ## Advanced Usage ### Custom Query Configuration @@ -284,7 +509,7 @@ const [address, setAddress] = useState(""); // only executes when address is not null const { data } = usePrimaryName({ address: address || null, - chainId: 1 + chainId: 1, }); ``` diff --git a/packages/ensnode-react/src/hooks/index.ts b/packages/ensnode-react/src/hooks/index.ts index 0d5997070..46583a1b2 100644 --- a/packages/ensnode-react/src/hooks/index.ts +++ b/packages/ensnode-react/src/hooks/index.ts @@ -1,6 +1,11 @@ +// Re-export BrowserSupportedAssetUrl for convenience +export type { BrowserSupportedAssetUrl } from "@ensnode/ensnode-sdk"; + +export * from "./useAvatarUrl"; export * from "./useENSNodeConfig"; export * from "./useENSNodeSDKConfig"; export * from "./useIndexingStatus"; +export * from "./useIndexingStatus"; export * from "./usePrimaryName"; export * from "./usePrimaryNames"; export * from "./useRecords"; diff --git a/packages/ensnode-react/src/hooks/useAvatarUrl.ts b/packages/ensnode-react/src/hooks/useAvatarUrl.ts new file mode 100644 index 000000000..a9405f300 --- /dev/null +++ b/packages/ensnode-react/src/hooks/useAvatarUrl.ts @@ -0,0 +1,236 @@ +"use client"; + +import { type UseQueryResult, useQuery } from "@tanstack/react-query"; + +import { + type BrowserSupportedAssetUrl, + type BrowserSupportedAssetUrlProxy, + buildBrowserSupportedAvatarUrl, + type ENSNamespaceId, + ENSNamespaceIds, + type Name, +} from "@ensnode/ensnode-sdk"; + +import type { QueryParameter, WithSDKConfigParameter } from "../types"; +import { useENSNodeConfig } from "./useENSNodeConfig"; +import { useENSNodeSDKConfig } from "./useENSNodeSDKConfig"; +import { useRecords } from "./useRecords"; + +/** + * The ENS avatar text record key. + */ +const AVATAR_TEXT_RECORD_KEY = "avatar" as const; + +/** + * Parameters for the useAvatarUrl hook. + */ +export interface UseAvatarUrlParameters + extends QueryParameter, + WithSDKConfigParameter { + /** + * If null, the query will not be executed. + */ + name: Name | null; + /** + * Optional function to build a BrowserSupportedAssetUrl for a name's avatar image + * 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 `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?: BrowserSupportedAssetUrlProxy; +} + +/** + * Result returned by the useAvatarUrl hook. + * + * 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. + */ + rawAvatarTextRecord: string | null; + /** + * 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-browser-supported protocol and no url is known for how to load the avatar using a proxy. + */ + browserSupportedAvatarUrl: BrowserSupportedAssetUrl | null; + /** + * 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; +} + +/** + * 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 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 + * + * @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({ name }: { name: string }) { + * const { data, isLoading } = useAvatarUrl({ name }); + * + * if (isLoading || !data) { + * return
; + * } + * + * return ( + *
+ * {!data.browserSupportedAvatarUrl ? ( + *
+ * ) : ( + * {`${name} + * )} + *
+ * ); + * } + * ``` + * + * @example + * ```typescript + * // With custom IPFS gateway proxy + * import { useAvatarUrl, toBrowserSupportedUrl, defaultBrowserSupportedAssetUrlProxy } from "@ensnode/ensnode-sdk"; + * + * function ProfileAvatar({ name }: { name: string }) { + * const { data, isLoading } = useAvatarUrl({ + * name, + * 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} + * const ipfsPath = avatarUrl.href.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 (avatarUrl.protocol === 'ar:') { + * const arweaveId = avatarUrl.href.replace('ar://', ''); + * return toBrowserSupportedUrl(`https://arweave.net/${arweaveId}`); + * } + * + * // For other protocols, fall back to the ENS Metadata Service + * return defaultBrowserSupportedAssetUrlProxy(name, avatarUrl, namespaceId); + * } + * }); + * + * if (isLoading || !data) { + * return
; + * } + * + * return ( + *
+ * {!data.browserSupportedAvatarUrl ? ( + *
+ * ) : ( + * {`${name} + * )} + *
+ * ); + * } + * ``` + */ +export function useAvatarUrl( + parameters: UseAvatarUrlParameters, +): UseQueryResult { + const { name, config, query: queryOptions, browserSupportedAvatarUrlProxy } = parameters; + const _config = useENSNodeSDKConfig(config); + + const canEnable = name !== null; + + const recordsQuery = useRecords({ + name, + selection: { texts: [AVATAR_TEXT_RECORD_KEY] }, + config: _config, + query: { enabled: canEnable }, + }); + + const configQuery = useENSNodeConfig({ config: _config }); + + // Construct query options object + const baseQueryOptions: { + queryKey: readonly unknown[]; + queryFn: () => Promise; + retry: boolean; + } = { + queryKey: [ + "avatarUrl", + name, + _config.client.url.href, + configQuery.data?.ensIndexerPublicConfig?.namespace, + !!browserSupportedAvatarUrlProxy, + recordsQuery.data?.records?.texts?.avatar ?? null, + ] as const, + queryFn: async (): Promise => { + if (!name || !recordsQuery.data || !configQuery.data) { + return { + rawAvatarTextRecord: null, + browserSupportedAvatarUrl: null, + usesProxy: false, + }; + } + + const namespaceId: ENSNamespaceId = + configQuery.data.ensIndexerPublicConfig?.namespace ?? ENSNamespaceIds.Mainnet; + + const rawAvatarTextRecord = recordsQuery.data.records?.texts?.avatar ?? null; + + const result = buildBrowserSupportedAvatarUrl( + rawAvatarTextRecord, + name, + namespaceId, + browserSupportedAvatarUrlProxy, + ); + + return { + rawAvatarTextRecord: result.rawAssetTextRecord, + browserSupportedAvatarUrl: result.browserSupportedAssetUrl, + usesProxy: result.usesProxy, + }; + }, + retry: false, + }; + + const options = { + ...baseQueryOptions, + ...queryOptions, + enabled: + canEnable && + recordsQuery.isSuccess && + configQuery.isSuccess && + (queryOptions?.enabled ?? true), + } as typeof baseQueryOptions; + + return useQuery(options); +} diff --git a/packages/ensnode-react/src/types.ts b/packages/ensnode-react/src/types.ts index 652b7a605..0c736ddf5 100644 --- a/packages/ensnode-react/src/types.ts +++ b/packages/ensnode-react/src/types.ts @@ -35,33 +35,36 @@ export interface WithSDKConfigParameter 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; } diff --git a/packages/ensnode-sdk/package.json b/packages/ensnode-sdk/package.json index ddb76c6c9..1c1abc134 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/client.test.ts b/packages/ensnode-sdk/src/client.test.ts index b6674be2f..670b6a144 100644 --- a/packages/ensnode-sdk/src/client.test.ts +++ b/packages/ensnode-sdk/src/client.test.ts @@ -233,7 +233,10 @@ describe("ENSNodeClient", () => { // TODO: integrate with default-case expectations from resolution api and test behavior it("should handle address and text selections", async () => { const mockResponse = { records: EXAMPLE_RECORDS_RESPONSE }; - mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockResponse }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); const client = new ENSNodeClient(); const response = await client.resolveRecords(EXAMPLE_NAME, EXAMPLE_SELECTION); @@ -248,7 +251,10 @@ describe("ENSNodeClient", () => { it("should include trace if specified", async () => { const mockResponse = { records: EXAMPLE_RECORDS_RESPONSE, trace: [] }; - mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockResponse }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); const client = new ENSNodeClient(); const response = await client.resolveRecords(EXAMPLE_NAME, EXAMPLE_SELECTION, { @@ -265,7 +271,10 @@ describe("ENSNodeClient", () => { }); it("should throw error when API returns error", async () => { - mockFetch.mockResolvedValueOnce({ ok: false, json: async () => EXAMPLE_ERROR_RESPONSE }); + mockFetch.mockResolvedValueOnce({ + ok: false, + json: async () => EXAMPLE_ERROR_RESPONSE, + }); const client = new ENSNodeClient(); await expect(client.resolveRecords(EXAMPLE_NAME, EXAMPLE_SELECTION)).rejects.toThrowError( @@ -295,10 +304,15 @@ describe("ENSNodeClient", () => { it("should include trace if specified", async () => { const mockResponse = { name: EXAMPLE_NAME, trace: [] }; - mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockResponse }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); const client = new ENSNodeClient(); - const response = await client.resolvePrimaryName(EXAMPLE_ADDRESS, 1, { trace: true }); + const response = await client.resolvePrimaryName(EXAMPLE_ADDRESS, 1, { + trace: true, + }); const expectedUrl = new URL( `/api/resolve/primary-name/${EXAMPLE_ADDRESS}/1`, @@ -329,7 +343,10 @@ describe("ENSNodeClient", () => { }); it("should throw error when API returns error", async () => { - mockFetch.mockResolvedValueOnce({ ok: false, json: async () => EXAMPLE_ERROR_RESPONSE }); + mockFetch.mockResolvedValueOnce({ + ok: false, + json: async () => EXAMPLE_ERROR_RESPONSE, + }); const client = new ENSNodeClient(); await expect(client.resolvePrimaryName(EXAMPLE_ADDRESS, 1)).rejects.toThrowError(ClientError); @@ -375,10 +392,15 @@ describe("ENSNodeClient", () => { it("should include trace if specified", async () => { const mockResponse = { ...EXAMPLE_PRIMARY_NAMES_RESPONSE, trace: [] }; - mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockResponse }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }); const client = new ENSNodeClient(); - const response = await client.resolvePrimaryNames(EXAMPLE_ADDRESS, { trace: true }); + const response = await client.resolvePrimaryNames(EXAMPLE_ADDRESS, { + trace: true, + }); const expectedUrl = new URL( `/api/resolve/primary-names/${EXAMPLE_ADDRESS}`, @@ -409,7 +431,10 @@ describe("ENSNodeClient", () => { }); it("should throw error when API returns error", async () => { - mockFetch.mockResolvedValueOnce({ ok: false, json: async () => EXAMPLE_ERROR_RESPONSE }); + mockFetch.mockResolvedValueOnce({ + ok: false, + json: async () => EXAMPLE_ERROR_RESPONSE, + }); const client = new ENSNodeClient(); await expect(client.resolvePrimaryNames(EXAMPLE_ADDRESS)).rejects.toThrowError(ClientError); @@ -435,7 +460,10 @@ describe("ENSNodeClient", () => { }); it("should throw error when API returns error", async () => { - mockFetch.mockResolvedValueOnce({ ok: false, json: async () => EXAMPLE_ERROR_RESPONSE }); + mockFetch.mockResolvedValueOnce({ + ok: false, + json: async () => EXAMPLE_ERROR_RESPONSE, + }); const client = new ENSNodeClient(); @@ -465,7 +493,10 @@ describe("ENSNodeClient", () => { // arrange const client = new ENSNodeClient(); - mockFetch.mockResolvedValueOnce({ ok: false, json: async () => EXAMPLE_ERROR_RESPONSE }); + mockFetch.mockResolvedValueOnce({ + ok: false, + json: async () => EXAMPLE_ERROR_RESPONSE, + }); // act & assert await expect(client.indexingStatus()).rejects.toThrow( diff --git a/packages/ensnode-sdk/src/ens/index.ts b/packages/ensnode-sdk/src/ens/index.ts index 502250384..d3ee6523a 100644 --- a/packages/ensnode-sdk/src/ens/index.ts +++ b/packages/ensnode-sdk/src/ens/index.ts @@ -5,6 +5,7 @@ export * from "./constants"; export * from "./dns-encoded-name"; export * from "./encode-labelhash"; export * from "./is-normalized"; +export * from "./metadata-service"; export * from "./names"; export * from "./parse-reverse-name"; export * from "./reverse-name"; 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..dea310ca3 --- /dev/null +++ b/packages/ensnode-sdk/src/ens/metadata-service.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; + +import { ENSNamespaceIds } from "@ensnode/datasources"; + +import type { BrowserSupportedAssetUrlProxy } from "./metadata-service"; +import { buildBrowserSupportedAvatarUrl } from "./metadata-service"; + +describe("buildBrowserSupportedAvatarUrl", () => { + it("returns null 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("uses https URLs directly without proxy", () => { + const httpsUrl = "https://example.com/avatar.png"; + const result = buildBrowserSupportedAvatarUrl( + httpsUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); + expect(result.usesProxy).toBe(false); + }); + + it("uses http URLs directly without proxy", () => { + const httpUrl = "http://example.com/avatar.png"; + const result = buildBrowserSupportedAvatarUrl( + httpUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.browserSupportedAssetUrl?.protocol).toBe("http:"); + expect(result.usesProxy).toBe(false); + }); + + it("uses data URLs directly without proxy", () => { + const dataUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCI+PC9zdmc+"; + const result = buildBrowserSupportedAvatarUrl( + dataUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.browserSupportedAssetUrl?.protocol).toBe("data:"); + expect(result.usesProxy).toBe(false); + }); + + it("uses proxy for IPFS URLs", () => { + const ipfsUrl = "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; + const result = buildBrowserSupportedAvatarUrl( + ipfsUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); + expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); + expect(result.usesProxy).toBe(true); + }); + + it("uses proxy for CAIP-22 ERC-721 NFT URIs", () => { + const nftUri = "eip155:1/erc721:0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d/0"; + const result = buildBrowserSupportedAvatarUrl( + nftUri, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); + expect(result.browserSupportedAssetUrl?.hostname).toBe("metadata.ens.domains"); + expect(result.usesProxy).toBe(true); + }); + + it("uses proxy for CAIP-29 ERC-1155 NFT URIs", () => { + const nftUri = "eip155:1/erc1155:0xfaafdc07907ff5120a76b34b731b278c38d6043c/1"; + const result = buildBrowserSupportedAvatarUrl( + nftUri, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.browserSupportedAssetUrl?.protocol).toBe("https:"); + expect(result.usesProxy).toBe(true); + }); + + it("returns null for invaldi CAIP namespace (not eip155)", () => { + const invalidCaip = "cosmos:cosmoshub-4/nft:0x123/1"; + const result = buildBrowserSupportedAvatarUrl( + invalidCaip, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.browserSupportedAssetUrl).toBe(null); + expect(result.usesProxy).toBe(false); + }); + + it("returns null for invalid asset type (not erc721 or erc1155)", () => { + const invalidAssetType = "eip155:1/erc20:0x123/1"; + const result = buildBrowserSupportedAvatarUrl( + invalidAssetType, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.browserSupportedAssetUrl).toBe(null); + expect(result.usesProxy).toBe(false); + }); + + it("returns null for malformed URLs", () => { + const malformedUrl = "not-a-valid-url"; + const result = buildBrowserSupportedAvatarUrl( + malformedUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + ); + + expect(result.browserSupportedAssetUrl).toBe(null); + expect(result.usesProxy).toBe(false); + }); + + it("uses custom proxy when provided", () => { + const ipfsUrl = "ipfs://QmCustomHash123"; + const customProxy: BrowserSupportedAssetUrlProxy = (name) => { + return new URL(`https://my-custom-proxy.com/${name}/avatar`); + }; + + const result = buildBrowserSupportedAvatarUrl( + ipfsUrl, + "lightwalker.eth", + ENSNamespaceIds.Mainnet, + customProxy, + ); + + expect(result.browserSupportedAssetUrl?.hostname).toBe("my-custom-proxy.com"); + expect(result.usesProxy).toBe(true); + }); + + 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.browserSupportedAssetUrl).toBe(null); + expect(result.usesProxy).toBe(false); + }); +}); 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..0838075fa --- /dev/null +++ b/packages/ensnode-sdk/src/ens/metadata-service.ts @@ -0,0 +1,274 @@ +import { AssetId } from "caip"; + +import type { ENSNamespaceId } from "@ensnode/datasources"; +import { ENSNamespaceIds } from "@ensnode/datasources"; + +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 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 an NFT URI (eip155:/ protocol) + * @returns True if the value is a valid NFT URI using the eip155 protocol, false otherwise + * + * @example + * // Valid NFT URI - CAIP-22 (ERC-721) + * isValidNftUri("eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/771769") + * // => true + * + * @example + * // Valid NFT URI - CAIP-29 (ERC-1155) + * isValidNftUri("eip155:1/erc1155:0xfaafdc07907ff5120a76b34b731b278c38d6043c/1") + * // => true + */ +function isValidNftUri(value: string): boolean { + try { + // Use caip package to parse the NFT URI identifier + const parsed = AssetId.parse(value); + + // Verify it uses the eip155 chain namespace + if ( + typeof parsed.chainId === "object" && + "namespace" in parsed.chainId && + parsed.chainId.namespace !== "eip155" + ) { + return false; + } + + // Verify it's an ERC-721 or ERC-1155 token + 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. + * + * @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, + _, + 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 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) { + // Try to convert to browser-supported URL first + try { + const browserSupportedAssetUrl = toBrowserSupportedUrl(rawAssetTextRecord); + + return { + rawAssetTextRecord, + browserSupportedAssetUrl, + usesProxy: false, + }; + } 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); + // 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, + }; + } + } + } + + // Invariant: At this point, the asset text record either: + // 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. + + // Use custom proxy if provided, otherwise use default + const activeProxy: BrowserSupportedAssetUrlProxy = + browserSupportedAssetUrlProxy ?? defaultBrowserSupportedAssetUrlProxy; + + // 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); + + // 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). + * + * 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 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, +): BrowserSupportedAssetUrl | null { + switch (namespaceId) { + case ENSNamespaceIds.Mainnet: + return toBrowserSupportedUrl( + `https://metadata.ens.domains/mainnet/avatar/${encodeURIComponent(name)}`, + ); + case ENSNamespaceIds.Sepolia: + return toBrowserSupportedUrl( + `https://metadata.ens.domains/sepolia/avatar/${encodeURIComponent(name)}`, + ); + 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/packages/ensnode-sdk/src/ens/types.ts b/packages/ensnode-sdk/src/ens/types.ts index 6321f2bc5..34043825a 100644 --- a/packages/ensnode-sdk/src/ens/types.ts +++ b/packages/ensnode-sdk/src/ens/types.ts @@ -154,7 +154,9 @@ export type InterpretedName = Name & { __brand: "InterpretedName" }; * @see https://ensnode.io/docs/reference/terminology#subgraph-interpreted-label * @dev nominally typed to enforce usage & enhance codebase clarity */ -export type SubgraphInterpretedLabel = Label & { __brand: "SubgraphInterpretedLabel" }; +export type SubgraphInterpretedLabel = Label & { + __brand: "SubgraphInterpretedLabel"; +}; /** * A Subgraph Interpreted Name is a name exclusively composed of 0 or more Subgraph Interpreted Labels. @@ -162,7 +164,9 @@ export type SubgraphInterpretedLabel = Label & { __brand: "SubgraphInterpretedLa * @see https://ensnode.io/docs/reference/terminology#subgraph-interpreted-name * @dev nominally typed to enforce usage & enhance codebase clarity */ -export type SubgraphInterpretedName = Name & { __brand: "SubgraphInterpretedName" }; +export type SubgraphInterpretedName = Name & { + __brand: "SubgraphInterpretedName"; +}; /** * A DNS-Encoded Name as a hex string, representing the binary DNS wire format encoding @@ -227,7 +231,9 @@ export type DNSEncodedName = Hex; * * @dev nominally typed to enforce usage & enhance codebase clarity */ -export type DNSEncodedLiteralName = DNSEncodedName & { __brand: "DNSEncodedLiteralName" }; +export type DNSEncodedLiteralName = DNSEncodedName & { + __brand: "DNSEncodedLiteralName"; +}; /** * A DNSEncodedName that encodes a name consisting of 0 or more labels that are either: diff --git a/packages/ensnode-sdk/src/ensindexer/config/zod-schemas.test.ts b/packages/ensnode-sdk/src/ensindexer/config/zod-schemas.test.ts index 50c813c02..ae088aa48 100644 --- a/packages/ensnode-sdk/src/ensindexer/config/zod-schemas.test.ts +++ b/packages/ensnode-sdk/src/ensindexer/config/zod-schemas.test.ts @@ -197,7 +197,10 @@ describe("ENSIndexer: Config", () => { formatParseError( makeENSIndexerPublicConfigSchema().safeParse({ ...validConfig, - labelSet: { ...validConfig.labelSet, labelSetVersion: "not-a-number" }, + labelSet: { + ...validConfig.labelSet, + labelSetVersion: "not-a-number", + }, }), ), ).toContain("labelSet.labelSetVersion must be an integer"); diff --git a/packages/ensnode-sdk/src/ensindexer/config/zod-schemas.ts b/packages/ensnode-sdk/src/ensindexer/config/zod-schemas.ts index 4383d8c24..d3ff3c929 100644 --- a/packages/ensnode-sdk/src/ensindexer/config/zod-schemas.ts +++ b/packages/ensnode-sdk/src/ensindexer/config/zod-schemas.ts @@ -165,7 +165,9 @@ export const makeENSIndexerPublicConfigSchema = (valueLabel: string = "ENSIndexe .object({ labelSet: makeFullyPinnedLabelSetSchema(`${valueLabel}.labelSet`), indexedChainIds: makeIndexedChainIdsSchema(`${valueLabel}.indexedChainIds`), - isSubgraphCompatible: z.boolean({ error: `${valueLabel}.isSubgraphCompatible` }), + isSubgraphCompatible: z.boolean({ + error: `${valueLabel}.isSubgraphCompatible`, + }), namespace: makeENSNamespaceIdSchema(`${valueLabel}.namespace`), plugins: makePluginsListSchema(`${valueLabel}.plugins`), databaseSchemaName: makeDatabaseSchemaNameSchema(`${valueLabel}.databaseSchemaName`), diff --git a/packages/ensnode-sdk/src/shared/datasources-with-resolvers.ts b/packages/ensnode-sdk/src/shared/datasources-with-resolvers.ts index d152964bb..26b0cea45 100644 --- a/packages/ensnode-sdk/src/shared/datasources-with-resolvers.ts +++ b/packages/ensnode-sdk/src/shared/datasources-with-resolvers.ts @@ -7,7 +7,9 @@ import { maybeGetDatasource, } from "@ensnode/datasources"; -type DatasourceWithResolverContract = Datasource & { contracts: { Resolver: ContractConfig } }; +type DatasourceWithResolverContract = Datasource & { + contracts: { Resolver: ContractConfig }; +}; export const DATASOURCE_NAMES_WITH_RESOLVERS = [ DatasourceNames.ENSRoot, 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..688e414d3 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/url.test.ts @@ -0,0 +1,269 @@ +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..5e2007e9a 100644 --- a/packages/ensnode-sdk/src/shared/url.ts +++ b/packages/ensnode-sdk/src/shared/url.ts @@ -1,3 +1,30 @@ +/** + * Type alias for URLs to assets that are supported by browsers for direct rendering. + * Assets must be accessible via the http, https, or data protocol. + * + * Invariant: value guaranteed to pass isBrowserSupportedProtocol check. + */ +export type BrowserSupportedAssetUrl = URL; + +/** + * 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); } @@ -5,3 +32,99 @@ export function isHttpProtocol(url: URL): boolean { 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/data) + * 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 and hostname is valid + * @throws if the URL string is invalid, uses a non-browser-supported protocol, or has an invalid hostname + * + * @example + * ```typescript + * // Explicit protocol - no transformation + * 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 + * + * // 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 { + // 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 (!isBrowserSupportedProtocol(url)) { + throw new Error( + `BrowserSupportedAssetUrl must use http, https, or data protocol, got: ${url.protocol}`, + ); + } + + // 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; +} diff --git a/packages/ensnode-sdk/src/shared/zod-schemas.ts b/packages/ensnode-sdk/src/shared/zod-schemas.ts index 3e7a4c154..6fbe661d3 100644 --- a/packages/ensnode-sdk/src/shared/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/zod-schemas.ts @@ -86,8 +86,14 @@ export const makeChainIdSchema = (valueLabel: string = "Chain ID") => */ export const makeChainIdStringSchema = (valueLabel: string = "Chain ID String") => z - .string({ error: `${valueLabel} must be a string representing a chain ID.` }) - .pipe(z.coerce.number({ error: `${valueLabel} must represent a positive integer (>0).` })) + .string({ + error: `${valueLabel} must be a string representing a chain ID.`, + }) + .pipe( + z.coerce.number({ + error: `${valueLabel} must represent a positive integer (>0).`, + }), + ) .pipe(makeChainIdSchema(`The numeric value represented by ${valueLabel}`)); /** @@ -105,8 +111,14 @@ export const makeDefaultableChainIdStringSchema = ( valueLabel: string = "Defaultable Chain ID String", ) => z - .string({ error: `${valueLabel} must be a string representing a chain ID.` }) - .pipe(z.coerce.number({ error: `${valueLabel} must represent a non-negative integer (>=0).` })) + .string({ + error: `${valueLabel} must be a string representing a chain ID.`, + }) + .pipe( + z.coerce.number({ + error: `${valueLabel} must represent a non-negative integer (>=0).`, + }), + ) .pipe(makeDefaultableChainIdSchema(`The numeric value represented by ${valueLabel}`)); /** @@ -116,7 +128,9 @@ export const makeCoinTypeSchema = (valueLabel: string = "Coin Type") => z .number({ error: `${valueLabel} must be a number.` }) .int({ error: `${valueLabel} must be an integer.` }) - .nonnegative({ error: `${valueLabel} must be a non-negative integer (>=0).` }) + .nonnegative({ + error: `${valueLabel} must be a non-negative integer (>=0).`, + }) .transform((val) => val as CoinType); /** @@ -124,8 +138,14 @@ export const makeCoinTypeSchema = (valueLabel: string = "Coin Type") => */ export const makeCoinTypeStringSchema = (valueLabel: string = "Coin Type String") => z - .string({ error: `${valueLabel} must be a string representing a coin type.` }) - .pipe(z.coerce.number({ error: `${valueLabel} must represent a non-negative integer (>=0).` })) + .string({ + error: `${valueLabel} must be a string representing a coin type.`, + }) + .pipe( + z.coerce.number({ + error: `${valueLabel} must represent a non-negative integer (>=0).`, + }), + ) .pipe(makeCoinTypeSchema(`The numeric value represented by ${valueLabel}`)); /** @@ -209,7 +229,9 @@ export const makeBlockrangeSchema = (valueLabel: string = "Value") => return true; }, - { error: `${valueLabel}: startBlock must be before or equal to endBlock` }, + { + error: `${valueLabel}: startBlock must be before or equal to endBlock`, + }, ); /** diff --git a/packages/ensnode-sdk/src/tracing/index.ts b/packages/ensnode-sdk/src/tracing/index.ts index bc1fd2f91..05da86a54 100644 --- a/packages/ensnode-sdk/src/tracing/index.ts +++ b/packages/ensnode-sdk/src/tracing/index.ts @@ -64,5 +64,7 @@ export interface ProtocolSpan { events: SpanEvent[]; } -export type ProtocolSpanTreeNode = ProtocolSpan & { children: ProtocolSpanTreeNode[] }; +export type ProtocolSpanTreeNode = ProtocolSpan & { + children: ProtocolSpanTreeNode[]; +}; export type ProtocolTrace = ProtocolSpanTreeNode[]; diff --git a/packages/ponder-metadata/src/index.ts b/packages/ponder-metadata/src/index.ts index ccaafe727..016c07171 100644 --- a/packages/ponder-metadata/src/index.ts +++ b/packages/ponder-metadata/src/index.ts @@ -2,4 +2,8 @@ export { queryPonderMeta } from "./db-helpers"; export { type MetadataMiddlewareResponse, ponderMetadata } from "./middleware"; export { PrometheusMetrics } from "./prometheus-metrics"; export type { PonderMetadataMiddlewareResponse } from "./types/api"; -export type { BlockInfo, ChainIndexingStatus, PonderStatus } from "./types/common"; +export type { + BlockInfo, + ChainIndexingStatus, + PonderStatus, +} from "./types/common"; diff --git a/packages/ponder-metadata/src/prometheus-metrics.test.ts b/packages/ponder-metadata/src/prometheus-metrics.test.ts index 7fc0d60d1..a16dd2ab7 100644 --- a/packages/ponder-metadata/src/prometheus-metrics.test.ts +++ b/packages/ponder-metadata/src/prometheus-metrics.test.ts @@ -93,14 +93,18 @@ ponder_indexing_has_error 0 it("should get network-specific metrics", () => { // Test network 1 - expect(parser.getValue("ponder_historical_total_indexing_seconds", { network: "1" })).toBe( - 251224935, - ); + expect( + parser.getValue("ponder_historical_total_indexing_seconds", { + network: "1", + }), + ).toBe(251224935); // Test network 8453 - expect(parser.getValue("ponder_historical_total_indexing_seconds", { network: "8453" })).toBe( - 251224935, - ); + expect( + parser.getValue("ponder_historical_total_indexing_seconds", { + network: "8453", + }), + ).toBe(251224935); }); it("should get all network IDs", () => { diff --git a/packages/ponder-subgraph/src/graphql.ts b/packages/ponder-subgraph/src/graphql.ts index 539956a68..734a276ca 100644 --- a/packages/ponder-subgraph/src/graphql.ts +++ b/packages/ponder-subgraph/src/graphql.ts @@ -422,7 +422,9 @@ export function buildGraphQLSchema({ // key constraints, all `one` relations must be nullable. type: referencedEntityType, resolve: (parent, _args, context) => { - const loader = context.getDataLoader({ table: referencedTable }); + const loader = context.getDataLoader({ + table: referencedTable, + }); const rowFragment: Record = {}; for (let i = 0; i < references.length; i++) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c804a931..980d5823e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -724,6 +724,9 @@ importers: '@ensnode/datasources': specifier: workspace:* version: link:../datasources + caip: + specifier: ^1.1.1 + version: 1.1.1 zod: specifier: 'catalog:' version: 3.25.76 @@ -10732,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 @@ -15384,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