From 0e2b3df42aee57f30b7e8c32dbf034f5deb37303 Mon Sep 17 00:00:00 2001 From: MananTank Date: Mon, 13 Jan 2025 18:18:05 +0000 Subject: [PATCH] [TOOL-3009] Dashboard: Support all valid domains as ENS name and not just .eth (#5937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR-Codex overview This PR introduces the `isValidENSName` utility function to validate ENS names, replacing the previous `isEnsName` checks across multiple files. It refines address validation and enhances the overall handling of ENS names within the codebase. ### Detailed summary - Added `isValidENSName` utility function for ENS name validation. - Replaced occurrences of `isEnsName` with `isValidENSName` in various files. - Updated validation logic in components and utilities to improve address and ENS name handling. - Enhanced error messages and conditions related to ENS name checks. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .changeset/happy-carrots-appear.md | 13 +++++ .../overview/components/published-by-ui.tsx | 6 +-- .../[addressOrEns]/resolveAddressAndEns.tsx | 5 +- .../[contract_id]/[version]/page.tsx | 1 - .../[publisher]/[contract_id]/page.tsx | 1 - .../components/contract-components/hooks.ts | 10 ++-- .../published-contract/index.tsx | 6 +-- .../publisher/publisher-header.tsx | 15 ++++-- apps/dashboard/src/constants/schemas.ts | 17 ++---- .../solidity-inputs/address-input.tsx | 11 ++-- .../components/solidity-inputs/helpers.ts | 4 +- apps/dashboard/src/lib/address-utils.ts | 4 +- apps/dashboard/src/lib/ens.ts | 7 +-- apps/dashboard/src/middleware.ts | 7 +-- .../src/components/social/social-profiles.tsx | 3 +- packages/thirdweb/src/exports/utils.ts | 3 ++ .../react/core/hooks/wallets/useSendToken.ts | 3 +- .../src/utils/ens/isValidENSName.test.ts | 39 ++++++++++++++ .../thirdweb/src/utils/ens/isValidENSName.ts | 54 +++++++++++++++++++ 19 files changed, 160 insertions(+), 49 deletions(-) create mode 100644 .changeset/happy-carrots-appear.md create mode 100644 packages/thirdweb/src/utils/ens/isValidENSName.test.ts create mode 100644 packages/thirdweb/src/utils/ens/isValidENSName.ts diff --git a/.changeset/happy-carrots-appear.md b/.changeset/happy-carrots-appear.md new file mode 100644 index 00000000000..ec268dbb9c1 --- /dev/null +++ b/.changeset/happy-carrots-appear.md @@ -0,0 +1,13 @@ +--- +"thirdweb": patch +--- + +Add `isValidENSName` utility function for checking if a string is a valid ENS name. It does not check if the name is actually registered, it only checks if the string is in a valid format. + +```ts +import { isValidENSName } from "thirdweb/utils"; + +isValidENSName("thirdweb.eth"); // true +isValidENSName("foo.bar.com"); // true +isValidENSName("foo"); // false +``` diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/published-by-ui.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/published-by-ui.tsx index 29a42696e17..4e70eaae6e1 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/published-by-ui.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/published-by-ui.tsx @@ -6,9 +6,9 @@ import { getBytecode, getContract } from "thirdweb/contract"; import { getPublishedUriFromCompilerUri } from "thirdweb/extensions/thirdweb"; import { getInstalledModules } from "thirdweb/modules"; import { download } from "thirdweb/storage"; -import { extractIPFSUri } from "thirdweb/utils"; +import { extractIPFSUri, isValidENSName } from "thirdweb/utils"; import { fetchPublishedContractsFromDeploy } from "../../../../../../../components/contract-components/fetchPublishedContractsFromDeploy"; -import { isEnsName, resolveEns } from "../../../../../../../lib/ens"; +import { resolveEns } from "../../../../../../../lib/ens"; type ModuleMetadataPickedKeys = { publisher: string; @@ -52,7 +52,7 @@ export async function getPublishedByCardProps(params: { // get publisher address/ens let publisherAddressOrEns = publishedContractToShow.publisher; - if (!isEnsName(publishedContractToShow.publisher)) { + if (!isValidENSName(publishedContractToShow.publisher)) { try { const res = await resolveEns(publishedContractToShow.publisher); if (res.ensName) { diff --git a/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/resolveAddressAndEns.tsx b/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/resolveAddressAndEns.tsx index 68dd1ef7346..fb6579422d0 100644 --- a/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/resolveAddressAndEns.tsx +++ b/apps/dashboard/src/app/(dashboard)/profile/[addressOrEns]/resolveAddressAndEns.tsx @@ -1,6 +1,7 @@ import { getAddress, isAddress } from "thirdweb"; +import { isValidENSName } from "thirdweb/utils"; import { mapThirdwebPublisher } from "../../../../components/contract-components/fetch-contracts-with-versions"; -import { isEnsName, resolveEns } from "../../../../lib/ens"; +import { resolveEns } from "../../../../lib/ens"; type ResolvedAddressInfo = { address: string; @@ -17,7 +18,7 @@ export async function resolveAddressAndEns( }; } - if (isEnsName(addressOrEns)) { + if (isValidENSName(addressOrEns)) { const mappedEns = mapThirdwebPublisher(addressOrEns); const res = await resolveEns(mappedEns).catch(() => null); if (res?.address) { diff --git a/apps/dashboard/src/app/(dashboard)/published-contract/[publisher]/[contract_id]/[version]/page.tsx b/apps/dashboard/src/app/(dashboard)/published-contract/[publisher]/[contract_id]/[version]/page.tsx index 783c15d7e65..8d906c2ad24 100644 --- a/apps/dashboard/src/app/(dashboard)/published-contract/[publisher]/[contract_id]/[version]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/published-contract/[publisher]/[contract_id]/[version]/page.tsx @@ -85,7 +85,6 @@ export default async function PublishedContractPage( diff --git a/apps/dashboard/src/app/(dashboard)/published-contract/[publisher]/[contract_id]/page.tsx b/apps/dashboard/src/app/(dashboard)/published-contract/[publisher]/[contract_id]/page.tsx index 633acaf2568..f437907c6b1 100644 --- a/apps/dashboard/src/app/(dashboard)/published-contract/[publisher]/[contract_id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/published-contract/[publisher]/[contract_id]/page.tsx @@ -54,7 +54,6 @@ export default async function PublishedContractPage(
diff --git a/apps/dashboard/src/components/contract-components/hooks.ts b/apps/dashboard/src/components/contract-components/hooks.ts index c3d8d1cb425..e514eea8517 100644 --- a/apps/dashboard/src/components/contract-components/hooks.ts +++ b/apps/dashboard/src/components/contract-components/hooks.ts @@ -3,12 +3,12 @@ import { useThirdwebClient } from "@/constants/thirdweb.client"; import { queryOptions, useQuery } from "@tanstack/react-query"; import type { Abi } from "abitype"; -import { isEnsName, resolveEns } from "lib/ens"; +import { resolveEns } from "lib/ens"; import { useV5DashboardChain } from "lib/v5-adapter"; import { useMemo } from "react"; import type { ThirdwebContract } from "thirdweb"; import { getContract, resolveContractAbi } from "thirdweb/contract"; -import { isAddress } from "thirdweb/utils"; +import { isAddress, isValidENSName } from "thirdweb/utils"; import { type PublishedContractWithVersion, fetchPublishedContractVersions, @@ -130,7 +130,7 @@ function ensQuery(addressOrEnsName?: string) { return placeholderData; } // if it is neither an address or an ens name then return the placeholder data only - if (!isAddress(addressOrEnsName) && !isEnsName(addressOrEnsName)) { + if (!isAddress(addressOrEnsName) && !isValidENSName(addressOrEnsName)) { throw new Error("Invalid address or ENS name."); } @@ -143,7 +143,7 @@ function ensQuery(addressOrEnsName?: string) { }), ); - if (isEnsName(addressOrEnsName) && !address) { + if (isValidENSName(addressOrEnsName) && !address) { throw new Error("Failed to resolve ENS name."); } @@ -154,7 +154,7 @@ function ensQuery(addressOrEnsName?: string) { }, enabled: !!addressOrEnsName && - (isAddress(addressOrEnsName) || isEnsName(addressOrEnsName)), + (isAddress(addressOrEnsName) || isValidENSName(addressOrEnsName)), // 24h gcTime: 60 * 60 * 24 * 1000, // 1h diff --git a/apps/dashboard/src/components/contract-components/published-contract/index.tsx b/apps/dashboard/src/components/contract-components/published-contract/index.tsx index 7c9c404033b..32f5dfc89bc 100644 --- a/apps/dashboard/src/components/contract-components/published-contract/index.tsx +++ b/apps/dashboard/src/components/contract-components/published-contract/index.tsx @@ -42,13 +42,11 @@ interface ExtendedPublishedContract extends PublishedContractWithVersion { interface PublishedContractProps { publishedContract: ExtendedPublishedContract; - walletOrEns: string; twAccount: Account | undefined; } export const PublishedContract: React.FC = ({ publishedContract, - walletOrEns, twAccount, }) => { const address = useActiveAccount()?.address; @@ -154,7 +152,9 @@ export const PublishedContract: React.FC = ({ - {walletOrEns && } + {publishedContract.publisher && ( + + )} diff --git a/apps/dashboard/src/components/contract-components/publisher/publisher-header.tsx b/apps/dashboard/src/components/contract-components/publisher/publisher-header.tsx index 36b15bb72db..094d013a358 100644 --- a/apps/dashboard/src/components/contract-components/publisher/publisher-header.tsx +++ b/apps/dashboard/src/components/contract-components/publisher/publisher-header.tsx @@ -83,11 +83,16 @@ export const PublisherHeader: React.FC = ({ > - shortenIfAddress(replaceDeployerAddress(addr)) - } - /> + // When social profile API support other TLDs as well - we can remove this condition + ensQuery.data?.ensName ? ( + {ensQuery.data?.ensName} + ) : ( + + shortenIfAddress(replaceDeployerAddress(addr)) + } + /> + ) } loadingComponent={} formatFn={(name) => replaceDeployerAddress(name)} diff --git a/apps/dashboard/src/constants/schemas.ts b/apps/dashboard/src/constants/schemas.ts index c44e436b201..7137d0db9cc 100644 --- a/apps/dashboard/src/constants/schemas.ts +++ b/apps/dashboard/src/constants/schemas.ts @@ -1,5 +1,6 @@ import { resolveEns } from "lib/ens"; import { isAddress } from "thirdweb"; +import { isValidENSName } from "thirdweb/utils"; import z from "zod"; /** @@ -15,19 +16,11 @@ export const BasisPointsSchema = z .min(0, "Cannot be below 0%"); // @internal -type EnsName = `${string}.eth` | `${string}.cb.id`; +type EnsName = string; -// Only pass through to provider call if value ends with .eth or .cb.id -const EnsSchema: z.ZodType< - `0x${string}`, - z.ZodTypeDef, - `${string}.eth` | `${string}.cb.id` -> = z - .custom( - (ens) => - typeof ens === "string" && - (ens.endsWith(".eth") || ens.endsWith(".cb.id")), - ) +// Only pass through to provider call if value is a valid ENS name +const EnsSchema: z.ZodType<`0x${string}`, z.ZodTypeDef, string> = z + .custom((ens) => typeof ens === "string" && isValidENSName(ens)) .transform(async (ens) => (await resolveEns(ens)).address) .refine( (address): address is `0x${string}` => !!address && isAddress(address), diff --git a/apps/dashboard/src/contract-ui/components/solidity-inputs/address-input.tsx b/apps/dashboard/src/contract-ui/components/solidity-inputs/address-input.tsx index d13ecadca7d..fde72f66801 100644 --- a/apps/dashboard/src/contract-ui/components/solidity-inputs/address-input.tsx +++ b/apps/dashboard/src/contract-ui/components/solidity-inputs/address-input.tsx @@ -5,7 +5,7 @@ import { useEns } from "components/contract-components/hooks"; import { CheckIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useActiveAccount } from "thirdweb/react"; -import { isAddress } from "thirdweb/utils"; +import { isAddress, isValidENSName } from "thirdweb/utils"; import { FormHelperText } from "tw-components"; import type { SolidityInputProps } from "."; import { validateAddress } from "./helpers"; @@ -77,14 +77,19 @@ export const SolidityAddressInput: React.FC = ({ const resolvingEns = useMemo( () => - localInput?.endsWith(".eth") && + localInput && + isValidENSName(localInput) && !ensQuery.isError && !ensQuery.data?.address, [ensQuery.data?.address, ensQuery.isError, localInput], ); const resolvedAddress = useMemo( - () => localInput?.endsWith(".eth") && !hasError && ensQuery.data?.address, + () => + localInput && + isValidENSName(localInput) && + !hasError && + ensQuery.data?.address, [ensQuery.data?.address, hasError, localInput], ); diff --git a/apps/dashboard/src/contract-ui/components/solidity-inputs/helpers.ts b/apps/dashboard/src/contract-ui/components/solidity-inputs/helpers.ts index ccea77e6c46..9ae8f9c2df9 100644 --- a/apps/dashboard/src/contract-ui/components/solidity-inputs/helpers.ts +++ b/apps/dashboard/src/contract-ui/components/solidity-inputs/helpers.ts @@ -1,4 +1,4 @@ -import { isAddress, isBytes, isHex } from "thirdweb/utils"; +import { isAddress, isBytes, isHex, isValidENSName } from "thirdweb/utils"; // int and uint function calculateIntMinValues(solidityType: string) { @@ -147,7 +147,7 @@ export const validateBytes = (value: string, solidityType: string) => { // address export const validateAddress = (value: string) => { - if (!isAddress(value) && !value.endsWith(".eth")) { + if (!isAddress(value) && !isValidENSName(value)) { return { type: "pattern", message: "Input is not a valid address or ENS name.", diff --git a/apps/dashboard/src/lib/address-utils.ts b/apps/dashboard/src/lib/address-utils.ts index 7ac9658d854..e9a6277ed9a 100644 --- a/apps/dashboard/src/lib/address-utils.ts +++ b/apps/dashboard/src/lib/address-utils.ts @@ -1,12 +1,12 @@ import { isAddress } from "thirdweb"; -import { isEnsName } from "./ens"; +import { isValidENSName } from "thirdweb/utils"; // if a string is a valid address or ens name export function isPossibleEVMAddress(address?: string, ignoreEns?: boolean) { if (!address) { return false; } - if (isEnsName(address) && !ignoreEns) { + if (isValidENSName(address) && !ignoreEns) { return true; } return isAddress(address); diff --git a/apps/dashboard/src/lib/ens.ts b/apps/dashboard/src/lib/ens.ts index 9f209573ba3..a55c67964f7 100644 --- a/apps/dashboard/src/lib/ens.ts +++ b/apps/dashboard/src/lib/ens.ts @@ -1,16 +1,13 @@ import { getThirdwebClient } from "@/constants/thirdweb.server"; import { isAddress } from "thirdweb"; import { resolveAddress, resolveName } from "thirdweb/extensions/ens"; +import { isValidENSName } from "thirdweb/utils"; interface ENSResolveResult { ensName: string | null; address: string | null; } -export function isEnsName(name: string): boolean { - return name?.endsWith(".eth"); -} - export async function resolveEns( ensNameOrAddress: string, ): Promise { @@ -24,7 +21,7 @@ export async function resolveEns( }; } - if (!isEnsName(ensNameOrAddress)) { + if (!isValidENSName(ensNameOrAddress)) { throw new Error("Invalid ENS name"); } diff --git a/apps/dashboard/src/middleware.ts b/apps/dashboard/src/middleware.ts index 8a17f6336e1..4c1fe43ebfd 100644 --- a/apps/dashboard/src/middleware.ts +++ b/apps/dashboard/src/middleware.ts @@ -4,6 +4,7 @@ import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie"; import { type NextRequest, NextResponse } from "next/server"; import { getAddress } from "thirdweb"; import { getChainMetadata } from "thirdweb/chains"; +import { isValidENSName } from "thirdweb/utils"; import { defineDashboardChain } from "./lib/defineDashboardChain"; // ignore assets, api - only intercept page routes @@ -136,7 +137,7 @@ export async function middleware(request: NextRequest) { // DIFFERENT DYNAMIC ROUTING CASES // /
/... case - if (paths[0] && isPossibleEVMAddress(paths[0])) { + if (paths[0] && isPossibleAddressOrENSName(paths[0])) { // special case for "deployer.thirdweb.eth" // we want to always redirect this to "thirdweb.eth/..." if (paths[0] === "deployer.thirdweb.eth") { @@ -181,8 +182,8 @@ export async function middleware(request: NextRequest) { } } -function isPossibleEVMAddress(address: string) { - return address?.startsWith("0x") || address?.endsWith(".eth"); +function isPossibleAddressOrENSName(address: string) { + return address.startsWith("0x") || isValidENSName(address); } // utils for rewriting and redirecting with relative paths diff --git a/apps/playground-web/src/components/social/social-profiles.tsx b/apps/playground-web/src/components/social/social-profiles.tsx index 43e57e5d9ec..2919e68537f 100644 --- a/apps/playground-web/src/components/social/social-profiles.tsx +++ b/apps/playground-web/src/components/social/social-profiles.tsx @@ -8,6 +8,7 @@ import { isAddress } from "thirdweb"; import { resolveAddress } from "thirdweb/extensions/ens"; import { type SocialProfile, getSocialProfiles } from "thirdweb/social"; import { resolveScheme } from "thirdweb/storage"; +import { isValidENSName } from "thirdweb/utils"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -19,7 +20,7 @@ export function SocialProfiles() { const { mutate: searchProfiles, isPending } = useMutation({ mutationFn: async (address: string) => { const resolvedAddress = await (async () => { - if (address.endsWith(".eth")) { + if (isValidENSName(address)) { return resolveAddress({ client: THIRDWEB_CLIENT, name: address, diff --git a/packages/thirdweb/src/exports/utils.ts b/packages/thirdweb/src/exports/utils.ts index 4d265e549bb..8f68c3a66d0 100644 --- a/packages/thirdweb/src/exports/utils.ts +++ b/packages/thirdweb/src/exports/utils.ts @@ -208,3 +208,6 @@ export type { export { shortenLargeNumber } from "../utils/shortenLargeNumber.js"; export { formatNumber } from "../utils/formatNumber.js"; + +// ENS +export { isValidENSName } from "../utils/ens/isValidENSName.js"; diff --git a/packages/thirdweb/src/react/core/hooks/wallets/useSendToken.ts b/packages/thirdweb/src/react/core/hooks/wallets/useSendToken.ts index f8afaeb06fe..c26eb76986d 100644 --- a/packages/thirdweb/src/react/core/hooks/wallets/useSendToken.ts +++ b/packages/thirdweb/src/react/core/hooks/wallets/useSendToken.ts @@ -6,6 +6,7 @@ import { transfer } from "../../../../extensions/erc20/write/transfer.js"; import { sendTransaction } from "../../../../transaction/actions/send-transaction.js"; import { prepareTransaction } from "../../../../transaction/prepare-transaction.js"; import { isAddress } from "../../../../utils/address.js"; +import { isValidENSName } from "../../../../utils/ens/isValidENSName.js"; import { toWei } from "../../../../utils/units.js"; import { useActiveWallet } from "./useActiveWallet.js"; @@ -53,7 +54,7 @@ export function useSendToken(client: ThirdwebClient) { // input validation if ( !receiverAddress || - (!receiverAddress.endsWith(".eth") && !isAddress(receiverAddress)) + (!isValidENSName(receiverAddress) && !isAddress(receiverAddress)) ) { throw new Error("Invalid receiver address"); } diff --git a/packages/thirdweb/src/utils/ens/isValidENSName.test.ts b/packages/thirdweb/src/utils/ens/isValidENSName.test.ts new file mode 100644 index 00000000000..f1ff09f86cb --- /dev/null +++ b/packages/thirdweb/src/utils/ens/isValidENSName.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { isValidENSName } from "./isValidENSName.js"; + +describe("isValidENSName", () => { + it("should return true for a valid ENS name", () => { + expect(isValidENSName("thirdweb.eth")).toBe(true); + expect(isValidENSName("deployer.thirdweb.eth")).toBe(true); + expect(isValidENSName("x.eth")).toBe(true); + expect(isValidENSName("foo.bar.com")).toBe(true); + expect(isValidENSName("foo.com")).toBe(true); + expect(isValidENSName("somename.xyz")).toBe(true); + expect(isValidENSName("_foo.bar")).toBe(true); + expect(isValidENSName("-foo.bar.com")).toBe(true); + }); + + it("should return false for an invalid ENS name", () => { + // No TLD + expect(isValidENSName("")).toBe(false); + expect(isValidENSName("foo")).toBe(false); + + // parts with length < 2 + expect(isValidENSName(".eth")).toBe(false); + expect(isValidENSName("foo..com")).toBe(false); + expect(isValidENSName("thirdweb.eth.")).toBe(false); + + // numeric TLD + expect(isValidENSName("foo.123")).toBe(false); + + // whitespace in parts + expect(isValidENSName("foo .com")).toBe(false); + expect(isValidENSName("foo. com")).toBe(false); + + // full-width characters + expect(isValidENSName("foo.bar.com")).toBe(false); + + // wildcard characters + expect(isValidENSName("foo*bar.com")).toBe(false); + }); +}); diff --git a/packages/thirdweb/src/utils/ens/isValidENSName.ts b/packages/thirdweb/src/utils/ens/isValidENSName.ts new file mode 100644 index 00000000000..cd2a9106e95 --- /dev/null +++ b/packages/thirdweb/src/utils/ens/isValidENSName.ts @@ -0,0 +1,54 @@ +// modified version of isFQDN from validator.js that checks if given string is a valid domain name +// https://github.com/validatorjs/validator.js/blob/master/src/lib/isFQDN.js +// underscores are allowed, hyphens are allowed, no max length check + +/** + * Checks if a string is a valid ENS name. + * It does not check if the ENS name is currently registered or resolves to an address - it only validates the string format. + * + * @param name - The ENS name to check. + * + * @example + * ```ts + * isValidENSName("thirdweb.eth"); // true + * isValidENSName("foo.bar.com"); // true + * isValidENSName("xyz"); // false + */ +export function isValidENSName(name: string) { + const parts = name.split("."); + const tld = parts[parts.length - 1]; + + // disallow fqdns without tld + if (parts.length < 2 || !tld) { + return false; + } + + // disallow spaces + if (/\s/.test(tld)) { + return false; + } + + // reject numeric TLDs + if (/^\d+$/.test(tld)) { + return false; + } + + return parts.every((part) => { + // part must be at least 1 char long + if (part.length < 1) { + return false; + } + + // disallow invalid chars + if (!/^[a-z_\u00a1-\uffff0-9-]+$/i.test(part)) { + return false; + } + + // disallow full-width chars + if (/[\uff01-\uff5e]/.test(part)) { + return false; + } + + return true; + }); +}