diff --git a/.changeset/fair-plants-pretend.md b/.changeset/fair-plants-pretend.md new file mode 100644 index 00000000000..21cadbdacf2 --- /dev/null +++ b/.changeset/fair-plants-pretend.md @@ -0,0 +1,67 @@ +--- +"thirdweb": minor +--- +The Connected-details button now shows USD value next to the token balance. + +### Breaking change to the AccountBalance +The formatFn props now takes in an object of type `AccountBalanceInfo`. The old `formatFn` was inflexible because it only allowed you to format the balance value. +With this new version, you have access to both the balance and symbol. +```tsx +import { AccountBalance, type AccountBalanceInfo } from "thirdweb/react"; + + `${props.symbol.toLowerCase()} ${props.balance}`} +/> +``` + +AccountBalance now supports showing the token balance in fiat value (only USD supported at the moment) +```tsx + +``` + +The `formatFn` prop now takes in an object of type `AccountBalanceInfo` and outputs a string +```tsx +import { AccountBalance, type AccountBalanceInfo } from "thirdweb/react"; + + `${props.balance}---${props.symbol.toLowerCase()}`} +/> + +// Result: 11.12---eth +``` + +### ConnectButton also supports displaying balance in fiat since it uses AccountBalance internally +```tsx + +``` + +### Export utils functions: +formatNumber: Round up a number to a certain decimal place +```tsx +import { formatNumber } from "thirdweb/utils"; +const value = formatNumber(12.1214141, 1); // 12.1 +``` + +shortenLargeNumber: Shorten the string for large value. Mainly used for the AccountBalance's `formatFn` +```tsx +import { shortenLargeNumber } from "thirdweb/utils"; +const numStr = shortenLargeNumber(1_000_000_000) +``` + +### Fix to ConnectButton +The social image of the Details button now display correctly for non-square image. + +### Massive test coverage improvement for the Connected-button components \ No newline at end of file diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts index 65496c90206..63c172b8762 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 AccountBalanceInfo, } from "../react/web/ui/prebuilt/Account/balance.js"; export { AccountName, diff --git a/packages/thirdweb/src/exports/utils.ts b/packages/thirdweb/src/exports/utils.ts index 85b00cf30cd..3ff52e20378 100644 --- a/packages/thirdweb/src/exports/utils.ts +++ b/packages/thirdweb/src/exports/utils.ts @@ -204,3 +204,6 @@ export type { AbiConstructor, AbiFallback, } from "abitype"; + +export { shortenLargeNumber } from "../utils/shortenLargeNumber.js"; +export { formatNumber } from "../utils/formatNumber.js"; diff --git a/packages/thirdweb/src/pay/convert/cryptoToFiat.test.ts b/packages/thirdweb/src/pay/convert/cryptoToFiat.test.ts index fed1b8e0aca..744af03d81a 100644 --- a/packages/thirdweb/src/pay/convert/cryptoToFiat.test.ts +++ b/packages/thirdweb/src/pay/convert/cryptoToFiat.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { TEST_CLIENT } from "~test/test-clients.js"; import { TEST_ACCOUNT_A } from "~test/test-wallets.js"; import { base } from "../../chains/chain-definitions/base.js"; @@ -92,4 +92,24 @@ describe.runIf(process.env.TW_SECRET_KEY)("Pay: crypto-to-fiat", () => { `Error: ${TEST_ACCOUNT_A.address} on chainId: ${base.id} is not a valid contract address.`, ); }); + it("should throw if response is not OK", async () => { + global.fetch = vi.fn(); + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + }); + await expect(() => + convertCryptoToFiat({ + chain: base, + fromTokenAddress: NATIVE_TOKEN_ADDRESS, + fromAmount: 1, + to: "USD", + client: TEST_CLIENT, + }), + ).rejects.toThrowError( + `Failed to fetch USD value for token (${NATIVE_TOKEN_ADDRESS}) on chainId: ${base.id}`, + ); + vi.restoreAllMocks(); + }); }); diff --git a/packages/thirdweb/src/pay/convert/cryptoToFiat.ts b/packages/thirdweb/src/pay/convert/cryptoToFiat.ts index b046fedd3f7..092e1f75805 100644 --- a/packages/thirdweb/src/pay/convert/cryptoToFiat.ts +++ b/packages/thirdweb/src/pay/convert/cryptoToFiat.ts @@ -7,6 +7,7 @@ import { getContract } from "../../contract/contract.js"; import { isAddress } from "../../utils/address.js"; import { getClientFetch } from "../../utils/fetch.js"; import { getPayConvertCryptoToFiatEndpoint } from "../utils/definitions.js"; +import type { SupportedFiatCurrency } from "./type.js"; /** * Props for the `convertCryptoToFiat` function @@ -31,7 +32,7 @@ export type ConvertCryptoToFiatParams = { * The fiat symbol. e.g "USD" * Only USD is supported at the moment. */ - to: "USD"; + to: SupportedFiatCurrency; }; /** diff --git a/packages/thirdweb/src/pay/convert/fiatToCrypto.test.ts b/packages/thirdweb/src/pay/convert/fiatToCrypto.test.ts index 3a74b4ef0d5..1d7c57ea66b 100644 --- a/packages/thirdweb/src/pay/convert/fiatToCrypto.test.ts +++ b/packages/thirdweb/src/pay/convert/fiatToCrypto.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { TEST_CLIENT } from "~test/test-clients.js"; import { TEST_ACCOUNT_A } from "~test/test-wallets.js"; import { base } from "../../chains/chain-definitions/base.js"; @@ -93,4 +93,24 @@ describe.runIf(process.env.TW_SECRET_KEY)("Pay: fiatToCrypto", () => { `Error: ${TEST_ACCOUNT_A.address} on chainId: ${base.id} is not a valid contract address.`, ); }); + it("should throw if response is not OK", async () => { + global.fetch = vi.fn(); + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + }); + await expect(() => + convertFiatToCrypto({ + chain: ethereum, + to: NATIVE_TOKEN_ADDRESS, + fromAmount: 1, + from: "USD", + client: TEST_CLIENT, + }), + ).rejects.toThrowError( + `Failed to convert USD value to token (${NATIVE_TOKEN_ADDRESS}) on chainId: 1`, + ); + vi.restoreAllMocks(); + }); }); diff --git a/packages/thirdweb/src/pay/convert/fiatToCrypto.ts b/packages/thirdweb/src/pay/convert/fiatToCrypto.ts index 7d6e6083c7f..580c81e87bc 100644 --- a/packages/thirdweb/src/pay/convert/fiatToCrypto.ts +++ b/packages/thirdweb/src/pay/convert/fiatToCrypto.ts @@ -7,6 +7,7 @@ import { getContract } from "../../contract/contract.js"; import { isAddress } from "../../utils/address.js"; import { getClientFetch } from "../../utils/fetch.js"; import { getPayConvertFiatToCryptoEndpoint } from "../utils/definitions.js"; +import type { SupportedFiatCurrency } from "./type.js"; /** * Props for the `convertFiatToCrypto` function @@ -18,7 +19,7 @@ export type ConvertFiatToCryptoParams = { * The fiat symbol. e.g: "USD" * Currently only USD is supported. */ - from: "USD"; + from: SupportedFiatCurrency; /** * The total amount of fiat to convert * e.g: If you want to convert 2 cents to USD, enter `0.02` @@ -101,7 +102,7 @@ export async function convertFiatToCrypto( const response = await getClientFetch(client)(url); if (!response.ok) { throw new Error( - `Failed to convert ${to} value to token (${to}) on chainId: ${chain.id}`, + `Failed to convert ${from} value to token (${to}) on chainId: ${chain.id}`, ); } diff --git a/packages/thirdweb/src/pay/convert/type.ts b/packages/thirdweb/src/pay/convert/type.ts new file mode 100644 index 00000000000..efb77994804 --- /dev/null +++ b/packages/thirdweb/src/pay/convert/type.ts @@ -0,0 +1,5 @@ +const SUPPORTED_FIAT_CURRENCIES = ["USD"] as const; +/** + * @internal + */ +export type SupportedFiatCurrency = (typeof SUPPORTED_FIAT_CURRENCIES)[number]; diff --git a/packages/thirdweb/src/react/core/hooks/connection/ConnectButtonProps.ts b/packages/thirdweb/src/react/core/hooks/connection/ConnectButtonProps.ts index 9a46bb44295..2a25abd8191 100644 --- a/packages/thirdweb/src/react/core/hooks/connection/ConnectButtonProps.ts +++ b/packages/thirdweb/src/react/core/hooks/connection/ConnectButtonProps.ts @@ -3,6 +3,7 @@ import type { Chain } from "../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../client/client.js"; import type { BuyWithCryptoStatus } from "../../../../pay/buyWithCrypto/getStatus.js"; import type { BuyWithFiatStatus } from "../../../../pay/buyWithFiat/getStatus.js"; +import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js"; import type { FiatProvider } from "../../../../pay/utils/commonTypes.js"; import type { AssetTabs } from "../../../../react/web/ui/ConnectWallet/screens/ViewAssets.js"; import type { PreparedTransaction } from "../../../../transaction/prepare-transaction.js"; @@ -320,6 +321,12 @@ export type ConnectButton_detailsModalOptions = { * Note: If an empty array is passed, the [View Funds] button will be hidden */ assetTabs?: AssetTabs[]; + + /** + * Show the token balance's value in fiat. + * Note: Not all tokens are resolvable to a fiat value. In that case, nothing will be shown. + */ + showBalanceInFiat?: SupportedFiatCurrency; }; /** @@ -377,6 +384,12 @@ export type ConnectButton_detailsButtonOptions = { * Use custom avatar URL for the connected wallet image in the `ConnectButton` details button, overriding ENS avatar or Blobbie icon. */ connectedAccountAvatarUrl?: string; + + /** + * Show the token balance's value in fiat. + * Note: Not all tokens are resolvable to a fiat value. In that case, nothing will be shown. + */ + showBalanceInFiat?: SupportedFiatCurrency; }; /** diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.test.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.test.tsx index 375d00308aa..73670ec29e3 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.test.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.test.tsx @@ -50,4 +50,19 @@ describe("ConnectButton", () => { ); expect(screen.getByText("Sign In")).toBeInTheDocument(); }); + + it("should render with fiat balance props", () => { + render( + , + ); + expect(screen.getByText("Connect Wallet")).toBeInTheDocument(); + }); }); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.test.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.test.tsx index b173d80f048..57714e8f444 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.test.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.test.tsx @@ -1,15 +1,39 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { FC } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { VITALIK_WALLET } from "~test/addresses.js"; import { fireEvent, render, + renderHook, screen, waitFor, -} from "../../../../../test/src/react-render.js"; -import { TEST_CLIENT } from "../../../../../test/src/test-clients.js"; -import { useActiveAccount } from "../../../core/hooks/wallets/useActiveAccount.js"; -import { DetailsModal } from "./Details.js"; +} from "~test/react-render.js"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { base } from "../../../../chains/chain-definitions/base.js"; +import { ethereum } from "../../../../chains/chain-definitions/ethereum.js"; +import { useActiveAccount } from "../../../../react/core/hooks/wallets/useActiveAccount.js"; +import { useActiveWalletChain } from "../../../../react/core/hooks/wallets/useActiveWalletChain.js"; +import { ThirdwebProvider } from "../../providers/thirdweb-provider.js"; +import { AccountProvider } from "../prebuilt/Account/provider.js"; +import { + ConnectedToSmartWallet, + ConnectedWalletDetails, + DetailsModal, + InAppWalletUserInfo, + SwitchNetworkButton, + detailsBtn_formatFiatBalanceForButton, + detailsBtn_formatTokenBalanceForButton, + useWalletDetailsModal, +} from "./Details.js"; +import en from "./locale/en.js"; import { getConnectLocale } from "./locale/getConnectLocale.js"; +/** + * Tests for the Details button and Details Modal (parts of the ConnectButton component) + */ +const queryClient = new QueryClient(); +const client = TEST_CLIENT; vi.mock("../../../core/hooks/wallets/useActiveAccount.js", () => ({ useActiveAccount: vi.fn(), })); @@ -25,7 +49,463 @@ const mockConnectOptions = {}; const mockAssetTabs: any[] = []; const mockOnDisconnect = vi.fn(); -describe("Details Component", () => { +describe("Details button", () => { + it("should render (when a wallet is connected)", () => { + const { container } = render( + + + {}} + chains={[]} + switchButton={undefined} + client={client} + connectLocale={en} + connectOptions={undefined} + /> + + , + ); + + const elements = container.getElementsByClassName("tw-connected-wallet"); + expect(!!elements.length).toBe(true); + }); + + it("should render with showBalanceInFiat", () => { + const { container } = render( + + + {}} + chains={[]} + switchButton={undefined} + client={client} + connectLocale={en} + connectOptions={undefined} + /> + + , + ); + + const elements = container.getElementsByClassName("tw-connected-wallet"); + expect(!!elements.length).toBe(true); + }); + + it("should render with custom UI from the `render` prop", () => { + const { container } = render( + + +

, + }} + detailsModal={undefined} + supportedTokens={undefined} + supportedNFTs={undefined} + onDisconnect={() => {}} + chains={[]} + switchButton={undefined} + client={client} + connectLocale={en} + connectOptions={undefined} + /> + + , + ); + + const element = container.querySelector("p.thirdweb_tw"); + expect(element).not.toBe(null); + }); + + it("should render style properly", () => { + const { container } = render( + + + {}} + chains={[]} + switchButton={undefined} + client={client} + connectLocale={en} + connectOptions={undefined} + /> + + , + ); + const element = container.querySelector( + '[data-test="connected-wallet-details"]', + ); + if (!element) { + throw new Error("Details button not rendered properly"); + } + const styles = window.getComputedStyle(element); + expect(styles.color).toBe("red"); + expect(styles.width).toBe("4444px"); + }); + + it("should render the Balance section", () => { + const { container } = render( + + + {}} + chains={[]} + switchButton={undefined} + client={client} + connectLocale={en} + connectOptions={undefined} + /> + + , + ); + + const elements = container.getElementsByClassName( + "tw-connected-wallet__balance", + ); + expect(!!elements.length).toBe(true); + }); + + it("should render the Address section if detailsButton.connectedAccountName is not passed", () => { + const { container } = render( + + + {}} + chains={[]} + switchButton={undefined} + client={client} + connectLocale={en} + connectOptions={undefined} + /> + + , + ); + + const elements = container.getElementsByClassName( + "tw-connected-wallet__address", + ); + expect(!!elements.length).toBe(true); + }); + + it("should NOT render the Address section if detailsButton.connectedAccountName is passed", () => { + const { container } = render( + + + {}} + chains={[]} + switchButton={undefined} + client={client} + connectLocale={en} + connectOptions={undefined} + /> + + , + ); + + const elements = container.getElementsByClassName( + "tw-connected-wallet__address", + ); + expect(elements.length).toBe(1); + expect(elements[0]?.innerHTML).toBe("test name"); + }); + + it("should render a custom img if detailsButton?.connectedAccountAvatarUrl is passed", () => { + const { container } = render( + + + {}} + chains={[]} + switchButton={undefined} + client={client} + connectLocale={en} + connectOptions={undefined} + /> + + , + ); + + const elements = container.getElementsByTagName("img"); + expect(elements.length).toBe(1); + expect(elements[0]?.src).toBe("https://thirdweb.com/cat.png"); + }); + + it("should render AccountAvatar if no custom image is passed", () => { + const { container } = render( + + + {}} + chains={[]} + switchButton={undefined} + client={client} + connectLocale={en} + connectOptions={undefined} + /> + + , + ); + + const elements = container.getElementsByClassName( + "tw-connected-wallet__account_avatar", + ); + expect(elements.length).toBe(1); + }); + + it("should render the SwitchNetworkButton if chain is mismatched", () => { + vi.mock( + "../../../../react/core/hooks/wallets/useActiveWalletChain.js", + () => ({ + useActiveWalletChain: vi.fn(), + }), + ); + vi.mocked(useActiveWalletChain).mockReturnValue(base); + const { container } = render( + + + {}} + chains={[]} + switchButton={{ + style: { + color: "red", + }, + className: "thirdwebSwitchBtn", + label: "switchbtn", + }} + client={client} + connectLocale={en} + connectOptions={undefined} + chain={ethereum} + /> + + , + ); + const element = container.querySelector( + "button.tw-connect-wallet--switch-network", + ); + expect(element).not.toBe(null); + const element2 = container.querySelector("button.thirdwebSwitchBtn"); + expect(element2).not.toBe(null); + expect(element && element.innerHTML === "switchbtn").toBe(true); + vi.resetAllMocks(); + }); + + it("should render the fiat value properly", () => { + expect( + detailsBtn_formatFiatBalanceForButton({ balance: 12.9231, symbol: "$" }), + ).toBe(" ($13)"); + }); + + it("should render the token balance properly", () => { + expect( + detailsBtn_formatTokenBalanceForButton({ + balance: 12.923111, + symbol: "ETH", + }), + ).toBe("12.9231 ETH"); + }); +}); + +const thirdwebWrapper: FC = ({ children }: React.PropsWithChildren) => ( + {children} +); + +/** + * useWalletDetailsModal + */ +describe("useWalletDetailsModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return an object with an open function", () => { + const { result } = renderHook(() => useWalletDetailsModal(), { + wrapper: thirdwebWrapper, + }); + expect(result.current).toHaveProperty("open"); + expect(typeof result.current.open).toBe("function"); + }); + + it("should throw an error when opening modal without a connected wallet", () => { + const { result } = renderHook(() => useWalletDetailsModal(), { + wrapper: thirdwebWrapper, + }); + + expect(() => + result.current.open({ + client, + }), + ).toThrow("Wallet is not connected."); + }); +}); + +/** + * SwitchNetworkButton + */ +describe("SwitchNetworkButton", () => { + it("should render a default button", () => { + const { container } = render( + , + ); + const element = container.querySelector( + "button.tw-connect-wallet--switch-network", + ); + expect(element).not.toBe(null); + }); + + it("should apply the style properly", () => { + const { container } = render( + , + ); + const element = container.querySelector( + "button.tw-connect-wallet--switch-network", + ); + if (!element) { + throw new Error("Failed to render SwitchNetworkButton"); + } + const styles = window.getComputedStyle(element); + expect(styles.color).toBe("red"); + expect(styles.width).toBe("4444px"); + }); + + it("should apply the className properly", () => { + const { container } = render( + , + ); + const element = container.querySelector("button.thirdwebRocks"); + expect(element).not.toBe(null); + }); + + it("should render button's text with locale.switchNetwork by default", () => { + const { container } = render( + , + ); + const element = container.querySelector( + "button.tw-connect-wallet--switch-network", + ); + if (!element) { + throw new Error("Failed to render SwitchNetworkButton"); + } + expect(element.innerHTML).toBe(en.switchNetwork); + }); + + it("should render `switchNetworkBtnTitle` properly", () => { + const { container } = render( + , + ); + const element = container.querySelector( + "button.tw-connect-wallet--switch-network", + ); + if (!element) { + throw new Error("Failed to render SwitchNetworkButton"); + } + expect(element.innerHTML).toBe("cat"); + }); +}); + +describe("ConnectedToSmartWallet", () => { + it("should render nothing since no active wallet exists in default test env", () => { + const { container } = render( + + + , + ); + // no smart wallet exists in this env so this component should render null + const element = container.querySelector("span"); + expect(element).toBe(null); + }); +}); + +describe("InAppWalletUserInfo", () => { + it("should render a Skeleton since no active wallet exists in default test env", () => { + const { container } = render( + + + , + ); + // no smart wallet exists in this env so this component should render null + const element = container.querySelector( + "div.InAppWalletUserInfo__skeleton", + ); + expect(element).not.toBe(null); + }); +}); + +describe("Details Modal", () => { beforeEach(() => { // Mock the animate method HTMLDivElement.prototype.animate = vi.fn().mockReturnValue({ diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx index 03ba1500de9..6aa59573a4d 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx @@ -14,10 +14,10 @@ import { trackPayEvent } from "../../../../analytics/track/pay.js"; import type { Chain } from "../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../client/client.js"; import { getContract } from "../../../../contract/contract.js"; +import type { SupportedFiatCurrency } from "../../../../pay/convert/type.js"; import { getLastAuthProvider } from "../../../../react/core/utils/storage.js"; import { shortenAddress } from "../../../../utils/address.js"; import { isContractDeployed } from "../../../../utils/bytecode/is-contract-deployed.js"; -import { formatNumber } from "../../../../utils/formatNumber.js"; import { webLocalStorage } from "../../../../utils/storage/webStorage.js"; import { isEcosystemWallet } from "../../../../wallets/ecosystem/is-ecosystem-wallet.js"; import type { Ecosystem } from "../../../../wallets/in-app/core/wallet/types.js"; @@ -86,7 +86,12 @@ 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 AccountBalanceInfo, + formatAccountFiatBalance, + formatAccountTokenBalance, +} 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"; @@ -144,7 +149,6 @@ export const ConnectedWalletDetails: React.FC<{ }> = (props) => { const { connectLocale: locale, client } = props; const setRootEl = useContext(SetRootElementContext); - const activeAccount = useActiveAccount(); const walletChain = useActiveWalletChain(); function closeModal() { @@ -196,6 +200,9 @@ export const ConnectedWalletDetails: React.FC<{ const combinedClassName = `${TW_CONNECTED_WALLET} ${props.detailsButton?.className || ""}`; + const tokenAddress = + props.detailsButton?.displayBalanceToken?.[Number(walletChain?.id)]; + return ( ) : ( - activeAccount && ( - } - fallbackComponent={} - queryOptions={{ - refetchOnWindowFocus: false, - refetchOnMount: false, - }} - /> - ) + + } + fallbackComponent={ + + } + queryOptions={{ + refetchOnWindowFocus: false, + refetchOnMount: false, + }} + style={{ + height: "100%", + width: "100%", + objectFit: "cover", + }} + /> )} - } - fallbackComponent={} - formatFn={formatBalanceOnButton} - tokenAddress={ - props.detailsButton?.displayBalanceToken?.[ - Number(walletChain?.id) - ] - } - /> + {props.detailsButton?.showBalanceInFiat ? ( + <> + + } + fallbackComponent={ + + } + tokenAddress={tokenAddress} + /> + + } + /> + + ) : ( + } + fallbackComponent={} + formatFn={detailsBtn_formatTokenBalanceForButton} + tokenAddress={tokenAddress} + /> + )} @@ -292,7 +338,25 @@ export const ConnectedWalletDetails: React.FC<{ }; /** - * @internal + * @internal Exported for tests + */ +export function detailsBtn_formatFiatBalanceForButton( + props: AccountBalanceInfo, +) { + return ` (${formatAccountFiatBalance({ ...props, decimals: 0 })})`; +} + +/** + * @internal Exported for test + */ +export function detailsBtn_formatTokenBalanceForButton( + props: AccountBalanceInfo, +) { + return `${formatAccountTokenBalance({ ...props, decimals: props.balance < 1 ? 5 : 4 })}`; +} + +/** + * @internal Exported for tests only */ export function DetailsModal(props: { client: ThirdwebClient; @@ -310,6 +374,7 @@ export function DetailsModal(props: { displayBalanceToken?: Record; connectOptions: DetailsModalConnectOptions | undefined; assetTabs?: AssetTabs[]; + showBalanceInFiat?: SupportedFiatCurrency; }) { const [screen, setScreen] = useState("main"); const { disconnect } = useDisconnect(); @@ -386,15 +451,44 @@ export function DetailsModal(props: { {chainNameQuery.name || `Unknown chain #${walletChain?.id}`} - } - loadingComponent={} - formatFn={(num: number) => formatNumber(num, 9)} - chain={walletChain} - tokenAddress={ - props.displayBalanceToken?.[Number(walletChain?.id)] - } - /> + {props.showBalanceInFiat ? ( + <> + } + loadingComponent={} + chain={walletChain} + tokenAddress={ + props.displayBalanceToken?.[Number(walletChain?.id)] + } + formatFn={(props: AccountBalanceInfo) => + formatAccountTokenBalance({ ...props, decimals: 7 }) + } + />{" "} + } + chain={walletChain} + tokenAddress={ + props.displayBalanceToken?.[Number(walletChain?.id)] + } + formatFn={(props: AccountBalanceInfo) => + ` (${formatAccountFiatBalance({ ...props, decimals: 3 })})` + } + showBalanceInFiat="USD" + /> + + ) : ( + } + loadingComponent={} + formatFn={(props: AccountBalanceInfo) => + formatAccountTokenBalance({ ...props, decimals: 7 }) + } + chain={walletChain} + tokenAddress={ + props.displayBalanceToken?.[Number(walletChain?.id)] + } + /> + )} )} @@ -446,6 +540,11 @@ export function DetailsModal(props: { } fallbackComponent={} + style={{ + height: "100%", + width: "100%", + objectFit: "cover", + }} /> ) )} @@ -479,7 +578,7 @@ export function DetailsModal(props: { ); let content = ( -

+
{ const theme = useCustomTheme(); return { @@ -1039,7 +1134,10 @@ const StyledChevronRightIcon = /* @__PURE__ */ styled( }; }); -function ConnectedToSmartWallet(props: { +/** + * @internal Exported for test + */ +export function ConnectedToSmartWallet(props: { client: ThirdwebClient; connectLocale: ConnectLocale; }) { @@ -1098,7 +1196,10 @@ function ConnectedToSmartWallet(props: { return null; } -function InAppWalletUserInfo(props: { +/** + * @internal Exported for tests + */ +export function InAppWalletUserInfo(props: { client: ThirdwebClient; locale: ConnectLocale; }) { @@ -1185,13 +1286,19 @@ function InAppWalletUserInfo(props: { ); } - return ; + return ( + + ); } /** - * @internal + * @internal Exported for tests */ -function SwitchNetworkButton(props: { +export function SwitchNetworkButton(props: { style?: React.CSSProperties; className?: string; switchNetworkBtnTitle?: string; @@ -1501,6 +1608,12 @@ export type UseWalletDetailsModalOptions = { * Note: If an empty array is passed, the [View Funds] button will be hidden */ assetTabs?: AssetTabs[]; + + /** + * Show the token balance's value in fiat. + * Note: Not all tokens are resolvable to a fiat value. In that case, nothing will be shown. + */ + showBalanceInFiat?: SupportedFiatCurrency; }; /** diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/PrivateKey.test.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/PrivateKey.test.tsx new file mode 100644 index 00000000000..2366271cccf --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/PrivateKey.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { render } from "~test/react-render.js"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { createWallet } from "../../../../../wallets/create-wallet.js"; +import en from "../locale/en.js"; +import { PrivateKey } from "./PrivateKey.js"; + +const client = TEST_CLIENT; + +describe("PrivateKey screen", () => { + it("should render the iframe", () => { + const { container } = render( + {}} + client={client} + connectLocale={en} + theme="dark" + wallet={createWallet("io.metamask")} + />, + ); + + const iframe = container.querySelector("iframe"); + expect(iframe).not.toBe(null); + }); + + it("should throw an error if no wallet is specified", () => { + expect(() => + render( + {}} + client={client} + connectLocale={en} + theme="dark" + />, + ), + ).toThrowError("[PrivateKey] No wallet found"); + }); + + it("should render the modal title", () => { + const { container } = render( + {}} + client={client} + connectLocale={en} + theme="dark" + wallet={createWallet("io.metamask")} + />, + ); + + const element = container.querySelector("h2"); + expect(element).not.toBe(null); + expect(element?.innerHTML).toBe(en.manageWallet.exportPrivateKey); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ReceiveFunds.test.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ReceiveFunds.test.tsx new file mode 100644 index 00000000000..b644e185ae8 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ReceiveFunds.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { render } from "~test/react-render.js"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import en from "../locale/en.js"; +import { ReceiveFunds } from "./ReceiveFunds.js"; + +const client = TEST_CLIENT; + +describe("ReceiveFunds screen", () => { + it("should render a title with locale.title", () => { + const { container } = render( + {}} client={client} connectLocale={en} />, + ); + const element = container.querySelector("h2"); + expect(element).not.toBe(null); + expect(element?.innerHTML).toBe(en.receiveFundsScreen.title); + }); + + it("should render a span with locale.instruction", () => { + const { container } = render( + {}} client={client} connectLocale={en} />, + ); + const element = container.querySelector( + "span.receive_fund_screen_instruction", + ); + expect(element).not.toBe(null); + expect(element?.innerHTML).toBe(en.receiveFundsScreen.instruction); + }); + + it("should render the CopyIcon", () => { + const { container } = render( + {}} client={client} connectLocale={en} />, + ); + const element = container.querySelector("svg.tw-copy-icon"); + expect(element).not.toBe(null); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ReceiveFunds.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ReceiveFunds.tsx index 04cc4bb071e..56f7b82cb87 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ReceiveFunds.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/ReceiveFunds.tsx @@ -70,7 +70,12 @@ export function ReceiveFunds(props: { - + {locale.instruction} diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SendFunds.test.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SendFunds.test.tsx new file mode 100644 index 00000000000..b3abe942c31 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SendFunds.test.tsx @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { render } from "~test/react-render.js"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { TEST_ACCOUNT_A } from "~test/test-wallets.js"; +import { base } from "../../../../../chains/chain-definitions/base.js"; +import { useActiveWalletChain } from "../../../../../react/core/hooks/wallets/useActiveWalletChain.js"; +import en from "../locale/en.js"; +import { SendFundsForm } from "./SendFunds.js"; + +const client = TEST_CLIENT; + +describe("SendFunds screen", () => { + it("should render a title with locale.title", () => { + vi.mock( + "../../../../../react/core/hooks/wallets/useActiveWalletChain.js", + () => ({ + useActiveWalletChain: vi.fn(), + }), + ); + vi.mocked(useActiveWalletChain).mockReturnValue(base); + const { container } = render( + {}} + receiverAddress={TEST_ACCOUNT_A.address} + setReceiverAddress={() => {}} + amount={"1"} + setAmount={() => {}} + onBack={() => {}} + client={client} + connectLocale={en} + />, + ); + const element = container.querySelector("h2"); + expect(element).not.toBe(null); + expect(element?.innerHTML).toBe(en.sendFundsScreen.title); + vi.resetAllMocks(); + }); + + it("SendFundsForm should render the send button", () => { + vi.mock( + "../../../../../react/core/hooks/wallets/useActiveWalletChain.js", + () => ({ + useActiveWalletChain: vi.fn(), + }), + ); + vi.mocked(useActiveWalletChain).mockReturnValue(base); + const { container } = render( + {}} + receiverAddress={TEST_ACCOUNT_A.address} + setReceiverAddress={() => {}} + amount={"1"} + setAmount={() => {}} + onBack={() => {}} + client={client} + connectLocale={en} + />, + ); + const element = container.querySelector( + "button.tw-sendfunds-screen-send-button", + ); + expect(element?.innerHTML).toBe(en.sendFundsScreen.submitButton); + vi.resetAllMocks(); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SendFunds.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SendFunds.tsx index cd12d481360..37f6c899819 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SendFunds.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/SendFunds.tsx @@ -107,9 +107,9 @@ export function SendFunds(props: { } /** - * @internal + * @internal Exported for tests */ -function SendFundsForm(props: { +export function SendFundsForm(props: { onTokenSelect: () => void; token: ERC20OrNativeToken; receiverAddress: string; @@ -321,6 +321,7 @@ function SendFundsForm(props: { fullWidth variant="accent" type="submit" + className="tw-sendfunds-screen-send-button" onClick={async () => { if (!receiverAddress || !amount) { return; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/StartScreen.test.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/StartScreen.test.tsx new file mode 100644 index 00000000000..d08244660ab --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/StartScreen.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { render } from "~test/react-render.js"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import en from "../locale/en.js"; +import { StartScreen } from "./StartScreen.js"; + +const client = TEST_CLIENT; + +describe("StartScreen", () => { + it("should render an image for the welcome screen", () => { + const { container } = render( + , + ); + + const img = container.querySelector("img"); + expect(img).not.toBe(null); + expect(img?.src).toBe("https://cat.png/"); + expect(img?.width).toBe(100); + }); + + it("should render new-to-wallet link", () => { + const { container } = render( + , + ); + + const a = container.querySelector("a"); + expect(a).not.toBe(null); + expect(a?.href).toBe("https://blog.thirdweb.com/web3-wallet/"); + }); + + it("should render an svg icon if a custom image is not passed", () => { + const { container } = render( + , + ); + const svg = container.querySelector("svg"); + expect(svg).not.toBe(null); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/formatTokenBalance.test.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/formatTokenBalance.test.ts new file mode 100644 index 00000000000..0378f7b5c28 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/formatTokenBalance.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { formatTokenBalance } from "./formatTokenBalance.js"; + +describe("formatTokenBalance", () => { + const mockBalanceData = { + symbol: "ETH", + name: "Ethereum", + decimals: 18, + displayValue: "1.23456789", + }; + + it("formats balance with symbol by default", () => { + const result = formatTokenBalance(mockBalanceData); + expect(result).toBe("1.23457 ETH"); + }); + + it("formats balance without symbol when showSymbol is false", () => { + const result = formatTokenBalance(mockBalanceData, false); + expect(result).toBe("1.23457"); + }); + + it("respects custom decimal places", () => { + const result = formatTokenBalance(mockBalanceData, true, 3); + expect(result).toBe("1.235 ETH"); + }); + + it("handles zero balance", () => { + const zeroBalance = { ...mockBalanceData, displayValue: "0" }; + const result = formatTokenBalance(zeroBalance); + expect(result).toBe("0 ETH"); + }); + + it("handles very small numbers", () => { + const smallBalance = { ...mockBalanceData, displayValue: "0.0000001" }; + const result = formatTokenBalance(smallBalance); + expect(result).toBe("0.00001 ETH"); + }); + + it("handles large numbers", () => { + const largeBalance = { ...mockBalanceData, displayValue: "1234567.89" }; + const result = formatTokenBalance(largeBalance); + expect(result).toBe("1234567.89 ETH"); + }); + + it("rounds up for very small non-zero values", () => { + const tinyBalance = { ...mockBalanceData, displayValue: "0.000000001" }; + const result = formatTokenBalance(tinyBalance, true, 8); + expect(result).toBe("1e-8 ETH"); + }); + + it("handles different token symbols", () => { + const usdcBalance = { + ...mockBalanceData, + symbol: "USDC", + displayValue: "100.5", + }; + const result = formatTokenBalance(usdcBalance); + expect(result).toBe("100.5 USDC"); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/nativeToken.test.ts b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/nativeToken.test.ts new file mode 100644 index 00000000000..cf16c579ef1 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/nativeToken.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { + NATIVE_TOKEN, + type NativeToken, + isNativeToken, +} from "./nativeToken.js"; // Replace with the actual file name// Assuming this is defined in a constants file + +describe("isNativeToken", () => { + it("should return true for NATIVE_TOKEN", () => { + expect(isNativeToken(NATIVE_TOKEN)).toBe(true); + }); + + it("should return true for an object with nativeToken property", () => { + const token: NativeToken = { nativeToken: true }; + expect(isNativeToken(token)).toBe(true); + }); + + it("should return true for a token with the native token address", () => { + const token = { address: NATIVE_TOKEN_ADDRESS }; + expect(isNativeToken(token)).toBe(true); + }); + + it("should return true for a token with the native token address in uppercase", () => { + const token = { address: NATIVE_TOKEN_ADDRESS.toUpperCase() }; + expect(isNativeToken(token)).toBe(true); + }); + + it("should return false for a non-native token", () => { + const token = { address: "0x1234567890123456789012345678901234567890" }; + expect(isNativeToken(token)).toBe(false); + }); + + it("should return false for an empty object", () => { + expect(isNativeToken({})).toBe(false); + }); + + it("should return false for a token with a similar but incorrect address", () => { + const token = { address: `${NATIVE_TOKEN_ADDRESS.slice(0, -1)}0` }; + expect(isNativeToken(token)).toBe(false); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/components/CopyIcon.tsx b/packages/thirdweb/src/react/web/ui/components/CopyIcon.tsx index eddb1ae3e1c..d788446bb1a 100644 --- a/packages/thirdweb/src/react/web/ui/components/CopyIcon.tsx +++ b/packages/thirdweb/src/react/web/ui/components/CopyIcon.tsx @@ -38,7 +38,11 @@ export const CopyIcon: React.FC<{ flex="row" center="both" > - {showCheckIcon ? : } + {showCheckIcon ? ( + + ) : ( + + )}
diff --git a/packages/thirdweb/src/react/web/ui/components/Skeleton.tsx b/packages/thirdweb/src/react/web/ui/components/Skeleton.tsx index 34df44e903a..27da4359721 100644 --- a/packages/thirdweb/src/react/web/ui/components/Skeleton.tsx +++ b/packages/thirdweb/src/react/web/ui/components/Skeleton.tsx @@ -11,6 +11,7 @@ export const Skeleton: React.FC<{ height: string; width?: string; color?: keyof Theme["colors"]; + className?: string; }> = (props) => { return ( ); }; 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..c577c982c2c 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,54 +1,185 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; -import { ANVIL_CHAIN } from "~test/chains.js"; -import { render, screen, waitFor } from "~test/react-render.js"; +import { VITALIK_WALLET } from "~test/addresses.js"; +import { render } 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 { ethereum } from "../../../../../chains/chain-definitions/ethereum.js"; +import { sepolia } from "../../../../../chains/chain-definitions/sepolia.js"; +import { defineChain } from "../../../../../chains/utils.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { + AccountBalance, + formatAccountFiatBalance, + formatAccountTokenBalance, + loadAccountBalance, +} from "./balance.js"; import { AccountProvider } from "./provider.js"; +const queryClient = new QueryClient(); + 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, + it("should render", async () => { + const { container } = render( + + + } + fallbackComponent={} + formatFn={() => "nope"} + /> + + , + ); + + const spans = container.getElementsByTagName("span"); + expect(spans).toHaveLength(1); + }); + + it("`loadAccountBalance` should fetch the native balance properly", async () => { + const result = await loadAccountBalance({ client: TEST_CLIENT, - address: TEST_ACCOUNT_A.address, + chain: ethereum, + address: VITALIK_WALLET, }); - render( - - - , + expect(Number.isNaN(result.balance)).toBe(false); + expect(result.symbol).toBe("ETH"); + }); + + it("`loadAccountBalance` should fetch the token balance properly", async () => { + const result = await loadAccountBalance({ + client: TEST_CLIENT, + chain: ethereum, + address: VITALIK_WALLET, + // USDC + tokenAddress: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + }); + + expect(Number.isNaN(result.balance)).toBe(false); + expect(result.symbol).toBe("USDC"); + }); + + it("`loadAccountBalance` should fetch the fiat balance properly", async () => { + const result = await loadAccountBalance({ + client: TEST_CLIENT, + chain: ethereum, + address: VITALIK_WALLET, + showBalanceInFiat: "USD", + }); + + expect(Number.isNaN(result.balance)).toBe(false); + expect(result.symbol).toBe("$"); + }); + + it("`loadAccountBalance` should throw if `chain` is not passed", async () => { + await expect(() => + loadAccountBalance({ client: TEST_CLIENT, address: VITALIK_WALLET }), + ).rejects.toThrowError("chain is required"); + }); + + it("`loadAccountBalance` should throw if `tokenAddress` is mistakenly passed as native token", async () => { + await expect(() => + loadAccountBalance({ + client: TEST_CLIENT, + address: VITALIK_WALLET, + tokenAddress: NATIVE_TOKEN_ADDRESS, + chain: ethereum, + }), + ).rejects.toThrowError( + `Invalid tokenAddress - cannot be ${NATIVE_TOKEN_ADDRESS}`, + ); + }); + + it("`loadAccountBalance` should throw if `address` is not a valid evm address", async () => { + await expect(() => + loadAccountBalance({ + client: TEST_CLIENT, + address: "haha", + chain: ethereum, + }), + ).rejects.toThrowError("Invalid wallet address. Expected an EVM address"); + }); + + it("`loadAccountBalance` should throw if `tokenAddress` is passed but is not a valid evm address", async () => { + await expect(() => + loadAccountBalance({ + client: TEST_CLIENT, + address: VITALIK_WALLET, + tokenAddress: "haha", + chain: ethereum, + }), + ).rejects.toThrowError( + "Invalid tokenAddress. Expected an EVM contract address", + ); + }); + + it("`formatAccountTokenBalance` should display a rounded-up value + symbol", () => { + expect( + formatAccountTokenBalance({ + balance: 1.1999, + symbol: "ETH", + decimals: 1, + }), + ).toBe("1.2 ETH"); + }); + + it("`formatAccountFiatBalance` should display fiat symbol followed by a rounded-up fiat value", () => { + expect( + formatAccountFiatBalance({ balance: 55.001, symbol: "$", decimals: 0 }), + ).toBe("$55"); + }); + + it("`loadAccountBalance` should throw if failed to load tokenBalance (native token)", async () => { + await expect(() => + loadAccountBalance({ + client: TEST_CLIENT, + address: VITALIK_WALLET, + chain: defineChain(-1), + }), + ).rejects.toThrowError( + `Failed to retrieve native token balance for address: ${VITALIK_WALLET} on chainId:-1`, ); + }); - waitFor(() => - expect( - screen.getByText(roundTo1Decimal(Number(balance.displayValue)), { - exact: true, - selector: "span", - }), - ).toBeInTheDocument(), + it("`loadAccountBalance` should throw if failed to load tokenBalance (erc20 token)", async () => { + await expect(() => + loadAccountBalance({ + client: TEST_CLIENT, + address: VITALIK_WALLET, + chain: defineChain(-1), + tokenAddress: "0xFfEBd97b29AD3b2BecF8E554e4a638A56C6Bbd59", + }), + ).rejects.toThrowError( + `Failed to retrieve token: 0xFfEBd97b29AD3b2BecF8E554e4a638A56C6Bbd59 balance for address: ${VITALIK_WALLET} on chainId:-1`, ); }); - it("should fallback properly if failed to load", () => { - render( - - oops} - /> - , + it("if fetching fiat value then it should throw if failed to resolve (native token)", async () => { + await expect(() => + loadAccountBalance({ + client: TEST_CLIENT, + address: VITALIK_WALLET, + chain: sepolia, + showBalanceInFiat: "USD", + }), + ).rejects.toThrowError( + `Failed to resolve fiat value for native token on chainId: ${sepolia.id}`, ); + }); - waitFor(() => - expect( - screen.getByText("oops", { - exact: true, - selector: "span", - }), - ).toBeInTheDocument(), + it("if fetching fiat value then it should throw if failed to resolve (erc20 token)", async () => { + await expect(() => + loadAccountBalance({ + client: TEST_CLIENT, + address: VITALIK_WALLET, + chain: sepolia, + showBalanceInFiat: "USD", + // this is a random erc20 token on sepolia that vitalik's wallet owns + tokenAddress: "0xFfEBd97b29AD3b2BecF8E554e4a638A56C6Bbd59", + }), + ).rejects.toThrowError( + `Failed to resolve fiat value for token: 0xFfEBd97b29AD3b2BecF8E554e4a638A56C6Bbd59 on chainId: ${sepolia.id}`, ); }); }); 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 93ea9a3ac85..9a7ef10c2ef 100644 --- a/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.tsx +++ b/packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.tsx @@ -1,16 +1,36 @@ "use client"; import { type UseQueryOptions, useQuery } from "@tanstack/react-query"; +import type { Address } from "abitype"; import type React from "react"; import type { JSX } from "react"; import type { Chain } from "../../../../../chains/types.js"; +import type { ThirdwebClient } from "../../../../../client/client.js"; +import { NATIVE_TOKEN_ADDRESS } from "../../../../../constants/addresses.js"; +import { convertCryptoToFiat } from "../../../../../exports/pay.js"; +import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js"; import { useActiveWalletChain } from "../../../../../react/core/hooks/wallets/useActiveWalletChain.js"; -import { - type GetWalletBalanceResult, - getWalletBalance, -} from "../../../../../wallets/utils/getWalletBalance.js"; +import { isAddress } from "../../../../../utils/address.js"; +import { formatNumber } from "../../../../../utils/formatNumber.js"; +import { shortenLargeNumber } from "../../../../../utils/shortenLargeNumber.js"; +import { getWalletBalance } from "../../../../../wallets/utils/getWalletBalance.js"; import { useAccountContext } from "./provider.js"; +/** + * @component + * @wallet + */ +export type AccountBalanceInfo = { + /** + * Represents either token balance or fiat balance. + */ + balance: number; + /** + * Represents either token symbol or fiat symbol + */ + symbol: string; +}; + /** * Props for the AccountBalance component * @component @@ -33,7 +53,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: AccountBalanceInfo) => 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 +87,14 @@ export interface AccountBalanceProps * Optional `useQuery` params */ queryOptions?: Omit< - UseQueryOptions, + UseQueryOptions, "queryFn" | "queryKey" >; + + /** + * Show the token balance in a supported fiat currency (e.g "USD") + */ + showBalanceInFiat?: SupportedFiatCurrency; } /** @@ -94,18 +119,21 @@ export interface AccountBalanceProps * * * ### Format the balance (round up, shorten etc.) - * The AccountBalance component accepts a `formatFn` which takes in a number and outputs a number - * The function is used to modify the display value of the wallet balance + * The AccountBalance component accepts a `formatFn` which takes in an object of type `AccountBalanceInfo` and outputs a string + * The function is used to modify the display value of the wallet balance (either in crypto or fiat) * * ```tsx - * const roundTo1Decimal = (num: number):number => Math.round(num * 10) / 10; + * import type { AccountBalanceInfo } from "thirdweb/react"; + * import { formatNumber } from "thirdweb/utils"; * - * + * const format = (props: AccountInfoBalance):string => `${formatNumber(props.balance, 1)} ${props.symbol.toLowerCase()}` + * + * * ``` * * Result: * ```html - * 1.1 ETH + * 1.1 eth // the balance is rounded up to 1 decimal and the symbol is lowercased * ``` * * ### Show a loading sign when the balance is being fetched @@ -149,10 +177,11 @@ export interface AccountBalanceProps export function AccountBalance({ chain, tokenAddress, - formatFn, loadingComponent, fallbackComponent, queryOptions, + formatFn, + showBalanceInFiat, ...restProps }: AccountBalanceProps) { const { address, client } = useAccountContext(); @@ -160,25 +189,20 @@ export function AccountBalance({ const chainToLoad = chain || walletChain; const balanceQuery = useQuery({ queryKey: [ - "walletBalance", + "internal_account_balance", chainToLoad?.id || -1, - address || "0x0", + address, { tokenAddress }, + showBalanceInFiat, ] as const, - queryFn: async () => { - if (!chainToLoad) { - throw new Error("chain is required"); - } - if (!client) { - throw new Error("client is required"); - } - return getWalletBalance({ + queryFn: async (): Promise => + loadAccountBalance({ chain: chainToLoad, client, address, tokenAddress, - }); - }, + showBalanceInFiat, + }), retry: false, ...queryOptions, }); @@ -191,13 +215,129 @@ export function AccountBalance({ return fallbackComponent || null; } - const displayValue = formatFn - ? formatFn(Number(balanceQuery.data.displayValue)) - : balanceQuery.data.displayValue; + // Prioritize using the formatFn from users + if (formatFn) { + return {formatFn(balanceQuery.data)}; + } + + if (showBalanceInFiat) { + return ( + + {formatAccountFiatBalance({ ...balanceQuery.data, decimals: 0 })} + + ); + } return ( - {displayValue} {balanceQuery.data.symbol} + {formatAccountTokenBalance({ + ...balanceQuery.data, + decimals: balanceQuery.data.balance < 1 ? 3 : 2, + })} ); } + +/** + * @internal Exported for tests + */ +export async function loadAccountBalance(props: { + chain?: Chain; + client: ThirdwebClient; + address: Address; + tokenAddress?: Address; + showBalanceInFiat?: SupportedFiatCurrency; +}): Promise { + const { chain, client, address, tokenAddress, showBalanceInFiat } = props; + if (!chain) { + throw new Error("chain is required"); + } + + if ( + tokenAddress && + tokenAddress?.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase() + ) { + throw new Error(`Invalid tokenAddress - cannot be ${NATIVE_TOKEN_ADDRESS}`); + } + + if (!isAddress(address)) { + throw new Error("Invalid wallet address. Expected an EVM address"); + } + + if (tokenAddress && !isAddress(tokenAddress)) { + throw new Error("Invalid tokenAddress. Expected an EVM contract address"); + } + + const tokenBalanceData = await getWalletBalance({ + chain, + client, + address, + tokenAddress, + }).catch(() => undefined); + + if (!tokenBalanceData) { + throw new Error( + `Failed to retrieve ${tokenAddress ? `token: ${tokenAddress}` : "native token"} balance for address: ${address} on chainId:${chain.id}`, + ); + } + + if (showBalanceInFiat) { + const fiatData = await convertCryptoToFiat({ + fromAmount: Number(tokenBalanceData.displayValue), + fromTokenAddress: tokenAddress || NATIVE_TOKEN_ADDRESS, + to: showBalanceInFiat, + chain, + client, + }).catch(() => undefined); + + if (fiatData === undefined) { + throw new Error( + `Failed to resolve fiat value for ${tokenAddress ? `token: ${tokenAddress}` : "native token"} on chainId: ${chain.id}`, + ); + } + return { + balance: fiatData?.result, + symbol: + new Intl.NumberFormat("en", { + style: "currency", + currency: showBalanceInFiat, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }) + .formatToParts(0) + .find((p) => p.type === "currency")?.value || + showBalanceInFiat.toUpperCase(), + }; + } + + return { + balance: Number(tokenBalanceData.displayValue), + symbol: tokenBalanceData.symbol, + }; +} + +/** + * 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 + * Used internally for the Details button and the Details Modal + */ +export function formatAccountTokenBalance( + props: AccountBalanceInfo & { decimals: number }, +): string { + const formattedTokenBalance = formatNumber(props.balance, props.decimals); + return `${formattedTokenBalance} ${props.symbol}`; +} + +/** + * Used internally for the Details button and Details Modal + * @internal + */ +export function formatAccountFiatBalance( + props: AccountBalanceInfo & { decimals: number }, +) { + const num = formatNumber(props.balance, props.decimals); + // Need to keep them short to avoid UI overflow issues + const formattedFiatBalance = shortenLargeNumber(num); + return `${props.symbol}${formattedFiatBalance}`; +} diff --git a/packages/thirdweb/src/utils/formatNumber.ts b/packages/thirdweb/src/utils/formatNumber.ts index bca1d567da0..4506812fc00 100644 --- a/packages/thirdweb/src/utils/formatNumber.ts +++ b/packages/thirdweb/src/utils/formatNumber.ts @@ -1,5 +1,11 @@ /** - * @internal + * Round up a number to a certain decimal place + * @example + * ```ts + * import { formatNumber } from "thirdweb/utils"; + * const value = formatNumber(12.1214141, 1); // 12.1 + * ``` + * @utils */ export function formatNumber(value: number, decimalPlaces: number) { if (value === 0) return 0; diff --git a/packages/thirdweb/src/utils/shortenLargeNumber.test.ts b/packages/thirdweb/src/utils/shortenLargeNumber.test.ts new file mode 100644 index 00000000000..1a3d0566d86 --- /dev/null +++ b/packages/thirdweb/src/utils/shortenLargeNumber.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { shortenLargeNumber } from "./shortenLargeNumber.js"; + +describe("shortenLargeNumber", () => { + it("should not affect number below 999", () => { + expect(shortenLargeNumber(999)).toBe("999"); + }); + it("should toLocaleString number below 10000", () => { + expect(shortenLargeNumber(1000)).toBe("1,000"); + expect(shortenLargeNumber(9999)).toBe("9,999"); + }); + it("should shorten the number to `k`", () => { + expect(shortenLargeNumber(10000)).toBe("10k"); + }); + it("should shorten the number to `M`", () => { + expect(shortenLargeNumber(1_000_000)).toBe("1M"); + }); + it("should shorten the number to `B`", () => { + expect(shortenLargeNumber(1_000_000_000)).toBe("1B"); + }); + it("should shorten the number to `k`", () => { + expect(shortenLargeNumber(11100)).toBe("11.1k"); + }); + it("should shorten the number to `M`", () => { + expect(shortenLargeNumber(1_100_000)).toBe("1.1M"); + }); + it("should shorten the number to `B`", () => { + expect(shortenLargeNumber(1_100_000_001)).toBe("1.1B"); + }); +}); diff --git a/packages/thirdweb/src/utils/shortenLargeNumber.ts b/packages/thirdweb/src/utils/shortenLargeNumber.ts new file mode 100644 index 00000000000..82710a25092 --- /dev/null +++ b/packages/thirdweb/src/utils/shortenLargeNumber.ts @@ -0,0 +1,48 @@ +/** + * Shorten the string for large value + * Mainly used for + * Examples: + * 10_000 -> 10k + * 1_000_000 -> 1M + * 1_000_000_000 -> 1B + * @example + * ```ts + * import { shortenLargeNumber } from "thirdweb/utils"; + * const numStr = shortenLargeNumber(1_000_000_000, ) + * ``` + * @utils + */ +export function shortenLargeNumber(value: number) { + if (value < 1000) { + return value.toString(); + } + if (value < 10_000) { + return value.toLocaleString("en-US"); + } + if (value < 1_000_000) { + return formatLargeNumber(value, 1_000, "k"); + } + if (value < 1_000_000_000) { + return formatLargeNumber(value, 1_000_000, "M"); + } + return formatLargeNumber(value, 1_000_000_000, "B"); +} + +/** + * Shorten the string for large value (over 4 digits) + * 1000 -> 1000 + * 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; +}