Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SDK] USD balance for AccountBalance #5533

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
5 changes: 5 additions & 0 deletions .changeset/fair-plants-pretend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---

Export util functions: formatNumber and shortenLargeNumber
1 change: 1 addition & 0 deletions packages/thirdweb/src/exports/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export {
export {
AccountBalance,
type AccountBalanceProps,
type AccountBalanceFormatParams,
} from "../react/web/ui/prebuilt/Account/balance.js";
export {
AccountName,
Expand Down
3 changes: 3 additions & 0 deletions packages/thirdweb/src/exports/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,6 @@ export type {
AbiConstructor,
AbiFallback,
} from "abitype";

export { shortenLargeNumber } from "../utils/shortenLargeNumber.js";
export { formatNumber } from "../utils/formatNumber.js";
25 changes: 20 additions & 5 deletions packages/thirdweb/src/react/web/ui/ConnectWallet/Details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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 { shortenLargeNumber } from "../../../../utils/shortenLargeNumber.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";
Expand Down Expand Up @@ -86,7 +87,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";
Expand Down Expand Up @@ -278,12 +282,12 @@ export const ConnectedWalletDetails: React.FC<{
chain={walletChain}
loadingComponent={<Skeleton height={fontSize.xs} width="70px" />}
fallbackComponent={<Skeleton height={fontSize.xs} width="70px" />}
formatFn={formatBalanceOnButton}
tokenAddress={
props.detailsButton?.displayBalanceToken?.[
Number(walletChain?.id)
]
}
showFiatValue="USD"
/>
</Text>
</Container>
Expand Down Expand Up @@ -380,11 +384,12 @@ function DetailsModal(props: {
<AccountBalance
fallbackComponent={<Skeleton height="1em" width="100px" />}
loadingComponent={<Skeleton height="1em" width="100px" />}
formatFn={(num: number) => formatNumber(num, 9)}
chain={walletChain}
tokenAddress={
props.displayBalanceToken?.[Number(walletChain?.id)]
}
formatFn={formatAccountBalanceForModal}
showFiatValue="USD"
/>
</Text>
</Text>
Expand Down Expand Up @@ -1006,8 +1011,18 @@ function DetailsModal(props: {
);
}

function formatBalanceOnButton(num: number) {
return formatNumber(num, num < 1 ? 5 : 4);
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 num = formatNumber(props.fiatBalance, 4);
const formattedFiatBalance = shortenLargeNumber(num);
return `${formattedTokenBalance} ${props.tokenSymbol} (${props.fiatSymbol}${formattedFiatBalance})`;
}
const formattedTokenBalance = formatNumber(props.tokenBalance, 9);
return `${formattedTokenBalance} ${props.tokenSymbol}`;
}

const WalletInfoButton = /* @__PURE__ */ StyledButton((_) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<AccountProvider address={TEST_ACCOUNT_A.address} client={TEST_CLIENT}>
<AccountBalance chain={ANVIL_CHAIN} formatFn={roundTo1Decimal} />
</AccountProvider>,
);

waitFor(() =>
expect(
screen.getByText(roundTo1Decimal(Number(balance.displayValue)), {
exact: true,
selector: "span",
}),
).toBeInTheDocument(),
);
});

it("should fallback properly if failed to load", () => {
render(
<AccountProvider address={TEST_ACCOUNT_A.address} client={TEST_CLIENT}>
Expand Down
103 changes: 90 additions & 13 deletions packages/thirdweb/src/react/web/ui/prebuilt/Account/balance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,24 @@ 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 { formatNumber } from "../../../../../utils/formatNumber.js";
import { shortenLargeNumber } from "../../../../../utils/shortenLargeNumber.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
Expand All @@ -33,7 +44,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`.
Expand Down Expand Up @@ -67,9 +78,11 @@ export interface AccountBalanceProps
* Optional `useQuery` params
*/
queryOptions?: Omit<
UseQueryOptions<GetWalletBalanceResult>,
UseQueryOptions<AccountBalanceFormatParams>,
"queryFn" | "queryKey"
>;

showFiatValue?: "USD";
}

/**
Expand Down Expand Up @@ -149,10 +162,11 @@ export interface AccountBalanceProps
export function AccountBalance({
chain,
tokenAddress,
formatFn,
loadingComponent,
fallbackComponent,
queryOptions,
formatFn,
showFiatValue,
...restProps
}: AccountBalanceProps) {
const { address, client } = useAccountContext();
Expand All @@ -164,20 +178,61 @@ export function AccountBalance({
chainToLoad?.id || -1,
address || "0x0",
{ tokenAddress },
showFiatValue,
] as const,
queryFn: async () => {
queryFn: async (): Promise<AccountBalanceFormatParams> => {
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()
graphite-app[bot] marked this conversation as resolved.
Show resolved Hide resolved
: undefined,
};
}

return {
tokenBalance: Number(tokenBalanceData.displayValue),
tokenSymbol: tokenBalanceData.symbol,
};
},
...queryOptions,
});
Expand All @@ -190,13 +245,35 @@ export function AccountBalance({
return fallbackComponent || null;
}

const displayValue = formatFn
? formatFn(Number(balanceQuery.data.displayValue))
: balanceQuery.data.displayValue;
if (formatFn) {
return <span {...restProps}>{formatFn(balanceQuery.data)}</span>;
}

return (
<span {...restProps}>
{displayValue} {balanceQuery.data.symbol}
{formatAccountBalanceForButton(balanceQuery.data)}
</span>
);
}

/**
* 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 num = formatNumber(props.fiatBalance, 0);
const formattedFiatBalance = shortenLargeNumber(num);
return `${formattedTokenBalance} ${props.tokenSymbol} (${props.fiatSymbol}${formattedFiatBalance})`;
}
const formattedTokenBalance = formatNumber(
props.tokenBalance,
props.tokenBalance < 1 ? 5 : 4,
);
return `${formattedTokenBalance} ${props.tokenSymbol}`;
}
8 changes: 7 additions & 1 deletion packages/thirdweb/src/utils/formatNumber.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
30 changes: 30 additions & 0 deletions packages/thirdweb/src/utils/shortenLargeNumber.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { shortenLargeNumber } from "./shortenLargeNumber.js";

describe("shortenLargeNumber", () => {
it("should not affect number below 10000", () => {
expect(shortenLargeNumber(1000)).toBe("1000");
});
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 not affect number below 10000", () => {
expect(shortenLargeNumber(1001)).toBe("1001");
});
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");
});
});
45 changes: 45 additions & 0 deletions packages/thirdweb/src/utils/shortenLargeNumber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* 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, )
kien-ngo marked this conversation as resolved.
Show resolved Hide resolved
* ```
* @utils
*/
export function shortenLargeNumber(value: number) {
if (value < 10_000) {
return value.toString();
}
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;
}
Loading