From e269cddd70e3558cc3b232258ce7298658ef501b Mon Sep 17 00:00:00 2001
From: 0xFirekeeper <0xFirekeeper@gmail.com>
Date: Tue, 16 Dec 2025 06:16:13 +0700
Subject: [PATCH 1/5] Add wallet portfolio fetching and display to user table
Introduces a new hook `useWalletPortfolio` to fetch and aggregate wallet token balances and USD values across multiple chains. Updates the user wallets table to include total balance and token columns, a chain selector, a fetch balances button with progress, and summary stats for funded wallets and total value. Enhances the analytics/stat component to support empty text display.
---
.../src/@/components/analytics/stat.tsx | 5 +-
.../user-wallets-table.tsx | 284 ++++++++++++++++--
.../src/@/hooks/useWalletPortfolio.ts | 153 ++++++++++
3 files changed, 420 insertions(+), 22 deletions(-)
create mode 100644 apps/dashboard/src/@/hooks/useWalletPortfolio.ts
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..d1ec7fca29d 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,100 @@ 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,
+ client: props.client,
+ chainIds: selectedChains,
+ authToken: props.authToken,
+ 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.client,
+ 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 +238,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 +383,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 +480,11 @@ export function UserWalletsTable(
@@ -330,6 +495,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 results = await Promise.all(
+ chainIds.map(async (chainId) => {
+ try {
+ const url = new URL(
+ `https://api.thirdweb.com/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 fetch(url.toString(), {
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ "x-client-id": client.clientId,
+ },
+ });
+
+ 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) {
+ console.error(
+ `Failed to fetch tokens for ${address} on chain ${chainId}:`,
+ e,
+ );
+ return [];
+ }
+ }),
+ );
+
+ 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
+export async function fetchAllPortfolios(
+ addresses: string[],
+ client: ThirdwebClient,
+ chainIds: number[],
+ authToken: string,
+ onProgress?: (completed: number, total: number) => void,
+): Promise