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> { + const results = new Map(); + const batchSize = 10; // Process 10 addresses at a time for better throughput + let completed = 0; + + for (let i = 0; i < addresses.length; i += batchSize) { + const batch = addresses.slice(i, i + batchSize); + + // Process batch concurrently with individual error handling + const batchResults = await Promise.allSettled( + batch.map(async (address) => { + const data = await fetchWalletPortfolio( + address, + chainIds, + authToken, + teamId, + clientId, + ecosystemSlug, + ); + return { address, data }; + }), + ); + + // Only add successful results + for (const result of batchResults) { + if (result.status === "fulfilled") { + results.set(result.value.address, result.value.data); + } + } + + completed = Math.min(i + batchSize, addresses.length); + onProgress?.(completed, addresses.length); + + // Small delay between batches to avoid overwhelming the API + if (i + batchSize < addresses.length) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + return results; +} + +export function useFetchAllPortfolios() { + return useMutation({ + mutationFn: async ({ + addresses, + chainIds, + authToken, + teamId, + clientId, + ecosystemSlug, + onProgress, + }: { + addresses: string[]; + chainIds: number[]; + authToken: string; + teamId: string; + clientId?: string; + ecosystemSlug?: string; + onProgress?: (completed: number, total: number) => void; + }) => { + return fetchAllPortfolios( + addresses, + chainIds, + authToken, + teamId, + clientId, + ecosystemSlug, + onProgress, + ); + }, + }); +}