Wallet Dashboard
Build a complete wallet dashboard that displays SOL balance, token holdings, transaction history, and real-time updates using React and Solana web3.js.
Project Overview
Text
┌─────────────────────────────────────────────────────────────────┐
│ Wallet Dashboard Features │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SOL Balance │ │
│ │ 12.5 SOL │ │
│ │ (~$250.00) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Token Holdings │ │
│ │ ┌──────────┬──────────┬───────────┐ │ │
│ │ │ Token │ Balance │ Value │ │ │
│ │ ├──────────┼──────────┼───────────┤ │ │
│ │ │ USDC │ 500.00 │ $500.00 │ │ │
│ │ │ RAY │ 125.50 │ $62.75 │ │ │
│ │ │ SRM │ 50.00 │ $25.00 │ │ │
│ │ └──────────┴──────────┴───────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Recent Transactions │ │
│ │ ✓ Received 2.5 SOL - 2 hours ago │ │
│ │ ✓ Sent 100 USDC - 5 hours ago │ │
│ │ ✓ Swap RAY → SOL - 1 day ago │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Project Setup
Bash
# Create Next.js project
bunx create-next-app@latest wallet-dashboard --typescript --tailwind --app
cd wallet-dashboard
# Install Solana dependencies
bun add @solana/web3.js @solana/spl-token @solana/wallet-adapter-react \
@solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets \
@tanstack/react-query
# Run development server
bun dev
Project Structure
Text
wallet-dashboard/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ └── providers.tsx
├── components/
│ ├── wallet/
│ │ ├── WalletButton.tsx
│ │ ├── BalanceCard.tsx
│ │ ├── TokenList.tsx
│ │ └── TransactionHistory.tsx
│ └── ui/
│ ├── Card.tsx
│ └── Skeleton.tsx
├── hooks/
│ ├── useBalance.ts
│ ├── useTokenAccounts.ts
│ └── useTransactions.ts
└── lib/
├── solana.ts
└── utils.ts
Core Hooks
useBalance Hook
TypeScript
// hooks/useBalance.ts
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { LAMPORTS_PER_SOL } from "@solana/web3.js";
import { useQuery } from "@tanstack/react-query";
export function useBalance() {
const { connection } = useConnection();
const { publicKey } = useWallet();
return useQuery({
queryKey: ["balance", publicKey?.toBase58()],
queryFn: async () => {
if (!publicKey) return null;
const balance = await connection.getBalance(publicKey);
return {
lamports: balance,
sol: balance / LAMPORTS_PER_SOL,
};
},
enabled: !!publicKey,
refetchInterval: 10000, // Refresh every 10 seconds
});
}
useTokenAccounts Hook
TypeScript
// hooks/useTokenAccounts.ts
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { useQuery } from "@tanstack/react-query";
interface TokenAccount {
mint: string;
balance: number;
decimals: number;
uiAmount: number;
}
export function useTokenAccounts() {
const { connection } = useConnection();
const { publicKey } = useWallet();
return useQuery({
queryKey: ["tokenAccounts", publicKey?.toBase58()],
queryFn: async (): Promise<TokenAccount[]> => {
if (!publicKey) return [];
const tokenAccounts = await connection.getParsedTokenAccountsByOwner(
publicKey,
{ programId: TOKEN_PROGRAM_ID },
);
return tokenAccounts.value.map((account) => {
const info = account.account.data.parsed.info;
return {
mint: info.mint,
balance: info.tokenAmount.amount,
decimals: info.tokenAmount.decimals,
uiAmount: info.tokenAmount.uiAmount || 0,
};
});
},
enabled: !!publicKey,
refetchInterval: 30000,
});
}
useTransactions Hook
TypeScript
// hooks/useTransactions.ts
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { useQuery } from "@tanstack/react-query";
import type { ParsedTransactionWithMeta, PublicKey } from "@solana/web3.js";
interface Transaction {
signature: string;
slot: number;
timestamp: number | null;
status: "success" | "failed";
type: "sent" | "received" | "unknown";
amount?: number;
}
export function useTransactions(limit: number = 10) {
const { connection } = useConnection();
const { publicKey } = useWallet();
return useQuery({
queryKey: ["transactions", publicKey?.toBase58(), limit],
queryFn: async (): Promise<Transaction[]> => {
if (!publicKey) return [];
// Get recent signatures
const signatures = await connection.getSignaturesForAddress(publicKey, {
limit,
});
// Get transaction details
const transactions = await connection.getParsedTransactions(
signatures.map((s) => s.signature),
{ maxSupportedTransactionVersion: 0 },
);
return signatures.map((sig, i) => {
const tx = transactions[i];
return {
signature: sig.signature,
slot: sig.slot,
timestamp: sig.blockTime,
status: sig.err ? "failed" : "success",
type: determineTransactionType(tx, publicKey),
amount: extractAmount(tx, publicKey),
};
});
},
enabled: !!publicKey,
refetchInterval: 60000,
});
}
function determineTransactionType(
tx: ParsedTransactionWithMeta | null,
wallet: PublicKey,
): "sent" | "received" | "unknown" {
if (!tx?.meta) return "unknown";
const preBalances = tx.meta.preBalances;
const postBalances = tx.meta.postBalances;
// Find wallet's index in account keys
const accountIndex = tx.transaction.message.accountKeys.findIndex((key) =>
key.pubkey.equals(wallet),
);
if (accountIndex === -1) return "unknown";
const balanceChange = postBalances[accountIndex] - preBalances[accountIndex];
if (balanceChange > 0) return "received";
if (balanceChange < 0) return "sent";
return "unknown";
}
function extractAmount(
tx: ParsedTransactionWithMeta | null,
wallet: PublicKey,
): number | undefined {
if (!tx?.meta) return undefined;
const accountIndex = tx.transaction.message.accountKeys.findIndex((key) =>
key.pubkey.equals(wallet),
);
if (accountIndex === -1) return undefined;
const balanceChange = Math.abs(
tx.meta.postBalances[accountIndex] - tx.meta.preBalances[accountIndex],
);
return balanceChange / 1e9; // Convert lamports to SOL
}
Components
Balance Card
TypeScript
// components/wallet/BalanceCard.tsx
"use client";
import { useBalance } from "@/hooks/useBalance";
import { Card } from "@/components/ui/Card";
import { Skeleton } from "@/components/ui/Skeleton";
interface BalanceCardProps {
solPrice?: number;
}
export function BalanceCard({ solPrice = 20 }: BalanceCardProps) {
const { data: balance, isLoading, error } = useBalance();
if (isLoading) {
return (
<Card>
<div className="p-6">
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="h-8 w-32 mb-1" />
<Skeleton className="h-4 w-20" />
</div>
</Card>
);
}
if (error || !balance) {
return (
<Card>
<div className="p-6 text-center text-gray-500">
Connect wallet to view balance
</div>
</Card>
);
}
const usdValue = balance.sol * solPrice;
return (
<Card>
<div className="p-6">
<h3 className="text-sm font-medium text-gray-500 mb-2">
SOL Balance
</h3>
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{balance.sol.toFixed(4)} SOL
</p>
<p className="text-sm text-gray-500">
≈ ${usdValue.toFixed(2)} USD
</p>
</div>
</Card>
);
}
Token List
TypeScript
// components/wallet/TokenList.tsx
"use client";
import { useTokenAccounts } from "@/hooks/useTokenAccounts";
import { Card } from "@/components/ui/Card";
import { Skeleton } from "@/components/ui/Skeleton";
// Well-known token mints
const TOKEN_INFO: Record<string, { symbol: string; name: string }> = {
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": {
symbol: "USDC",
name: "USD Coin",
},
"Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB": {
symbol: "USDT",
name: "Tether USD",
},
"So11111111111111111111111111111111111111112": {
symbol: "wSOL",
name: "Wrapped SOL",
},
// Add more tokens as needed
};
export function TokenList() {
const { data: tokens, isLoading, error } = useTokenAccounts();
if (isLoading) {
return (
<Card>
<div className="p-6">
<h3 className="text-lg font-semibold mb-4">Token Holdings</h3>
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4 py-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1">
<Skeleton className="h-4 w-20 mb-1" />
<Skeleton className="h-3 w-16" />
</div>
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
</Card>
);
}
if (error) {
return (
<Card>
<div className="p-6 text-center text-red-500">
Error loading tokens
</div>
</Card>
);
}
const filteredTokens = tokens?.filter((t) => t.uiAmount > 0) || [];
return (
<Card>
<div className="p-6">
<h3 className="text-lg font-semibold mb-4">Token Holdings</h3>
{filteredTokens.length === 0 ? (
<p className="text-gray-500 text-center py-4">
No tokens found
</p>
) : (
<div className="divide-y dark:divide-gray-700">
{filteredTokens.map((token) => {
const info = TOKEN_INFO[token.mint];
return (
<div
key={token.mint}
className="flex items-center justify-between py-3"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-xs font-medium">
{info?.symbol?.slice(0, 2) || "??"}
</span>
</div>
<div>
<p className="font-medium">
{info?.symbol || "Unknown"}
</p>
<p className="text-sm text-gray-500">
{info?.name || token.mint.slice(0, 8)}...
</p>
</div>
</div>
<div className="text-right">
<p className="font-medium">
{token.uiAmount.toLocaleString(undefined, {
maximumFractionDigits: 4,
})}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
</Card>
);
}
Transaction History
TypeScript
// components/wallet/TransactionHistory.tsx
"use client";
import { useTransactions } from "@/hooks/useTransactions";
import { Card } from "@/components/ui/Card";
import { Skeleton } from "@/components/ui/Skeleton";
export function TransactionHistory() {
const { data: transactions, isLoading, error } = useTransactions(10);
if (isLoading) {
return (
<Card>
<div className="p-6">
<h3 className="text-lg font-semibold mb-4">Recent Transactions</h3>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-4 py-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1">
<Skeleton className="h-4 w-32 mb-1" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))}
</div>
</Card>
);
}
if (error) {
return (
<Card>
<div className="p-6 text-center text-red-500">
Error loading transactions
</div>
</Card>
);
}
return (
<Card>
<div className="p-6">
<h3 className="text-lg font-semibold mb-4">Recent Transactions</h3>
{!transactions?.length ? (
<p className="text-gray-500 text-center py-4">
No transactions found
</p>
) : (
<div className="divide-y dark:divide-gray-700">
{transactions.map((tx) => (
<div
key={tx.signature}
className="flex items-center justify-between py-3"
>
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${
tx.type === "received"
? "bg-green-100 text-green-600"
: tx.type === "sent"
? "bg-red-100 text-red-600"
: "bg-gray-100 text-gray-600"
}`}
>
{tx.type === "received" ? "↓" : tx.type === "sent" ? "↑" : "•"}
</div>
<div>
<p className="font-medium capitalize">
{tx.type === "received"
? "Received"
: tx.type === "sent"
? "Sent"
: "Transaction"}
</p>
<p className="text-sm text-gray-500">
{tx.timestamp
? formatTimeAgo(tx.timestamp * 1000)
: "Pending"}
</p>
</div>
</div>
<div className="text-right">
{tx.amount && (
<p
className={`font-medium ${
tx.type === "received"
? "text-green-600"
: tx.type === "sent"
? "text-red-600"
: ""
}`}
>
{tx.type === "received" ? "+" : tx.type === "sent" ? "-" : ""}
{tx.amount.toFixed(4)} SOL
</p>
)}
<a
href={`https://explorer.solana.com/tx/${tx.signature}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-500 hover:underline"
>
View
</a>
</div>
</div>
))}
</div>
)}
</div>
</Card>
);
}
function formatTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return "Just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`;
return `${Math.floor(seconds / 86400)} days ago`;
}
Main Page
TypeScript
// app/page.tsx
"use client";
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import { BalanceCard } from "@/components/wallet/BalanceCard";
import { TokenList } from "@/components/wallet/TokenList";
import { TransactionHistory } from "@/components/wallet/TransactionHistory";
export default function DashboardPage() {
const { connected, publicKey } = useWallet();
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<header className="border-b dark:border-gray-800 bg-white dark:bg-gray-950">
<div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold">Wallet Dashboard</h1>
<WalletMultiButton />
</div>
</header>
<main className="max-w-6xl mx-auto px-4 py-8">
{!connected ? (
<div className="text-center py-20">
<h2 className="text-2xl font-bold mb-4">
Connect Your Wallet
</h2>
<p className="text-gray-500 mb-8">
Connect your Solana wallet to view your dashboard
</p>
<WalletMultiButton />
</div>
) : (
<>
<div className="mb-4">
<p className="text-sm text-gray-500">
Connected: {publicKey?.toBase58().slice(0, 8)}...
{publicKey?.toBase58().slice(-8)}
</p>
</div>
<div className="grid gap-6 md:grid-cols-2">
<BalanceCard />
<TokenList />
</div>
<div className="mt-6">
<TransactionHistory />
</div>
</>
)}
</main>
</div>
);
}
Providers Setup
TypeScript
// app/providers.tsx
"use client";
import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
import {
ConnectionProvider,
WalletProvider,
} from "@solana/wallet-adapter-react";
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
import { PhantomWalletAdapter } from "@solana/wallet-adapter-wallets";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { clusterApiUrl } from "@solana/web3.js";
import { useMemo, useState } from "react";
import "@solana/wallet-adapter-react-ui/styles.css";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const network = WalletAdapterNetwork.Devnet;
const endpoint = useMemo(() => clusterApiUrl(network), [network]);
const wallets = useMemo(
() => [new PhantomWalletAdapter()],
[]
);
return (
<QueryClientProvider client={queryClient}>
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
{children}
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
</QueryClientProvider>
);
}
Enhancements
TypeScript
// Real-time updates with WebSocket
import { useEffect } from "react";
export function useRealTimeBalance() {
const { connection } = useConnection();
const { publicKey } = useWallet();
const queryClient = useQueryClient();
useEffect(() => {
if (!publicKey) return;
// Subscribe to account changes
const subscriptionId = connection.onAccountChange(
publicKey,
(accountInfo) => {
// Invalidate query to trigger refetch
queryClient.invalidateQueries({
queryKey: ["balance", publicKey.toBase58()],
});
},
);
return () => {
connection.removeAccountChangeListener(subscriptionId);
};
}, [connection, publicKey, queryClient]);
}
// Price fetching from CoinGecko
export async function fetchSolPrice(): Promise<number> {
const response = await fetch(
"https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd",
);
const data = await response.json();
return data.solana.usd;
}
What You'll Learn
- Wallet adapter integration
- Querying Solana RPC for balance and tokens
- Parsing transaction history
- React Query for data fetching
- Real-time updates with WebSocket
Next: Token Creator - Create SPL tokens with metadata.