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;
+ });
+}