diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts
index 65496c90206..880249c4261 100644
--- a/packages/thirdweb/src/exports/react.ts
+++ b/packages/thirdweb/src/exports/react.ts
@@ -221,6 +221,7 @@ export {
export {
AccountBalance,
type AccountBalanceProps,
+ type AccountBalanceFormatParams,
} from "../react/web/ui/prebuilt/Account/balance.js";
export {
AccountName,
diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx
index 23f368e7df4..f494845ad5c 100644
--- a/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx
+++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx
@@ -86,7 +86,10 @@ import { fadeInAnimation } from "../design-system/animations.js";
import { StyledButton } from "../design-system/elements.js";
import { AccountAddress } from "../prebuilt/Account/address.js";
import { AccountAvatar } from "../prebuilt/Account/avatar.js";
-import { AccountBalance } from "../prebuilt/Account/balance.js";
+import {
+ AccountBalance,
+ type AccountBalanceFormatParams,
+} from "../prebuilt/Account/balance.js";
import { AccountBlobbie } from "../prebuilt/Account/blobbie.js";
import { AccountName } from "../prebuilt/Account/name.js";
import { AccountProvider } from "../prebuilt/Account/provider.js";
@@ -278,12 +281,13 @@ export const ConnectedWalletDetails: React.FC<{
chain={walletChain}
loadingComponent={}
fallbackComponent={}
- formatFn={formatBalanceOnButton}
+ formatFn={formatAccountBalanceForButton}
tokenAddress={
props.detailsButton?.displayBalanceToken?.[
Number(walletChain?.id)
]
}
+ showFiatValue="USD"
/>
@@ -380,11 +384,12 @@ function DetailsModal(props: {
}
loadingComponent={}
- formatFn={(num: number) => formatNumber(num, 9)}
chain={walletChain}
tokenAddress={
props.displayBalanceToken?.[Number(walletChain?.id)]
}
+ formatFn={formatAccountBalanceForModal}
+ showFiatValue="USD"
/>
@@ -1006,8 +1011,70 @@ function DetailsModal(props: {
);
}
-function formatBalanceOnButton(num: number) {
- return formatNumber(num, num < 1 ? 5 : 4);
+/**
+ * Format the display balance for both crypto and fiat, in the Details button and Modal
+ * If both crypto balance and fiat balance exist, we have to keep the string very short to avoid UI issues.
+ * @internal
+ */
+function formatAccountBalanceForButton(
+ props: AccountBalanceFormatParams,
+): string {
+ if (props.fiatBalance && props.fiatSymbol) {
+ // Need to keep them short to avoid UI overflow issues
+ const formattedTokenBalance = formatNumber(props.tokenBalance, 1);
+ const formattedFiatBalance = formatFiatValue(props.fiatBalance, 0);
+ return `${formattedTokenBalance} ${props.tokenSymbol} (${props.fiatSymbol}${formattedFiatBalance})`;
+ }
+ const formattedTokenBalance = formatNumber(
+ props.tokenBalance,
+ props.tokenBalance < 1 ? 5 : 4,
+ );
+ return `${formattedTokenBalance} ${props.tokenSymbol}`;
+}
+
+function formatAccountBalanceForModal(
+ props: AccountBalanceFormatParams,
+): string {
+ if (props.fiatBalance && props.fiatSymbol) {
+ // Need to keep them short to avoid UI overflow issues
+ const formattedTokenBalance = formatNumber(props.tokenBalance, 5);
+ const formattedFiatBalance = formatFiatValue(props.fiatBalance, 4);
+ return `${formattedTokenBalance} ${props.tokenSymbol} (${props.fiatSymbol}${formattedFiatBalance})`;
+ }
+ const formattedTokenBalance = formatNumber(props.tokenBalance, 9);
+ return `${formattedTokenBalance} ${props.tokenSymbol}`;
+}
+
+function formatFiatValue(value: number, decimals: number): string {
+ const num = formatNumber(value, decimals);
+ if (num < 1000) {
+ return num.toString();
+ }
+ if (num < 1_000_000) {
+ return formatLargeNumber(num, 1000000, "k");
+ }
+ if (num < 1000000000) {
+ return formatLargeNumber(num, 1000000, "M");
+ }
+ return formatLargeNumber(num, 1000000000, "B");
+}
+
+/**
+ * Shorten the string for large value
+ * 10_000 -> 10k
+ * 1_000_000 -> 1M
+ * 1_000_000_000 -> 1B
+ */
+function formatLargeNumber(
+ value: number,
+ divisor: number,
+ suffix: "k" | "M" | "B",
+) {
+ const quotient = value / divisor;
+ if (Number.isInteger(quotient)) {
+ return Math.floor(quotient) + suffix;
+ }
+ return quotient.toFixed(1).replace(/\.0$/, "") + suffix;
}
const WalletInfoButton = /* @__PURE__ */ StyledButton((_) => {
diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.test.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.test.tsx
index 3a4df585f20..252da834f24 100644
--- a/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.test.tsx
+++ b/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.test.tsx
@@ -1,37 +1,11 @@
import { describe, expect, it } from "vitest";
-import { ANVIL_CHAIN } from "~test/chains.js";
import { render, screen, waitFor } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { TEST_ACCOUNT_A } from "~test/test-wallets.js";
-import { getWalletBalance } from "../../../../../wallets/utils/getWalletBalance.js";
import { AccountBalance } from "./balance.js";
import { AccountProvider } from "./provider.js";
describe.runIf(process.env.TW_SECRET_KEY)("AccountBalance component", () => {
- it("format the balance properly", async () => {
- const roundTo1Decimal = (num: number): number => Math.round(num * 10) / 10;
- const balance = await getWalletBalance({
- chain: ANVIL_CHAIN,
- client: TEST_CLIENT,
- address: TEST_ACCOUNT_A.address,
- });
-
- render(
-
-
- ,
- );
-
- waitFor(() =>
- expect(
- screen.getByText(roundTo1Decimal(Number(balance.displayValue)), {
- exact: true,
- selector: "span",
- }),
- ).toBeInTheDocument(),
- );
- });
-
it("should fallback properly if failed to load", () => {
render(
diff --git a/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.tsx b/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.tsx
index dafa851123f..cfd7431cf39 100644
--- a/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.tsx
+++ b/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.tsx
@@ -4,13 +4,22 @@ import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
import type React from "react";
import type { JSX } from "react";
import type { Chain } from "../../../../../chains/types.js";
+import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js";
+import { convertCryptoToFiat } from "../../../../../exports/pay.js";
import { useActiveWalletChain } from "../../../../../react/core/hooks/wallets/useActiveWalletChain.js";
-import {
- type GetWalletBalanceResult,
- getWalletBalance,
-} from "../../../../../wallets/utils/getWalletBalance.js";
+import { getWalletBalance } from "../../../../../wallets/utils/getWalletBalance.js";
import { useAccountContext } from "./provider.js";
+/**
+ * @internal
+ */
+export type AccountBalanceFormatParams = {
+ tokenBalance: number;
+ tokenSymbol: string;
+ fiatBalance?: number;
+ fiatSymbol?: string;
+};
+
/**
* Props for the AccountBalance component
* @component
@@ -33,7 +42,7 @@ export interface AccountBalanceProps
* use this function to transform the balance display value like round up the number
* Particularly useful to avoid overflowing-UI issues
*/
- formatFn?: (num: number) => number;
+ formatFn?: (props: AccountBalanceFormatParams) => string;
/**
* This component will be shown while the balance of the account is being fetched
* If not passed, the component will return `null`.
@@ -67,9 +76,11 @@ export interface AccountBalanceProps
* Optional `useQuery` params
*/
queryOptions?: Omit<
- UseQueryOptions,
+ UseQueryOptions,
"queryFn" | "queryKey"
>;
+
+ showFiatValue?: "USD";
}
/**
@@ -149,10 +160,11 @@ export interface AccountBalanceProps
export function AccountBalance({
chain,
tokenAddress,
- formatFn,
loadingComponent,
fallbackComponent,
queryOptions,
+ formatFn,
+ showFiatValue,
...restProps
}: AccountBalanceProps) {
const { address, client } = useAccountContext();
@@ -164,20 +176,61 @@ export function AccountBalance({
chainToLoad?.id || -1,
address || "0x0",
{ tokenAddress },
+ showFiatValue,
] as const,
- queryFn: async () => {
+ queryFn: async (): Promise => {
if (!chainToLoad) {
throw new Error("chain is required");
}
if (!client) {
throw new Error("client is required");
}
- return getWalletBalance({
+ const tokenBalanceData = await getWalletBalance({
chain: chainToLoad,
client,
address,
tokenAddress,
});
+
+ if (!tokenBalanceData) {
+ throw new Error(
+ `Failed to retrieve ${tokenAddress ? `token: ${tokenAddress}` : "native token"} balance for address: ${address} on chainId:${chainToLoad.id}`,
+ );
+ }
+
+ if (showFiatValue) {
+ const fiatData = await convertCryptoToFiat({
+ fromAmount: Number(tokenBalanceData.displayValue),
+ fromTokenAddress: tokenAddress || NATIVE_TOKEN_ADDRESS,
+ to: showFiatValue,
+ chain: chainToLoad,
+ client,
+ }).catch(() => undefined);
+
+ // We can never support 100% of token out there, so if something fails to resolve, it's expected
+ // in that case just return the tokenBalance and symbol
+ return {
+ tokenBalance: Number(tokenBalanceData.displayValue),
+ tokenSymbol: tokenBalanceData.symbol,
+ fiatBalance: fiatData?.result,
+ fiatSymbol: fiatData?.result
+ ? new Intl.NumberFormat("en", {
+ style: "currency",
+ currency: showFiatValue,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ })
+ .formatToParts(0)
+ .find((p) => p.type === "currency")?.value ||
+ showFiatValue.toUpperCase()
+ : undefined,
+ };
+ }
+
+ return {
+ tokenBalance: Number(tokenBalanceData.displayValue),
+ tokenSymbol: tokenBalanceData.symbol,
+ };
},
...queryOptions,
});
@@ -190,13 +243,24 @@ export function AccountBalance({
return fallbackComponent || null;
}
- const displayValue = formatFn
- ? formatFn(Number(balanceQuery.data.displayValue))
- : balanceQuery.data.displayValue;
+ if (formatFn) {
+ return {formatFn(balanceQuery.data)};
+ }
+
+ const { tokenBalance, tokenSymbol, fiatBalance, fiatSymbol } =
+ balanceQuery.data;
+
+ if (fiatBalance && fiatSymbol) {
+ return (
+
+ {`${tokenBalance} ${tokenSymbol} (${fiatSymbol}${fiatBalance})`}
+
+ );
+ }
return (
- {displayValue} {balanceQuery.data.symbol}
+ {tokenBalance} {tokenSymbol}
);
}