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                                         │   │
│  │  ┌──────────┬──────────┬───────────┐                   │   │
│  │  │ TokenBalanceValue     │                   │   │
│  │  ├──────────┼──────────┼───────────┤                   │   │
│  │  │ USDC500.00$500.00   │                   │   │
│  │  │ RAY125.50$62.75    │                   │   │
│  │  │ SRM50.00$25.00    │                   │   │
│  │  └──────────┴──────────┴───────────┘                   │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  Recent Transactions                                    │   │
│  │  ✓ Received 2.5 SOL - 2 hours ago                      │   │
│  │  ✓ Sent 100 USDC - 5 hours ago                         │   │
│  │  ✓ Swap RAYSOL - 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.