diff --git a/apps/dashboard/src/@/components/analytics/stat.tsx b/apps/dashboard/src/@/components/analytics/stat.tsx
index 7d856cd4c9e..abc2f062504 100644
--- a/apps/dashboard/src/@/components/analytics/stat.tsx
+++ b/apps/dashboard/src/@/components/analytics/stat.tsx
@@ -6,13 +6,16 @@ export const StatCard: React.FC<{
icon: React.FC<{ className?: string }>;
formatter?: (value: number) => string;
isPending: boolean;
-}> = ({ label, value, formatter, icon: Icon, isPending }) => {
+ emptyText?: string;
+}> = ({ label, value, formatter, icon: Icon, isPending, emptyText }) => {
return (
-
{isPending ? (
+ ) : emptyText ? (
+ {emptyText}
) : value !== undefined && formatter ? (
formatter(value)
) : (
diff --git a/apps/dashboard/src/@/components/in-app-wallet-users-content/user-wallets-table.tsx b/apps/dashboard/src/@/components/in-app-wallet-users-content/user-wallets-table.tsx
index b15b56ab62a..0ec5f42e30d 100644
--- a/apps/dashboard/src/@/components/in-app-wallet-users-content/user-wallets-table.tsx
+++ b/apps/dashboard/src/@/components/in-app-wallet-users-content/user-wallets-table.tsx
@@ -2,14 +2,25 @@
import { createColumnHelper } from "@tanstack/react-table";
import { format } from "date-fns";
-import { ArrowLeftIcon, ArrowRightIcon, UserIcon } from "lucide-react";
+import {
+ ArrowLeftIcon,
+ ArrowRightIcon,
+ DollarSignIcon,
+ RefreshCwIcon,
+ UserIcon,
+ WalletIcon,
+} from "lucide-react";
import Papa from "papaparse";
import { useCallback, useMemo, useState } from "react";
import type { ThirdwebClient } from "thirdweb";
import type { WalletUser } from "thirdweb/wallets";
+import { StatCard } from "@/components/analytics/stat";
+import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { TWTable } from "@/components/blocks/TWTable";
import { WalletAddress } from "@/components/blocks/wallet-address";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/Spinner";
import {
ToolTipLabel,
@@ -22,6 +33,10 @@ import {
useAllEmbeddedWallets,
useEmbeddedWallets,
} from "@/hooks/useEmbeddedWallets";
+import {
+ useFetchAllPortfolios,
+ type WalletPortfolioData,
+} from "@/hooks/useWalletPortfolio";
import { CopyTextButton } from "../ui/CopyTextButton";
import { AdvancedSearchInput } from "./AdvancedSearchInput";
import { SearchResults } from "./SearchResults";
@@ -74,6 +89,101 @@ export function UserWalletsTable(
| { ecosystemSlug: string; projectClientId?: never }
),
) {
+ const [activePage, setActivePage] = useState(1);
+ const [searchResults, setSearchResults] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+ const [hasSearchResults, setHasSearchResults] = useState(false);
+
+ // Portfolio state
+ const [selectedChains, setSelectedChains] = useState([1]); // Default to Ethereum
+ const [portfolioMap, setPortfolioMap] = useState<
+ Map
+ >(new Map());
+ const [portfolioLoaded, setPortfolioLoaded] = useState(false);
+ const [fetchProgress, setFetchProgress] = useState({
+ completed: 0,
+ total: 0,
+ });
+
+ const walletsQuery = useEmbeddedWallets({
+ authToken: props.authToken,
+ clientId: props.projectClientId,
+ ecosystemSlug: props.ecosystemSlug,
+ teamId: props.teamId,
+ page: activePage,
+ });
+ const wallets = walletsQuery?.data?.users || [];
+ const { mutateAsync: getAllEmbeddedWallets, isPending: isLoadingAllWallets } =
+ useAllEmbeddedWallets({
+ authToken: props.authToken,
+ });
+
+ const fetchPortfoliosMutation = useFetchAllPortfolios();
+
+ const handleFetchBalances = useCallback(async () => {
+ if (selectedChains.length === 0) return;
+
+ try {
+ // First get all wallets
+ const allWallets = await getAllEmbeddedWallets({
+ clientId: props.projectClientId,
+ ecosystemSlug: props.ecosystemSlug,
+ teamId: props.teamId,
+ });
+
+ const allAddresses = allWallets
+ .map((w) => w.wallets[0]?.address)
+ .filter((a): a is string => !!a);
+
+ if (allAddresses.length === 0) {
+ setPortfolioLoaded(true);
+ return;
+ }
+
+ setFetchProgress({ completed: 0, total: allAddresses.length });
+
+ const results = await fetchPortfoliosMutation.mutateAsync({
+ addresses: allAddresses,
+ chainIds: selectedChains,
+ authToken: props.authToken,
+ teamId: props.teamId,
+ clientId: props.projectClientId,
+ ecosystemSlug: props.ecosystemSlug,
+ onProgress: (completed, total) => {
+ setFetchProgress({ completed, total });
+ },
+ });
+
+ setPortfolioMap(results);
+ setPortfolioLoaded(true);
+ } catch (error) {
+ console.error("Failed to fetch balances:", error);
+ }
+ }, [
+ selectedChains,
+ getAllEmbeddedWallets,
+ props.projectClientId,
+ props.ecosystemSlug,
+ props.teamId,
+ props.authToken,
+ fetchPortfoliosMutation,
+ ]);
+
+ const isFetchingBalances =
+ isLoadingAllWallets || fetchPortfoliosMutation.isPending;
+
+ const aggregatedStats = useMemo(() => {
+ let fundedWallets = 0;
+ let totalValue = 0;
+ portfolioMap.forEach((data) => {
+ if (data.totalUsdValue > 0) {
+ fundedWallets++;
+ totalValue += data.totalUsdValue;
+ }
+ });
+ return { fundedWallets, totalValue };
+ }, [portfolioMap]);
+
const columns = useMemo(() => {
return [
columnHelper.accessor("id", {
@@ -129,6 +239,79 @@ export function UserWalletsTable(
header: "Address",
id: "address",
}),
+ columnHelper.accessor("wallets", {
+ id: "total_balance",
+ header: "Total Balance",
+ cell: (cell) => {
+ const address = cell.getValue()[0]?.address;
+ if (!address) return "N/A";
+ if (!portfolioLoaded) {
+ return —;
+ }
+ const data = portfolioMap.get(address);
+ if (!data) {
+ return —;
+ }
+ return (
+
+ {new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(data.totalUsdValue)}
+
+ );
+ },
+ }),
+ columnHelper.accessor("wallets", {
+ id: "tokens",
+ header: "Tokens",
+ cell: (cell) => {
+ const address = cell.getValue()[0]?.address;
+ if (!address) return "N/A";
+ if (!portfolioLoaded) {
+ return —;
+ }
+ const data = portfolioMap.get(address);
+ if (!data || data.tokens.length === 0) {
+ return None;
+ }
+
+ const topTokens = data.tokens
+ .sort((a, b) => (b.usdValue || 0) - (a.usdValue || 0))
+ .slice(0, 3)
+ .map((t) => t.symbol)
+ .join(", ");
+
+ return (
+
+
+
+ {topTokens}
+ {data.tokens.length > 3 ? "..." : ""}
+
+
+
+ {data.tokens.map((t) => (
+
+ {t.symbol}
+
+ {new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(t.usdValue || 0)}
+
+
+ ))}
+
+
+
+
+ );
+ },
+ }),
columnHelper.accessor("linkedAccounts", {
cell: (cell) => {
const email = getPrimaryEmail(cell.getValue());
@@ -201,24 +384,7 @@ export function UserWalletsTable(
id: "login_methods",
}),
];
- }, [props.client]);
-
- const [activePage, setActivePage] = useState(1);
- const [searchResults, setSearchResults] = useState([]);
- const [isSearching, setIsSearching] = useState(false);
- const [hasSearchResults, setHasSearchResults] = useState(false);
- const walletsQuery = useEmbeddedWallets({
- authToken: props.authToken,
- clientId: props.projectClientId,
- ecosystemSlug: props.ecosystemSlug,
- teamId: props.teamId,
- page: activePage,
- });
- const wallets = walletsQuery?.data?.users || [];
- const { mutateAsync: getAllEmbeddedWallets, isPending } =
- useAllEmbeddedWallets({
- authToken: props.authToken,
- });
+ }, [props.client, portfolioMap, portfolioLoaded]);
const handleSearch = async (searchType: SearchType, query: string) => {
setIsSearching(true);
@@ -315,11 +481,11 @@ export function UserWalletsTable(
@@ -330,6 +496,83 @@ export function UserWalletsTable(
) : (
<>
+ {/* Chain Selector and Fetch Button */}
+
+
+
+
+
+
+ {isFetchingBalances && (
+
+ {fetchProgress.total > 0 && (
+
+ )}
+
+ This may take a few minutes
+
+
+ )}
+
+ {portfolioLoaded && !isFetchingBalances && (
+
+ Balances loaded for {portfolioMap.size} wallets
+
+ )}
+
+
+ {/* Stats Section */}
+
+
+
+ new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(value)
+ }
+ isPending={isFetchingBalances}
+ emptyText={!portfolioLoaded ? "—" : undefined}
+ />
+
{
const responses: WalletUser[] = [];
let page = 1;
-
- while (true) {
- const res = await queryClient.fetchQuery<{
- users: WalletUser[];
- hasMore: boolean;
- }>({
- queryFn: fetchAccountList({
- clientId,
- ecosystemSlug,
- teamId,
- jwt: authToken,
- pageNumber: page,
- }),
- queryKey: embeddedWalletsKeys.embeddedWallets(
- address || "",
- clientId || ecosystemSlug || "",
- page,
- ),
- });
-
- responses.push(...res.users);
-
- if (!res.hasMore) {
- break;
+ let consecutiveFailures = 0;
+ const maxConsecutiveFailures = 3;
+
+ while (consecutiveFailures < maxConsecutiveFailures) {
+ try {
+ const res = await queryClient.fetchQuery<{
+ users: WalletUser[];
+ hasMore: boolean;
+ }>({
+ queryFn: fetchAccountList({
+ clientId,
+ ecosystemSlug,
+ teamId,
+ jwt: authToken,
+ pageNumber: page,
+ }),
+ queryKey: embeddedWalletsKeys.embeddedWallets(
+ address || "",
+ clientId || ecosystemSlug || "",
+ page,
+ ),
+ retry: 3,
+ retryDelay: (attemptIndex) =>
+ Math.min(1000 * 2 ** attemptIndex, 10000),
+ });
+
+ responses.push(...res.users);
+ consecutiveFailures = 0; // Reset on success
+
+ if (!res.hasMore) {
+ break;
+ }
+
+ page++;
+ } catch (error) {
+ consecutiveFailures++;
+ console.warn(
+ `Failed to fetch page ${page}, attempt ${consecutiveFailures}/${maxConsecutiveFailures}:`,
+ error,
+ );
+
+ if (consecutiveFailures >= maxConsecutiveFailures) {
+ // If we have some data, return it instead of throwing
+ if (responses.length > 0) {
+ console.warn(
+ `Returning partial results (${responses.length} users) after ${maxConsecutiveFailures} consecutive failures`,
+ );
+ break;
+ }
+ throw error;
+ }
+
+ // Wait before retrying the same page
+ await new Promise((resolve) =>
+ setTimeout(resolve, 1000 * consecutiveFailures),
+ );
}
-
- page++;
}
return responses;
diff --git a/apps/dashboard/src/@/hooks/useWalletPortfolio.ts b/apps/dashboard/src/@/hooks/useWalletPortfolio.ts
new file mode 100644
index 00000000000..adba6d2a992
--- /dev/null
+++ b/apps/dashboard/src/@/hooks/useWalletPortfolio.ts
@@ -0,0 +1,231 @@
+import { useMutation } from "@tanstack/react-query";
+import type { GetBalanceResult } from "thirdweb/extensions/erc20";
+import { THIRDWEB_API_HOST } from "@/constants/urls";
+
+type WalletPortfolioToken = GetBalanceResult & {
+ usdValue?: number;
+ priceUsd?: number;
+};
+
+export type WalletPortfolioData = {
+ totalUsdValue: number;
+ tokens: WalletPortfolioToken[];
+};
+
+// Retry helper with exponential backoff
+async function fetchWithRetry(
+ url: string,
+ options: RequestInit,
+ maxRetries = 3,
+): Promise {
+ let lastError: Error | null = null;
+
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ try {
+ const response = await fetch(url, options);
+
+ // Retry on rate limit (429) or server errors (5xx)
+ if (response.status === 429 || response.status >= 500) {
+ const delay = Math.min(1000 * 2 ** attempt, 10000); // Max 10s delay
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ continue;
+ }
+
+ return response;
+ } catch (e) {
+ lastError = e instanceof Error ? e : new Error(String(e));
+ // Network error - retry with backoff
+ const delay = Math.min(1000 * 2 ** attempt, 10000);
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+ }
+
+ throw lastError || new Error("Max retries exceeded");
+}
+
+// Fetch tokens for a single address on a single chain
+async function fetchTokensForChain(
+ address: string,
+ chainId: number,
+ authToken: string,
+ teamId: string,
+ clientId?: string,
+ ecosystemSlug?: string,
+): Promise {
+ try {
+ const url = new URL(`${THIRDWEB_API_HOST}/v1/wallets/${address}/tokens`);
+ url.searchParams.set("chainId", chainId.toString());
+ url.searchParams.set("limit", "50");
+ url.searchParams.set("metadata", "true");
+ url.searchParams.set("includeSpam", "false");
+ url.searchParams.set("includeNative", "true");
+ url.searchParams.set("sortBy", "usd_value");
+ url.searchParams.set("sortOrder", "desc");
+
+ const response = await fetchWithRetry(url.toString(), {
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ "x-thirdweb-team-id": teamId,
+ ...(clientId && { "x-client-id": clientId }),
+ ...(ecosystemSlug && {
+ "x-ecosystem-id": `ecosystem.${ecosystemSlug}`,
+ }),
+ },
+ });
+
+ if (!response.ok) {
+ return [];
+ }
+
+ const data = await response.json();
+ const rawTokens = data.result?.tokens || [];
+
+ return rawTokens.map(
+ (t: {
+ balance: string;
+ decimals: number;
+ name: string;
+ symbol: string;
+ token_address: string;
+ chain_id: number;
+ price_data?: { usd_value?: number; price_usd?: number };
+ }): WalletPortfolioToken => ({
+ value: BigInt(t.balance),
+ decimals: t.decimals,
+ displayValue: (Number(t.balance) / 10 ** t.decimals).toString(),
+ name: t.name,
+ symbol: t.symbol,
+ tokenAddress: t.token_address,
+ chainId: t.chain_id,
+ usdValue: t.price_data?.usd_value,
+ priceUsd: t.price_data?.price_usd,
+ }),
+ );
+ } catch (e) {
+ // Silent fail for individual chain - continue with others
+ console.warn(
+ `Failed to fetch tokens for ${address} on chain ${chainId}:`,
+ e,
+ );
+ return [];
+ }
+}
+
+// Fetch tokens for a single address on selected chains
+async function fetchWalletPortfolio(
+ address: string,
+ chainIds: number[],
+ authToken: string,
+ teamId: string,
+ clientId?: string,
+ ecosystemSlug?: string,
+): Promise {
+ // Fetch all chains concurrently for this address
+ const results = await Promise.all(
+ chainIds.map((chainId) =>
+ fetchTokensForChain(
+ address,
+ chainId,
+ authToken,
+ teamId,
+ clientId,
+ ecosystemSlug,
+ ),
+ ),
+ );
+
+ const allTokens = results.flat();
+ const totalUsdValue = allTokens.reduce(
+ (acc, token) => acc + (token.usdValue || 0),
+ 0,
+ );
+
+ return {
+ totalUsdValue,
+ tokens: allTokens,
+ };
+}
+
+// Batch fetch portfolios for all addresses with progress callback
+async function fetchAllPortfolios(
+ addresses: string[],
+ chainIds: number[],
+ authToken: string,
+ teamId: string,
+ clientId?: string,
+ ecosystemSlug?: string,
+ onProgress?: (completed: number, total: number) => void,
+): Promise