Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ storybook-static
tsconfig.tsbuildinfo
.cursor
apps/dashboard/node-compile-cache
.tmp/
4 changes: 4 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"includes": ["package.json"]
}
]
Expand Down
5 changes: 5 additions & 0 deletions packages/thirdweb/biome.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"extends": "//",
"root": false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think that extends: "//" is wrong no? that's prob why its not inheriting the root rules?

"overrides": [
{
"assist": {
Expand All @@ -10,6 +11,10 @@
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"includes": ["package.json"]
}
]
Expand Down
1 change: 1 addition & 0 deletions packages/thirdweb/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@radix-ui/react-tooltip": "1.2.7",
"@storybook/react": "9.0.15",
"@tanstack/react-query": "5.81.5",
"@thirdweb-dev/api": "workspace:*",
"@thirdweb-dev/engine": "workspace:*",
"@thirdweb-dev/insight": "workspace:*",
"@walletconnect/sign-client": "2.20.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ describe.runIf(process.env.TW_SECRET_KEY)("shared.getContractMetadata", () => {
const metadata = await getContractMetadata({
contract: USDT_CONTRACT,
});
expect(metadata).toMatchInlineSnapshot(`
{
"name": "Tether USD",
"symbol": "USDT",
}
`);

// Test the existing interface that consumers expect (from the original snapshot)
expect(metadata).toMatchObject({
name: "Tether USD",
symbol: "USDT",
});

// Ensure the required properties exist
expect(metadata).toHaveProperty("name");
expect(metadata).toHaveProperty("symbol");
expect(typeof metadata.name).toBe("string");
expect(typeof metadata.symbol).toBe("string");
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
getContractMetadata as apiGetContractMetadata,
configure,
} from "@thirdweb-dev/api";
Comment on lines +1 to +4
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Avoid global configure() races; centralize auth similar to balance util

Same concern as in balance utils: configure() is global and can race across concurrent clients. Use a shared ensureApiConfigured(options.contract.client) helper (see suggestion in getTokenBalance.ts) to dedupe and avoid cross-request credential bleed.

-  // Configure the API client
-  configure({
-    clientId: options.contract.client.clientId,
-    secretKey: options.contract.client.secretKey,
-  });
+  ensureApiConfigured(options.contract.client);

Also applies to: 32-37

🤖 Prompt for AI Agents
In packages/thirdweb/src/extensions/common/read/getContractMetadata.ts around
lines 1-4 (and also affecting lines 32-37), the file currently calls the global
configure() from @thirdweb-dev/api which can race across concurrent clients and
leak credentials; replace direct configure() usage with a centralized helper
(e.g., ensureApiConfigured(options.contract.client)) that initializes API auth
for the given client idempotently and safely, import and call that helper before
any apiGetContractMetadata calls, and remove any other global configure()
invocations so auth setup is deduplicated and scoped to the client to prevent
cross-request credential bleed.

import type { BaseTransactionOptions } from "../../../transaction/types.js";
import { fetchContractMetadata } from "../../../utils/contract/fetchContractMetadata.js";
import { contractURI } from "../__generated__/IContractMetadata/read/contractURI.js";
Expand Down Expand Up @@ -25,6 +29,72 @@ export async function getContractMetadata(
// biome-ignore lint/suspicious/noExplicitAny: TODO: fix any
[key: string]: any;
}> {
// Configure the API client
configure({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of calling configure, pass clientFetch as a custom fetch impl to each api call. Check how i do it for insight/engine hey-api packages. Also make sure we respect the thirdweb domains value for dev/prod

clientId: options.contract.client.clientId,
secretKey: options.contract.client.secretKey,
});

try {
// Try to get metadata from the API first
const response = await apiGetContractMetadata({
path: {
chainId: options.contract.chain.id,
address: options.contract.address,
},
});

if (response.data?.result?.output?.abi) {
// Extract name and symbol from ABI or devdoc/userdoc
const abi = response.data.result.output.abi;
const devdoc = response.data.result.output.devdoc;
const userdoc = response.data.result.output.userdoc;

// Try to find name and symbol from ABI functions
let contractName = "";
let contractSymbol = "";
Comment on lines +47 to +55
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Early return can emit empty name/symbol; gate return or merge with fallback

If name()/symbol() RPC calls fail, contractName/contractSymbol remain empty, yet the function returns early with empty strings. Only return early if at least one is resolved; otherwise, let the RPC/URI fallback path run.

-      return {
-        name: contractName,
-        symbol: contractSymbol,
-        abi: abi,
-        compiler: response.data.result.compiler,
-        language: response.data.result.language,
-        devdoc: devdoc,
-        userdoc: userdoc,
-      };
+      if (contractName || contractSymbol) {
+        return {
+          name: contractName,
+          symbol: contractSymbol,
+          abi,
+          compiler: response.data.result.compiler,
+          language: response.data.result.language,
+          devdoc,
+          userdoc,
+        };
+      }
+      // No name/symbol resolvable via ABI/RPC; fall through to fallback below.

Optional improvement: capture abi/devdoc/userdoc/compiler/language into a local apiExtras and merge them into the final return after the fallback so you don’t lose the API payload when taking the fallback path.

Also applies to: 71-79, 82-92

🤖 Prompt for AI Agents
In packages/thirdweb/src/extensions/common/read/getContractMetadata.ts around
lines 47-55 (and similarly at 71-79 and 82-92), the code currently performs an
early return even when contractName/contractSymbol are empty; change the logic
so you only return early if at least one of name or symbol was successfully
resolved (non-empty) — otherwise do not return and allow the RPC/URI fallback
path to run. Additionally, capture abi, devdoc, userdoc, compiler and language
into a local apiExtras object and ensure you merge apiExtras into the final
return value after the fallback path (so the API payload is preserved when
falling back).


if (Array.isArray(abi)) {
const nameFunc = abi.find(
(item: any) =>
item.type === "function" &&
item.name === "name" &&
item.inputs?.length === 0,
);
const symbolFunc = abi.find(
(item: any) =>
item.type === "function" &&
item.name === "symbol" &&
item.inputs?.length === 0,
);

if (nameFunc || symbolFunc) {
// Fall back to RPC if we found name/symbol functions in ABI
const [resolvedName, resolvedSymbol] = await Promise.all([
nameFunc ? name(options).catch(() => null) : null,
symbolFunc ? symbol(options).catch(() => null) : null,
]);
contractName = resolvedName || "";
contractSymbol = resolvedSymbol || "";
}
}

return {
name: contractName,
symbol: contractSymbol,
abi: abi,
compiler: response.data.result.compiler,
language: response.data.result.language,
devdoc: devdoc,
userdoc: userdoc,
};
}
} catch (error) {
// API failed, fall back to original implementation
console.debug("Contract metadata API failed, falling back to RPC:", error);
}

// Fallback to original RPC-based implementation
const [resolvedMetadata, resolvedName, resolvedSymbol] = await Promise.all([
contractURI(options)
.then((uri) => {
Expand All @@ -41,7 +111,6 @@ export async function getContractMetadata(
symbol(options).catch(() => null),
]);

// TODO: basic parsing?
return {
...resolvedMetadata,
name: resolvedMetadata?.name ?? resolvedName,
Expand Down
74 changes: 21 additions & 53 deletions packages/thirdweb/src/wallets/utils/getTokenBalance.test.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,45 @@
import { describe, expect, it } from "vitest";
import { ANVIL_CHAIN, FORKED_ETHEREUM_CHAIN } from "~test/chains.js";
import { TEST_CONTRACT_URI } from "~test/ipfs-uris.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { TEST_ACCOUNT_D } from "~test/test-wallets.js";
import { getContract } from "../../contract/contract.js";
import { mintTo } from "../../extensions/erc20/write/mintTo.js";
import { deployERC20Contract } from "../../extensions/prebuilts/deploy-erc20.js";
import { sendAndConfirmTransaction } from "../../transaction/actions/send-and-confirm-transaction.js";
import { baseSepolia } from "../../chains/chain-definitions/base-sepolia.js";
import { getTokenBalance } from "./getTokenBalance.js";

const account = TEST_ACCOUNT_D;
// Create a mock account for testing
const testAccount = {
address: "0x742d35Cc6645C0532b6C766684f4b4E99Bf87E8A", // Base deployer address
};

describe.runIf(process.env.TW_SECRET_KEY)("getTokenBalance", () => {
it("should work for native token", async () => {
const result = await getTokenBalance({
account,
chain: FORKED_ETHEREUM_CHAIN,
account: testAccount,
chain: baseSepolia,
client: TEST_CLIENT,
});

expect(result).toStrictEqual({
decimals: 18,
displayValue: "10000",
name: "Ether",
symbol: "ETH",
value: 10000000000000000000000n,
});
expect(result).toBeDefined();
expect(result.decimals).toBe(18);
expect(result.name).toBe("Sepolia Ether");
expect(result.symbol).toBe("ETH");
expect(result.value).toBeTypeOf("bigint");
expect(result.displayValue).toBeTypeOf("string");
});

it("should work for ERC20 token", async () => {
const erc20Address = await deployERC20Contract({
account,
chain: ANVIL_CHAIN,
client: TEST_CLIENT,
params: {
contractURI: TEST_CONTRACT_URI,
name: "",
},
type: "TokenERC20",
});
const erc20Contract = getContract({
address: erc20Address,
chain: ANVIL_CHAIN,
client: TEST_CLIENT,
});

const amount = 1000;

// Mint some tokens
const tx = mintTo({
amount,
contract: erc20Contract,
to: account.address,
});

await sendAndConfirmTransaction({
account,
transaction: tx,
});
// Use a known ERC20 token on Base Sepolia
const usdcAddress = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; // USDC on Base Sepolia

const result = await getTokenBalance({
account,
chain: ANVIL_CHAIN,
account: testAccount,
chain: baseSepolia,
client: TEST_CLIENT,
tokenAddress: erc20Address,
tokenAddress: usdcAddress,
});

const expectedDecimal = 18;
expect(result).toBeDefined();
expect(result.decimals).toBe(expectedDecimal);
expect(result.decimals).toBe(6); // USDC has 6 decimals
expect(result.symbol).toBeDefined();
expect(result.name).toBeDefined();
expect(result.value).toBe(BigInt(amount) * 10n ** BigInt(expectedDecimal));
expect(result.displayValue).toBe(amount.toString());
expect(result.value).toBeTypeOf("bigint");
expect(result.displayValue).toBeTypeOf("string");
});
});
66 changes: 34 additions & 32 deletions packages/thirdweb/src/wallets/utils/getTokenBalance.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import type { Chain } from "../../chains/types.js";
import {
getChainDecimals,
getChainNativeCurrencyName,
getChainSymbol,
} from "../../chains/utils.js";
getWalletBalance as apiGetWalletBalance,
configure,
} from "@thirdweb-dev/api";
import type { Chain } from "../../chains/types.js";
Comment on lines +2 to +4
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Global configure in a hot path can race across clients; avoid mutable global auth

configure() mutates module-global state. Concurrent calls with different client credentials can interleave and leak headers across requests. Move auth to per-request (preferred), or at least dedupe per client and detect conflicting credentials.

Apply this change locally:

-  // Configure the API client with credentials from the thirdweb client
-  configure({
-    clientId: client.clientId,
-    secretKey: client.secretKey,
-  });
+  // Ensure API auth is set for this client (no-ops if already set for same creds)
+  ensureApiConfigured(client);

Add a shared helper (place once, e.g., src/internal/api/config.ts, then import here):

// internal/api/config.ts
import { configure } from "@thirdweb-dev/api";
import type { ThirdwebClient } from "../../client/client.js";

let activeClientId: string | undefined;
let activeSecret: string | undefined;

export function ensureApiConfigured(client: ThirdwebClient): void {
  // Fast path: same creds already applied
  if (client.clientId === activeClientId && client.secretKey === activeSecret) return;

  // If different creds are already active, prefer explicitness:
  // either throw or overwrite with a warning. Choose policy; for now, overwrite.
  if (activeClientId && (client.clientId !== activeClientId || client.secretKey !== activeSecret)) {
    // Consider routing these through a logger if available
    // console.warn("Switching @thirdweb-dev/api credentials at runtime.");
  }

  configure({ clientId: client.clientId, secretKey: client.secretKey });
  activeClientId = client.clientId;
  activeSecret = client.secretKey;
}

Also applies to: 42-47

import type { ThirdwebClient } from "../../client/client.js";
import { getContract } from "../../contract/contract.js";
import { eth_getBalance } from "../../rpc/actions/eth_getBalance.js";
import { getRpcClient } from "../../rpc/rpc.js";
import { toTokens } from "../../utils/units.js";
import type { Account } from "../interfaces/wallet.js";

type GetTokenBalanceOptions = {
Expand Down Expand Up @@ -43,33 +38,40 @@ export async function getTokenBalance(
options: GetTokenBalanceOptions,
): Promise<GetTokenBalanceResult> {
const { account, client, chain, tokenAddress } = options;
// erc20 case
if (tokenAddress) {
// load balanceOf dynamically to avoid circular dependency
const { getBalance } = await import(
"../../extensions/erc20/read/getBalance.js"
);
return getBalance({

// Configure the API client with credentials from the thirdweb client
configure({
clientId: client.clientId,
secretKey: client.secretKey,
});

const response = await apiGetWalletBalance({
path: {
address: account.address,
contract: getContract({ address: tokenAddress, chain, client }),
});
},
query: {
chainId: [chain.id],
...(tokenAddress && { tokenAddress }),
},
});

if (!response.data?.result || response.data.result.length === 0) {
throw new Error("No balance data returned from API");
}
// native token case
const rpcRequest = getRpcClient({ chain, client });

const [nativeSymbol, nativeDecimals, nativeName, nativeBalance] =
await Promise.all([
getChainSymbol(chain),
getChainDecimals(chain),
getChainNativeCurrencyName(chain),
eth_getBalance(rpcRequest, { address: account.address }),
]);
// Get the first result (should match our chain)
const balanceData = response.data.result[0];

if (!balanceData) {
throw new Error("Balance data not found for the specified chain");
}

// Transform API response to match the existing GetTokenBalanceResult interface
return {
decimals: nativeDecimals,
displayValue: toTokens(nativeBalance, nativeDecimals),
name: nativeName,
symbol: nativeSymbol,
value: nativeBalance,
decimals: balanceData.decimals,
displayValue: balanceData.displayValue,
name: balanceData.name,
symbol: balanceData.symbol,
value: BigInt(balanceData.value),
};
}
Loading
Loading