Change getContractMetadata() return type from any to unknown (#8289)

This commit is contained in:
Jonas Daniels
2025-10-21 17:43:43 -07:00
committed by GitHub
parent f2121e9519
commit b30369f3cc
25 changed files with 335 additions and 62 deletions
+44
View File
@@ -0,0 +1,44 @@
---
"thirdweb": minor
---
### `getContractMetadata()` now returns a record with `unknown` values instead of `any`.
before:
```ts
const metadata = await getContractMetadata({ contract });
metadata // Record<string, any>
metadata.name; // string
metadata.symbol; // string
```
after:
```ts
const metadata = await getContractMetadata({ contract });
metadata // Record<string, unknown>
metadata.name; // string | null
metadata.symbol; // string | null
```
Metadata is not (and was never) strictly defined outside of `name` and `symbol` and may contain any type of data in the record.
This is not a runtime change but it may break type inference in existing apps that relied on the previous return type.
**Recommended fix:**
You *should* type-guard any key you access from "metadata".
```ts
const metadata = await getContractMetadata({ contract });
if ("foo" in metadata && typeof metadata.foo === "string") {
metadata.foo; // string
}
```
**Quick fix:**
If adding type assertions is not something you can do in the short term you can also assert the type directly.
_This is as "unsafe" as the type was before._
```ts
const metadata = await getContractMetadata({ contract });
const foo = metadata.foo as string;
```
@@ -21,7 +21,7 @@ export async function getUniversalBridgeTokens(props: {
headers: {
"Content-Type": "application/json",
"x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
} as Record<string, string>,
},
method: "GET",
});
@@ -21,7 +21,7 @@ export async function addUniversalBridgeTokenRoute(props: {
Authorization: `Bearer ${authToken}`,
"Content-Type": "application/json",
"x-client-id": props.project.publishableKey,
} as Record<string, string>,
},
method: "POST",
});
@@ -59,7 +59,10 @@ export async function fetchDashboardContractMetadata(
return {
contractType: compilerMetadata?.name || "",
image: contractMetadata.image || "",
image:
contractMetadata.image && typeof contractMetadata.image === "string"
? contractMetadata.image
: "",
name: contractName,
symbol: contractSymbol,
};
@@ -10,8 +10,7 @@ import * as ERC20Ext from "thirdweb/extensions/erc20";
import * as ERC721Ext from "thirdweb/extensions/erc721";
import * as ERC1155Ext from "thirdweb/extensions/erc1155";
import { download } from "thirdweb/storage";
import type { OverrideEntry } from "thirdweb/utils";
import { maxUint256 } from "thirdweb/utils";
import { isRecord, maxUint256, type OverrideEntry } from "thirdweb/utils";
import type { z } from "zod";
import type {
ClaimCondition as LegacyClaimCondition,
@@ -86,7 +85,7 @@ export async function getClaimPhasesInLegacyFormat(
]);
const snapshot = await fetchSnapshot(
condition.merkleRoot,
contractMetadata.merkle,
isRecord(contractMetadata.merkle) ? contractMetadata.merkle : {},
options.contract.client,
);
return {
@@ -47,7 +47,10 @@ export default async function Image({
chainName: info.chainMetadata.name,
contractAddress: info.serverContract.address,
displayName: contractDisplayName,
logo: contractMetadata.image,
logo:
contractMetadata.image && typeof contractMetadata.image === "string"
? contractMetadata.image
: undefined,
});
} catch {
return contractOGImageTemplate({
@@ -89,16 +89,31 @@ export async function ERC20PublicPage(props: {
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={contractCreator}
image={contractMetadata.image}
image={
contractMetadata.image &&
typeof contractMetadata.image === "string"
? contractMetadata.image
: undefined
}
isDashboardUser={isDashboardUser}
name={contractMetadata.name}
name={
contractMetadata.name && typeof contractMetadata.name === "string"
? contractMetadata.name
: // if we do not have a contract name fall back to the address
props.clientContract.address
}
socialUrls={
typeof contractMetadata.social_urls === "object" &&
contractMetadata.social_urls !== null
? contractMetadata.social_urls
: {}
}
symbol={contractMetadata.symbol}
symbol={
contractMetadata.symbol &&
typeof contractMetadata.symbol === "string"
? contractMetadata.symbol
: undefined
}
/>
</div>
</div>
@@ -143,21 +158,25 @@ export async function ERC20PublicPage(props: {
</div>
)}
{showBuyEmbed && (
<div className="container max-w-7xl pb-10">
<GridPatternEmbedContainer>
<BuyEmbed
chainMetadata={props.chainMetadata}
claimConditionMeta={claimConditionMeta}
clientContract={props.clientContract}
tokenAddress={props.clientContract.address}
tokenDecimals={tokenDecimals}
tokenName={contractMetadata.name}
tokenSymbol={contractMetadata.symbol}
/>
</GridPatternEmbedContainer>
</div>
)}
{showBuyEmbed &&
contractMetadata.name &&
typeof contractMetadata.name === "string" &&
contractMetadata.symbol &&
typeof contractMetadata.symbol === "string" && (
<div className="container max-w-7xl pb-10">
<GridPatternEmbedContainer>
<BuyEmbed
chainMetadata={props.chainMetadata}
claimConditionMeta={claimConditionMeta}
clientContract={props.clientContract}
tokenAddress={props.clientContract.address}
tokenDecimals={tokenDecimals}
tokenName={contractMetadata.name}
tokenSymbol={contractMetadata.symbol}
/>
</GridPatternEmbedContainer>
</div>
)}
<div className="container flex max-w-7xl grow flex-col pb-10">
<div className="flex grow flex-col gap-8">
@@ -167,12 +186,15 @@ export async function ERC20PublicPage(props: {
contractAddress={props.clientContract.address}
/>
<RecentTransfers
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
decimals={tokenDecimals}
tokenSymbol={contractMetadata.symbol}
/>
{contractMetadata.symbol &&
typeof contractMetadata.symbol === "string" && (
<RecentTransfers
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
decimals={tokenDecimals}
tokenSymbol={contractMetadata.symbol}
/>
)}
</div>
</div>
</div>
@@ -53,6 +53,13 @@ export async function NFTPublicPage(props: {
const _isTokenByIndexSupported =
props.type === "erc721" && isTokenByIndexSupported(functionSelectors);
// FIXME: this is technically a bad fallback but we gotta do what we gotta do
const contractMetadataWithNameAndSymbolFallback = {
...contractMetadata,
// fall back to the contract address if the name is not set
name: contractMetadata.name || props.clientContract.address,
symbol: contractMetadata.symbol || "",
};
const buyNFTDropCard = nftDropClaimParams ? (
<BuyNFTDropCardServer
chainMetadata={props.chainMetadata}
@@ -73,7 +80,7 @@ export async function NFTPublicPage(props: {
<NFTsGrid
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
collectionMetadata={contractMetadata}
collectionMetadata={contractMetadataWithNameAndSymbolFallback}
gridClassName={
buyNFTDropCard
? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
@@ -110,7 +117,7 @@ export async function NFTPublicPage(props: {
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
contractCreator={contractCreator}
contractMetadata={contractMetadata}
contractMetadata={contractMetadataWithNameAndSymbolFallback}
isDashboardUser={isDashboardUser}
>
<ResponsiveLayout
@@ -138,7 +145,7 @@ export async function NFTPublicPage(props: {
<PageLoadTokenViewerSheet
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
collectionMetadata={contractMetadata}
collectionMetadata={contractMetadataWithNameAndSymbolFallback}
tokenByIndexSupported={_isTokenByIndexSupported}
tokenId={BigInt(props.tokenId)}
type={props.type}
@@ -37,7 +37,7 @@ export function TokenViewerSheet(
clientContract: ThirdwebContract;
chainMetadata: ChainMetadata;
collectionMetadata: {
name: string;
name: string | null;
image?: string;
};
type: "erc1155" | "erc721";
@@ -57,6 +57,11 @@ export function TokenViewerSheet(
) {
const tokenId = props.variant === "fetch-data" ? props.tokenId : props.nft.id;
const collectionMetadataWithNameFallback = {
...props.collectionMetadata,
name: props.collectionMetadata.name || props.clientContract.address,
};
return (
<Dialog
onOpenChange={(open) => {
@@ -78,7 +83,7 @@ export function TokenViewerSheet(
<FetchAndRenderTokenInfo
chainMetadata={props.chainMetadata}
clientContract={props.clientContract}
collectionMetadata={props.collectionMetadata}
collectionMetadata={collectionMetadataWithNameFallback}
tokenByIndexSupported={props.tokenByIndexSupported}
tokenId={props.tokenId}
type={props.type}
@@ -86,7 +91,7 @@ export function TokenViewerSheet(
) : (
<TokenInfoUI
chainMetadata={props.chainMetadata}
collectionMetadata={props.collectionMetadata}
collectionMetadata={collectionMetadataWithNameFallback}
contract={props.clientContract}
data={props.nft}
tokenId={tokenId}
@@ -84,7 +84,10 @@ export const SettingsMetadata = ({
console.error(err);
}
}
let image: string | undefined = metadata.data?.image;
let image: string | undefined =
metadata.data?.image && typeof metadata.data.image === "string"
? metadata.data.image
: undefined;
try {
image = image
? // eslint-disable-next-line no-restricted-syntax
@@ -76,7 +76,9 @@ export default async function DropPage({
]);
const thumbnail =
project.thumbnail || nft.metadata.image || contractMetadata.image || "";
project.thumbnail ||
nft.metadata.image ||
(typeof contractMetadata.image === "string" ? contractMetadata.image : "");
const displayName = contractMetadata.name || nft.metadata.name || "";
@@ -18,19 +18,19 @@ export function getAuthHeaders(
case !!secretKey:
return {
"x-secret-key": secretKey,
} as Record<string, string>;
};
// 2. if we have a JWT AND either a teamId or clientId, we'll use the JWT for auth
case !!(jwt && (teamId || clientId)):
return {
Authorization: `Bearer ${jwt}`,
} as Record<string, string>;
};
// 3. if we have an incoming service api key, we'll use it
case !!incomingServiceApiKey: {
return {
"x-service-api-key": incomingServiceApiKey,
} as Record<string, string>;
};
}
// 4. if nothing else is present, we'll use the service api key
+5
View File
@@ -197,3 +197,8 @@ export type { JWTPayload } from "../utils/jwt/types.js";
export type { NFTInput, NFTMetadata } from "../utils/nft/parseNft.js";
export { resolvePromisedValue } from "../utils/promise/resolve-promised-value.js";
export { shortenLargeNumber } from "../utils/shortenLargeNumber.js";
// ------------------------------------------------
// type guards
// ------------------------------------------------
export * from "../utils/type-guards.js";
@@ -1,5 +1,6 @@
import { upload } from "../../../storage/upload.js";
import type { BaseTransactionOptions } from "../../../transaction/types.js";
import { isRecord } from "../../../utils/type-guards.js";
import { setContractURI } from "../../common/__generated__/IContractMetadata/write/setContractURI.js";
/**
@@ -80,7 +81,12 @@ export function saveSnapshot(
// keep the old merkle roots from other tokenIds
for (const key of Object.keys(metadata.merkle || {})) {
merkleInfos[key] = metadata.merkle[key];
const merkleInfo = isRecord(metadata.merkle)
? metadata.merkle[key]
: undefined;
if (merkleInfo) {
merkleInfos[key] = merkleInfo;
}
}
const mergedMetadata = {
...metadata,
@@ -20,10 +20,9 @@ export { isContractURISupported as isGetContractMetadataSupported } from "../__g
export async function getContractMetadata(
options: BaseTransactionOptions,
): Promise<{
name: string;
symbol: string;
// biome-ignore lint/suspicious/noExplicitAny: TODO: fix any
[key: string]: any;
name: string | null;
symbol: string | null;
[key: string]: unknown;
}> {
const [resolvedMetadata, resolvedName, resolvedSymbol] = await Promise.all([
contractURI(options)
@@ -43,8 +42,14 @@ export async function getContractMetadata(
// TODO: basic parsing?
return {
...resolvedMetadata,
name: resolvedMetadata?.name ?? resolvedName,
symbol: resolvedMetadata?.symbol ?? resolvedSymbol,
...(resolvedMetadata ?? {}),
name:
resolvedMetadata?.name && typeof resolvedMetadata.name === "string"
? resolvedMetadata.name
: resolvedName,
symbol:
resolvedMetadata?.symbol && typeof resolvedMetadata.symbol === "string"
? resolvedMetadata.symbol
: resolvedSymbol,
};
}
@@ -8,6 +8,7 @@ import { getContractMetadata } from "../../../../../../extensions/common/read/ge
import { getNFT } from "../../../../../../extensions/erc1155/read/getNFT.js";
import type { PreparedTransaction } from "../../../../../../transaction/prepare-transaction.js";
import type { BaseTransactionOptions } from "../../../../../../transaction/types.js";
import { isString } from "../../../../../../utils/type-guards.js";
import type { Account } from "../../../../../../wallets/interfaces/wallet.js";
import { useReadContract } from "../../../../../core/hooks/contract/useReadContract.js";
import { useActiveAccount } from "../../../../../core/hooks/wallets/useActiveAccount.js";
@@ -191,8 +192,10 @@ async function getPayMetadata(
};
}
return {
image: contractMetadata?.image,
name: contractMetadata?.name,
image: isString(contractMetadata?.image)
? contractMetadata.image
: undefined,
name: isString(contractMetadata?.name) ? contractMetadata.name : undefined,
};
}
@@ -11,6 +11,7 @@ import {
createListing,
} from "../../../../../../extensions/marketplace/direct-listings/write/createListing.js";
import type { BaseTransactionOptions } from "../../../../../../transaction/types.js";
import { isString } from "../../../../../../utils/type-guards.js";
import { useReadContract } from "../../../../../core/hooks/contract/useReadContract.js";
import type { TransactionButtonProps } from "../../../../../core/hooks/transaction/transaction-button-utils.js";
import { useActiveAccount } from "../../../../../core/hooks/wallets/useActiveAccount.js";
@@ -228,7 +229,9 @@ async function getPayMetadata(
}
return {
image: contractMetadata?.image,
name: contractMetadata?.name,
image: isString(contractMetadata?.image)
? contractMetadata.image
: undefined,
name: isString(contractMetadata?.name) ? contractMetadata.name : undefined,
};
}
@@ -18,8 +18,7 @@ export type FetchContractMetadata = {
*/
export async function fetchContractMetadata(
options: FetchContractMetadata,
// biome-ignore lint/suspicious/noExplicitAny: TODO: fix any
): Promise<{ [key: string]: any } | undefined> {
): Promise<{ [key: string]: unknown } | undefined> {
const { client, uri } = options;
// handle case where the URI is a base64 encoded JSON
@@ -3,6 +3,7 @@ import { getContractMetadata } from "../../../extensions/common/read/getContract
import { MerkleTree } from "../../../merkletree/MerkleTree.js";
import { download } from "../../../storage/download.js";
import type { Address } from "../../address.js";
import { isRecord } from "../../type-guards.js";
import { hashEntryERC1155 } from "./hash-entry-erc1155.js";
import type {
ClaimProofERC1155,
@@ -54,7 +55,8 @@ export async function fetchProofsERC1155(options: {
const metadata = await getContractMetadata({
contract,
});
const merkleData: Record<string, string> = metadata.merkle || {};
const merkleData = isRecord(metadata.merkle) ? metadata.merkle : {};
const snapshotUri = merkleData[merkleRoot];
if (!snapshotUri) {
@@ -3,6 +3,7 @@ import { getContractMetadata } from "../../../extensions/common/read/getContract
import { MerkleTree } from "../../../merkletree/MerkleTree.js";
import { download } from "../../../storage/download.js";
import type { Address } from "../../address.js";
import { isRecord } from "../../type-guards.js";
import { convertQuantity } from "../drops/convert-quantity.js";
import { hashEntryERC20 } from "./hash-entry-erc20.js";
import type {
@@ -56,7 +57,7 @@ export async function fetchProofsERC20(options: {
const metadata = await getContractMetadata({
contract,
});
const merkleData: Record<string, string> = metadata.merkle || {};
const merkleData = isRecord(metadata.merkle) ? metadata.merkle : {};
const snapshotUri = merkleData[merkleRoot];
if (!snapshotUri) {
@@ -3,6 +3,7 @@ import { getContractMetadata } from "../../../extensions/common/read/getContract
import { MerkleTree } from "../../../merkletree/MerkleTree.js";
import { download } from "../../../storage/download.js";
import type { Address } from "../../address.js";
import { isRecord } from "../../type-guards.js";
import { hashEntryERC721 } from "./hash-entry-erc721.js";
import type {
ClaimProofERC721,
@@ -54,7 +55,8 @@ export async function fetchProofsERC721(options: {
const metadata = await getContractMetadata({
contract,
});
const merkleData: Record<string, string> = metadata.merkle || {};
const merkleData = isRecord(metadata.merkle) ? metadata.merkle : {};
const snapshotUri = merkleData[merkleRoot];
if (!snapshotUri) {
@@ -6,6 +6,7 @@ import {
import type { ThirdwebContract } from "../../../contract/contract.js";
import { getContractMetadata } from "../../../extensions/common/read/getContractMetadata.js";
import type { Hex } from "../../encoding/hex.js";
import { isRecord } from "../../type-guards.js";
import type { ClaimCondition, OverrideProof } from "./types.js";
export type GetClaimParamsOptions = {
@@ -118,7 +119,8 @@ export async function getClaimParams(options: GetClaimParamsOptions) {
const metadata = await getContractMetadata({
contract: options.contract,
});
const merkleData: Record<string, string> = metadata.merkle || {};
const merkleData = isRecord(metadata.merkle) ? metadata.merkle : {};
const snapshotUri = merkleData[cc.merkleRoot];
if (!snapshotUri) {
@@ -5,6 +5,7 @@ import type { SetClaimConditionsParams as GeneratedParams } from "../../../exten
import { upload } from "../../../storage/upload.js";
import { dateToSeconds } from "../../date.js";
import { type Hex, toHex } from "../../encoding/hex.js";
import { isRecord } from "../../type-guards.js";
import { convertErc20Amount } from "../convert-erc20-amount.js";
import { processOverrideList } from "./process-override-list.js";
import type { ClaimConditionsInput } from "./types.js";
@@ -74,7 +75,12 @@ export async function getMulticallSetClaimConditionTransactions(options: {
});
// keep the old merkle roots from other tokenIds
for (const key of Object.keys(metadata.merkle || {})) {
merkleInfos[key] = metadata.merkle[key];
const merkleInfo = isRecord(metadata.merkle)
? metadata.merkle[key]
: undefined;
if (merkleInfo) {
merkleInfos[key] = merkleInfo;
}
}
const mergedMetadata = {
...metadata,
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
isObject,
isObjectWithKeys,
isRecord,
isString,
} from "./type-guards.js";
describe("isObject", () => {
it("returns true for plain objects", () => {
expect(isObject({})).toBe(true);
expect(isObject({ a: 1 })).toBe(true);
});
it("returns true for non-null object-like values (arrays, dates, maps)", () => {
expect(isObject([])).toBe(true);
expect(isObject(new Date())).toBe(true);
expect(isObject(new Map())).toBe(true);
});
it("returns false for primitives, functions and null/undefined", () => {
expect(isObject(null)).toBe(false);
expect(isObject(undefined)).toBe(false);
expect(isObject(123)).toBe(false);
expect(isObject("str")).toBe(false);
expect(isObject(true)).toBe(false);
expect(isObject(() => {})).toBe(false);
});
});
describe("isString", () => {
it("returns true for string primitives", () => {
expect(isString("" as unknown)).toBe(true);
expect(isString("hello" as unknown)).toBe(true);
});
it("returns false for non-strings and String objects", () => {
// String objects are typeof "object"
// eslint-disable-next-line no-new-wrappers
expect(isString(new String("x") as unknown)).toBe(false);
expect(isString(1 as unknown)).toBe(false);
expect(isString({} as unknown)).toBe(false);
expect(isString([] as unknown)).toBe(false);
expect(isString(null as unknown)).toBe(false);
expect(isString(undefined as unknown)).toBe(false);
});
});
describe("isObjectWithKeys", () => {
it("returns true when object has all specified keys", () => {
const value = { a: 1, b: 2 } as const;
expect(isObjectWithKeys(value, ["a"])).toBe(true);
expect(isObjectWithKeys(value, ["a", "b"])).toBe(true);
});
it("returns false when any specified key is missing", () => {
const value = { a: 1 } as const;
expect(isObjectWithKeys(value, ["a", "b"])).toBe(false);
});
it("defaults to just checking object-ness when no keys are provided", () => {
expect(isObjectWithKeys({})).toBe(true);
expect(isObjectWithKeys([])).toBe(true);
expect(isObjectWithKeys(123 as unknown)).toBe(false);
});
it("works with arrays when keys exist on the array", () => {
const arr = ["x"];
expect(isObjectWithKeys(arr, ["0"])).toBe(true);
expect(isObjectWithKeys(arr, ["length"])).toBe(true);
expect(isObjectWithKeys([], ["0"])).toBe(false);
});
});
describe("isRecord", () => {
it("returns true for plain object with string values (default)", () => {
expect(isRecord({})).toBe(true);
expect(isRecord({ a: "x", b: "y" })).toBe(true);
});
it("returns false for arrays and non-objects", () => {
expect(isRecord([] as unknown)).toBe(false);
expect(isRecord(1 as unknown)).toBe(false);
expect(isRecord(null as unknown)).toBe(false);
expect(isRecord(undefined as unknown)).toBe(false);
});
it("returns false when any value is not a string (default guards)", () => {
expect(isRecord({ a: 1 })).toBe(false);
expect(isRecord({ a: "x", b: 2 as unknown as string })).toBe(false);
});
it("respects a custom value guard (numbers)", () => {
const numberGuard = (v: unknown): v is number => typeof v === "number";
expect(isRecord({ a: 1, b: 2 }, { value: numberGuard })).toBe(true);
expect(isRecord({ a: 1, b: "x" }, { value: numberGuard })).toBe(false);
});
it("applies a custom key guard", () => {
const keyStartsWithA = (k: unknown): k is string =>
typeof k === "string" && k.startsWith("a");
expect(
isRecord({ apple: "x", aardvark: "y" }, { key: keyStartsWithA }),
).toBe(true);
expect(isRecord({ banana: "x" }, { key: keyStartsWithA })).toBe(false);
});
it("objects with only symbol keys are considered records (no enumerable string keys)", () => {
const s = Symbol("s");
const obj = { [s]: "x" } as Record<symbol, string>;
// Object.entries ignores symbol keys; guard will see zero entries
expect(isRecord(obj)).toBe(true);
});
});
+37 -1
View File
@@ -4,10 +4,20 @@
* @returns True if the value is an object, false otherwise.
* @internal
*/
function isObject(value: unknown): value is object {
export function isObject(value: unknown): value is object {
return typeof value === "object" && value !== null;
}
/**
* Checks if a value is a string.
* @param value - The value to check.
* @returns True if the value is a string, false otherwise.
* @internal
*/
export function isString(value: unknown): value is string {
return typeof value === "string";
}
/**
* Checks if a value is an object with specified keys.
* @param value - The value to check.
@@ -21,3 +31,29 @@ export function isObjectWithKeys<key extends string>(
): value is Record<key, unknown> {
return isObject(value) && keys.every((key) => key in value);
}
/**
* Checks if a value is a record with string values.
* @param value - The value to check.
* @returns True if the value is a record with string values, false otherwise.
* @internal
*/
export function isRecord<
K extends string | number | symbol = string,
V = string,
T extends Record<K, V> = Record<K, V>,
>(
value: unknown,
guards?: {
key?: (k: unknown) => k is K;
value?: (v: unknown) => v is V;
},
): value is T {
const keyGuard = guards?.key ?? isString;
const valueGuard = guards?.value ?? isString;
return (
isObject(value) &&
!Array.isArray(value) &&
Object.entries(value).every(([k, v]) => keyGuard(k) && valueGuard(v))
);
}