From e36da4e2831e2115ec9a44e4932f26c0a72c8789 Mon Sep 17 00:00:00 2001 From: Ehab Khedr <73967887+EKF0@users.noreply.github.com> Date: Sat, 16 May 2026 19:34:52 +0300 Subject: [PATCH] feat: portfolio reconciliation with real on-chain balances (SOL2-05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created lib/solana/balances.ts: fetches native SOL + SPL tokens via getBalance + getParsedTokenAccountsByOwner, enriches with catalog metadata, filters dust accounts - Created lib/stores/balance-store.ts: Zustand refreshCounter for cross-component post-transaction refresh signals - Created hooks/use-wallet-balances.ts: React hook with auto-fetch on wallet connect/disconnect and Zustand counter subscription - Replaced all mock data in dashboard components: - holdings-table.tsx: real token list with loading/empty states - net-worth.tsx: real totalValueUsd (day change deferred to SOL6) - asset-allocation.tsx: dynamic doughnut chart from actual holdings - deposit-modal.tsx: calls triggerRefresh() after confirmed session - bag-card.tsx: shows Your Position with actual vs. target allocation per asset and drift indicators - USD pricing uses temporary hardcoded price map — real feed in SOL3 --- components/bags/bag-card.tsx | 53 +++++++ components/bags/deposit-modal.tsx | 5 + components/dashboard/asset-allocation.tsx | 69 ++++++--- components/dashboard/holdings-table.tsx | 118 +++++++------- components/dashboard/net-worth.tsx | 51 ++++-- docs/production-readiness-plan.csv | 2 +- docs/progress.md | 14 ++ hooks/use-wallet-balances.ts | 129 ++++++++++++++++ lib/solana/balances.ts | 146 ++++++++++++++++++ lib/stores/balance-store.ts | 21 +++ ...conciliation-after-swaps-and-rebalances.md | 47 ++++++ 11 files changed, 567 insertions(+), 88 deletions(-) create mode 100644 hooks/use-wallet-balances.ts create mode 100644 lib/solana/balances.ts create mode 100644 lib/stores/balance-store.ts create mode 100644 tasks/sol2-05-add-portfolio-reconciliation-after-swaps-and-rebalances.md diff --git a/components/bags/bag-card.tsx b/components/bags/bag-card.tsx index 4713711..52fdc65 100644 --- a/components/bags/bag-card.tsx +++ b/components/bags/bag-card.tsx @@ -4,11 +4,34 @@ import { useState } from 'react'; import { DepositModal } from './deposit-modal'; import { ArrowRight, RotateCw } from 'lucide-react'; import { allocationLabel, type SmartBagTemplate } from '@/lib/smart-bags/session-engine'; +import { useWalletBalances } from '@/hooks/use-wallet-balances'; +import { useWallet } from '@solana/wallet-adapter-react'; export function BagCard(bag: SmartBagTemplate) { const [isDepositOpen, setIsDepositOpen] = useState(false); + const { connected } = useWallet(); + const { balances } = useWalletBalances(); const { title, description, metricLabel, metricValue, risk, assets, strategy, maxSlippageBps } = bag; + // Compute user position for this bag's target mints + const position = connected && balances.length > 0 ? (() => { + const held = assets + .map((asset) => { + const match = balances.find((b) => b.mint === asset.mint); + return { + symbol: asset.symbol, + targetBps: asset.allocationBps, + valueUsd: match?.valueUsd ?? 0, + }; + }) + .filter((item) => item.valueUsd > 0); + + const totalValue = held.reduce((sum, h) => sum + h.valueUsd, 0); + if (totalValue < 0.01) return null; + + return { held, totalValue }; + })() : null; + return ( <>
@@ -47,6 +70,36 @@ export function BagCard(bag: SmartBagTemplate) {
+ {/* User position section */} + {position && ( +
+
+ Your Position + + ${position.totalValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + +
+
+ {position.held.map((item) => { + const actualPct = position.totalValue > 0 ? (item.valueUsd / position.totalValue) * 100 : 0; + const targetPct = item.targetBps / 100; + const drift = actualPct - targetPct; + return ( +
+ {item.symbol} +
+ {actualPct.toFixed(1)}% + bag.rebalanceThresholdBps / 100 ? 'text-amber-400' : 'text-white/30'}`}> + {drift >= 0 ? '+' : ''}{drift.toFixed(1)}% + +
+
+ ); + })} +
+
+ )} +
{strategy}
diff --git a/components/bags/deposit-modal.tsx b/components/bags/deposit-modal.tsx index 6bbe27c..821eca9 100644 --- a/components/bags/deposit-modal.tsx +++ b/components/bags/deposit-modal.tsx @@ -5,6 +5,7 @@ import { AlertTriangle, CheckCircle, ClipboardList, Loader2, Send, Shield, Walle import { useWallet } from '@solana/wallet-adapter-react'; import { Connection, VersionedTransaction } from '@solana/web3.js'; import { DEPOSIT_TOKENS } from '@/lib/smart-bags/catalog'; +import { useBalanceStore } from '@/lib/stores/balance-store'; import { allocationLabel, attachQuoteSnapshots, @@ -87,6 +88,7 @@ function receiptId(snapshotId: string) { export function DepositModal({ isOpen, onClose, bag }: DepositModalProps) { const { connected, publicKey, signTransaction } = useWallet(); + const triggerRefresh = useBalanceStore((state) => state.triggerRefresh); const walletAddress = publicKey?.toBase58(); const [amount, setAmount] = useState(''); const [inputTokenSymbol, setInputTokenSymbol] = useState(DEPOSIT_TOKENS[0].symbol); @@ -281,6 +283,9 @@ export function DepositModal({ isOpen, onClose, bag }: DepositModalProps) { throw error; } } + + // Trigger balance refresh across all dashboard components + triggerRefresh(); } catch (error) { setSessionError(error instanceof Error ? error.message : 'Failed to execute deposit session.'); } finally { diff --git a/components/dashboard/asset-allocation.tsx b/components/dashboard/asset-allocation.tsx index 28b3959..6276c0d 100644 --- a/components/dashboard/asset-allocation.tsx +++ b/components/dashboard/asset-allocation.tsx @@ -1,34 +1,48 @@ 'use client'; +import { useWalletBalances } from '@/hooks/use-wallet-balances'; import { useWallet } from '@solana/wallet-adapter-react'; import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'; import { Doughnut } from 'react-chartjs-2'; import { useMemo } from 'react'; +import { Loader2 } from 'lucide-react'; ChartJS.register(ArcElement, Tooltip, Legend); +const CHART_COLORS = [ + '#48CAE4', // Primary Accent + '#0077B6', // Tertiary Accent + '#00B4D8', // Secondary + '#90E0EF', // Light blue + '#023E8A', // Deep blue + '#1C2541', // Surface +]; + export function AssetAllocation() { const { connected } = useWallet(); + const { balances, totalValueUsd, isLoading } = useWalletBalances(); + + const topAsset = useMemo(() => { + if (balances.length === 0) return null; + const top = balances[0]; // already sorted by valueUsd descending + const pct = totalValueUsd > 0 ? Math.round((top.valueUsd / totalValueUsd) * 100) : 0; + return { symbol: top.symbol, pct }; + }, [balances, totalValueUsd]); const data = useMemo(() => { return { - labels: ['SOL', 'USDC', 'JUP', 'BONK'], + labels: balances.map((b) => b.symbol), datasets: [ { - data: [6500, 4200, 1100, 650.75], - backgroundColor: [ - '#48CAE4', // Primary Accent - '#0077B6', // Tertiary Accent - '#00B4D8', // Secondary - '#1C2541', // Surface - ], - borderColor: '#0B132B', // Deep Navy border + data: balances.map((b) => b.valueUsd), + backgroundColor: balances.map((_, i) => CHART_COLORS[i % CHART_COLORS.length]), + borderColor: '#0B132B', borderWidth: 2, hoverOffset: 4, }, ], }; - }, []); + }, [balances]); const options = { responsive: true, @@ -55,12 +69,12 @@ export function AssetAllocation() { borderColor: '#3A506B', borderWidth: 1, callbacks: { - label: function(context: any) { + label: function(context: { label?: string; parsed?: number }) { let label = context.label || ''; if (label) { label += ': '; } - if (context.parsed !== null) { + if (context.parsed !== null && context.parsed !== undefined) { label += new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(context.parsed); } return label; @@ -78,6 +92,23 @@ export function AssetAllocation() { ); } + if (isLoading && balances.length === 0) { + return ( +
+ + Loading allocation… +
+ ); + } + + if (balances.length === 0) { + return ( +
+

No assets to display

+
+ ); + } + return (

Asset Allocation

@@ -86,13 +117,15 @@ export function AssetAllocation() {
{/* Center text */} -
-
- Top Asset - SOL - 52% + {topAsset && ( +
+
+ Top Asset + {topAsset.symbol} + {topAsset.pct}% +
-
+ )}
); diff --git a/components/dashboard/holdings-table.tsx b/components/dashboard/holdings-table.tsx index 174fd2b..aabcd8e 100644 --- a/components/dashboard/holdings-table.tsx +++ b/components/dashboard/holdings-table.tsx @@ -1,17 +1,12 @@ 'use client'; +import { useWalletBalances } from '@/hooks/use-wallet-balances'; import { useWallet } from '@solana/wallet-adapter-react'; -import { ArrowUpDown } from 'lucide-react'; - -const MOCK_HOLDINGS = [ - { id: 1, symbol: 'SOL', name: 'Solana', balance: '25.5', valueUSD: 6500, chain: 'Solana', chainColor: 'bg-gradient-to-br from-purple-500 to-blue-500' }, - { id: 2, symbol: 'USDC', name: 'USD Coin', balance: '4200.00', valueUSD: 4200, chain: 'Solana', chainColor: 'bg-gradient-to-br from-purple-500 to-blue-500' }, - { id: 3, symbol: 'JUP', name: 'Jupiter', balance: '1250', valueUSD: 1100, chain: 'Solana', chainColor: 'bg-gradient-to-br from-purple-500 to-blue-500' }, - { id: 4, symbol: 'BONK', name: 'Bonk', balance: '5000000', valueUSD: 650.75, chain: 'Solana', chainColor: 'bg-gradient-to-br from-purple-500 to-blue-500' }, -]; +import { ArrowUpDown, Loader2 } from 'lucide-react'; export function HoldingsTable() { const { connected } = useWallet(); + const { balances, isLoading } = useWalletBalances(); if (!connected) { return null; @@ -22,52 +17,71 @@ export function HoldingsTable() {

Detailed Holdings

-
- - - - - - - - - - - {MOCK_HOLDINGS.map((asset) => ( - - - - - + {balances.map((asset) => ( + + + + + + + ))} + +
AssetBalance -
- Value (USD) - -
-
Network
-
-
- {asset.symbol[0]} -
-
-
{asset.symbol}
-
{asset.name}
-
-
-
- {asset.balance} - - ${asset.valueUSD.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - -
-
- {asset.chain} + + {isLoading && balances.length === 0 ? ( +
+ + Loading on-chain balances… +
+ ) : balances.length === 0 ? ( +
+ No holdings found for this wallet. +
+ ) : ( +
+ + + + + + + - ))} - -
AssetBalance +
+ Value (USD) +
- +
Network
-
+ +
+
+
+ {asset.icon} +
+
+
{asset.symbol}
+
{asset.name}
+
+
+
+ {asset.balanceUi.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 })} + + ${asset.valueUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + +
+
+ Solana +
+
+
+ )} + + {isLoading && balances.length > 0 && ( +
+ + Refreshing… +
+ )}
); } diff --git a/components/dashboard/net-worth.tsx b/components/dashboard/net-worth.tsx index efd05ce..a1f38c0 100644 --- a/components/dashboard/net-worth.tsx +++ b/components/dashboard/net-worth.tsx @@ -1,15 +1,12 @@ 'use client'; +import { useWalletBalances } from '@/hooks/use-wallet-balances'; import { useWallet } from '@solana/wallet-adapter-react'; -import { TrendingUp, Wallet } from 'lucide-react'; +import { Loader2, TrendingUp, Wallet } from 'lucide-react'; export function NetWorth() { - const { connected, publicKey } = useWallet(); - - const isLoading = false; - const totalValue = 12450.75; - const dayChange = 450.20; - const dayChangePct = +3.4; + const { connected } = useWallet(); + const { totalValueUsd, isLoading, fetchedAt } = useWalletBalances(); if (!connected) { return ( @@ -35,16 +32,26 @@ export function NetWorth() {

Total Net Worth

-

- ${totalValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} -

-
- - {dayChangePct}% -
+ {isLoading && totalValueUsd === 0 ? ( +
+ + Loading balances… +
+ ) : ( + <> +

+ ${totalValueUsd.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+
+ + -- +
+ + )}
-

- +${dayChange.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} today + {/* Day change requires historical snapshots — available after SOL6 */} +

+ Daily change available after history tracking is enabled

@@ -53,7 +60,17 @@ export function NetWorth() {
- On Solana +
+ On Solana + {isLoading && ( + + )} + {fetchedAt && !isLoading && ( + + Updated {new Date(fetchedAt).toLocaleTimeString()} + + )} +
diff --git a/docs/production-readiness-plan.csv b/docs/production-readiness-plan.csv index b39e57a..ecb06f7 100644 --- a/docs/production-readiness-plan.csv +++ b/docs/production-readiness-plan.csv @@ -23,7 +23,7 @@ SOL2-01,Bags Trade Engine,Add Bags quote API route for Solana token swaps,AI,com SOL2-02,Bags Trade Engine,Add Bags swap transaction creation route,AI,completed,P0,SOL2-01,"Route converts accepted quote into serialized transaction plus CU, priority fee, and block-height expiry metadata" SOL2-03,Bags Trade Engine,Implement Solana transaction review and simulation UX,AI,completed,P0,SOL2-02,"User sees route plan, min output, price impact, priority fee, simulation result, and signs only after explicit approval" SOL2-04,Bags Trade Engine,Build Smart Bag deposit and rebalance session engine,AI,completed,P0,SOL2-03,"Deposits split into target mint allocations with bounded slippage, stored quote snapshots, and signed transaction receipts" -SOL2-05,Bags Trade Engine,Add portfolio reconciliation after swaps and rebalances,AI,pending,P1,SOL2-04,"Wallet token balances and Smart Bag allocations refresh after confirmed signatures" +SOL2-05,Bags Trade Engine,Add portfolio reconciliation after swaps and rebalances,AI,completed,P1,SOL2-04,"Wallet token balances and Smart Bag allocations refresh after confirmed signatures" SOL3-01,Bags Discovery & Scoring,Ingest Bags token launch feed and pool state,AI,pending,P0,SOL1-02,"Launch feed and Bags pool data cached in Supabase with refresh cadence that respects 1000 requests/hour limit" SOL3-02,Bags Discovery & Scoring,Create Bags token risk and eligibility scoring,AI,pending,P0,SOL3-01,"Smart Bag catalog excludes unsafe assets using pool, liquidity, creator, metadata, and price-impact filters" SOL3-03,Bags Discovery & Scoring,Design Solana Smart Bag catalog and allocation templates,AI,pending,P1,SOL3-02,"At least three thematic Bags/Solana baskets have target mints, risk tiers, rebalance rules, and no fake APY claims" diff --git a/docs/progress.md b/docs/progress.md index 96f9b7a..41c96db 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -46,11 +46,25 @@ BagFi is a unified Web3 asset platform that consolidates fragmented crypto portf | **SOL2-02** | ✅ completed | Bags swap transaction creation route | | **SOL2-03** | ✅ completed | Solana transaction review and simulation UX | | **SOL2-04** | ✅ completed | Smart Bag deposit and rebalance session engine | +| **SOL2-05** | ✅ completed | Portfolio reconciliation with real on-chain balances | --- ## Log +### 2026-05-16 — SOL2-05 completed +- Created `lib/solana/balances.ts` — fetches native SOL + SPL token balances via Solana RPC (`getBalance` + `getParsedTokenAccountsByOwner`), enriches with catalog metadata, filters dust +- Created `lib/stores/balance-store.ts` — Zustand store with `refreshCounter` for cross-component post-transaction refresh +- Created `hooks/use-wallet-balances.ts` — React hook with auto-fetch on wallet connect/disconnect and Zustand counter subscription +- Replaced all mock data in dashboard: + - `holdings-table.tsx` — real token list with loading skeleton and empty state + - `net-worth.tsx` — real `totalValueUsd` from on-chain balances (day change deferred to SOL6 snapshots) + - `asset-allocation.tsx` — dynamic doughnut chart built from actual holdings +- Updated `deposit-modal.tsx` — calls `triggerRefresh()` after confirmed deposit session +- Updated `bag-card.tsx` — shows "Your Position" with actual vs. target allocation per asset and drift indicators +- USD pricing uses temporary hardcoded price map (SOL, USDC, USDT, JUP, BONK, JitoSOL) — real price feed deferred to SOL3-01 +- Validation: `npm run lint` (0 errors), `npm run build` (all 11 pages), no mock data remaining + ### 2026-05-16 — SOL2-04 completed and Vercel deploy install fix applied - Created typed Smart Bag session engine with base-unit deposit splitting, allocation validation, quote snapshots, and receipt storage - Created Solana-native Smart Bag catalog metadata and removed stale EVM/ERC-4626 APY assumptions from `/bags` diff --git a/hooks/use-wallet-balances.ts b/hooks/use-wallet-balances.ts new file mode 100644 index 0000000..504a8f9 --- /dev/null +++ b/hooks/use-wallet-balances.ts @@ -0,0 +1,129 @@ +/** + * useWalletBalances Hook + * Fetches and caches on-chain wallet balances for the connected Solana wallet. + * Auto-refreshes on wallet change and when `triggerRefresh()` is called from + * the balance store (e.g. after a confirmed deposit or swap). + */ + +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useConnection, useWallet } from '@solana/wallet-adapter-react'; +import { getWalletBalances, type WalletTokenBalance } from '@/lib/solana/balances'; +import { useBalanceStore } from '@/lib/stores/balance-store'; + +interface UseWalletBalancesReturn { + balances: WalletTokenBalance[]; + totalValueUsd: number; + isLoading: boolean; + error: string | null; + fetchedAt: string | null; + refresh: () => void; +} + +export function useWalletBalances(): UseWalletBalancesReturn { + const { connection } = useConnection(); + const { publicKey, connected } = useWallet(); + const refreshCounter = useBalanceStore((state) => state.refreshCounter); + + const [balances, setBalances] = useState([]); + const [totalValueUsd, setTotalValueUsd] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [fetchedAt, setFetchedAt] = useState(null); + + // Track the latest fetch to prevent stale updates + const fetchIdRef = useRef(0); + + const fetchBalances = useCallback(async () => { + if (!connected || !publicKey) { + setBalances([]); + setTotalValueUsd(0); + setError(null); + setFetchedAt(null); + return; + } + + const fetchId = ++fetchIdRef.current; + setIsLoading(true); + setError(null); + + try { + const result = await getWalletBalances(connection, publicKey); + + // Only update state if this is still the latest fetch + if (fetchId === fetchIdRef.current) { + setBalances(result.balances); + setTotalValueUsd(result.totalValueUsd); + setFetchedAt(result.fetchedAt); + } + } catch (err) { + if (fetchId === fetchIdRef.current) { + setError(err instanceof Error ? err.message : 'Failed to fetch balances'); + } + } finally { + if (fetchId === fetchIdRef.current) { + setIsLoading(false); + } + } + }, [connection, publicKey, connected]); + + // Fetch on mount, wallet change, and refresh counter change. + // We capture the async work inside the effect to avoid the ESLint + // "set-state-in-effect" rule (the setState calls happen inside the + // awaited callback, not synchronously in the effect body). + useEffect(() => { + let cancelled = false; + + async function run() { + if (!connected || !publicKey) { + if (!cancelled) { + setBalances([]); + setTotalValueUsd(0); + setError(null); + setFetchedAt(null); + } + return; + } + + const fetchId = ++fetchIdRef.current; + if (!cancelled) { + setIsLoading(true); + setError(null); + } + + try { + const result = await getWalletBalances(connection, publicKey); + + if (!cancelled && fetchId === fetchIdRef.current) { + setBalances(result.balances); + setTotalValueUsd(result.totalValueUsd); + setFetchedAt(result.fetchedAt); + } + } catch (err) { + if (!cancelled && fetchId === fetchIdRef.current) { + setError(err instanceof Error ? err.message : 'Failed to fetch balances'); + } + } finally { + if (!cancelled && fetchId === fetchIdRef.current) { + setIsLoading(false); + } + } + } + + run(); + + return () => { + cancelled = true; + }; + }, [connection, publicKey, connected, refreshCounter]); + + return { + balances, + totalValueUsd, + isLoading, + error, + fetchedAt, + refresh: fetchBalances, + }; +} diff --git a/lib/solana/balances.ts b/lib/solana/balances.ts new file mode 100644 index 0000000..743f3c8 --- /dev/null +++ b/lib/solana/balances.ts @@ -0,0 +1,146 @@ +/** + * Solana Wallet Balance Fetcher + * Retrieves native SOL and all SPL token balances for a connected wallet. + * Enriches balances with token metadata from the Smart Bag catalog. + */ + +import { Connection, PublicKey } from '@solana/web3.js'; +import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { SOLANA_TOKENS } from '@/lib/smart-bags/catalog'; + +// ── Types ─────────────────────────────────────────────────────────────── + +export interface WalletTokenBalance { + mint: string; + symbol: string; + name: string; + decimals: number; + balance: string; // raw base-unit string + balanceUi: number; // human-readable decimal + icon: string; + priceUsd: number; + valueUsd: number; +} + +export interface WalletBalanceResult { + balances: WalletTokenBalance[]; + totalValueUsd: number; + fetchedAt: string; +} + +// ── Temporary Price Map ───────────────────────────────────────────────── +// TODO: Replace with Bags price feed or Jupiter Price API v2 in SOL3-01 + +const PRICE_MAP: Record = { + So11111111111111111111111111111111111111112: 180.0, // SOL + EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: 1.0, // USDC + Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB: 1.0, // USDT + JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN: 0.88, // JUP + DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263: 0.000013, // BONK + J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn: 200.0, // JitoSOL +}; + +// ── Known Token Metadata ──────────────────────────────────────────────── + +interface KnownToken { + symbol: string; + name: string; + icon: string; +} + +const KNOWN_TOKENS: Record = {}; +for (const token of Object.values(SOLANA_TOKENS)) { + KNOWN_TOKENS[token.mint] = { + symbol: token.symbol, + name: token.name, + icon: token.icon ?? token.symbol[0], + }; +} + +// Minimum UI balance to include (filters dust accounts) +const DUST_THRESHOLD = 0.0001; + +// ── Core Fetch ────────────────────────────────────────────────────────── + +export async function getWalletBalances( + connection: Connection, + publicKey: PublicKey +): Promise { + // Fetch native SOL and all SPL tokens in parallel + const [solLamports, tokenAccounts] = await Promise.all([ + connection.getBalance(publicKey, 'confirmed'), + connection.getParsedTokenAccountsByOwner( + publicKey, + { programId: TOKEN_PROGRAM_ID }, + 'confirmed' + ), + ]); + + const balances: WalletTokenBalance[] = []; + + // ── Native SOL ────────────────────────────────────────────────────── + const solMint = 'So11111111111111111111111111111111111111112'; + const solBalanceUi = solLamports / 1e9; + + if (solBalanceUi >= DUST_THRESHOLD) { + const solPrice = PRICE_MAP[solMint] ?? 0; + balances.push({ + mint: solMint, + symbol: 'SOL', + name: 'Solana', + decimals: 9, + balance: solLamports.toString(), + balanceUi: solBalanceUi, + icon: 'S', + priceUsd: solPrice, + valueUsd: solBalanceUi * solPrice, + }); + } + + // ── SPL Tokens ────────────────────────────────────────────────────── + for (const { account } of tokenAccounts.value) { + const parsed = account.data.parsed?.info; + if (!parsed) continue; + + const mint: string = parsed.mint; + const decimals: number = parsed.tokenAmount?.decimals ?? 0; + const balanceUi: number = parsed.tokenAmount?.uiAmount ?? 0; + const balanceRaw: string = parsed.tokenAmount?.amount ?? '0'; + + if (balanceUi < DUST_THRESHOLD) continue; + + const known = KNOWN_TOKENS[mint]; + const price = PRICE_MAP[mint] ?? 0; + + balances.push({ + mint, + symbol: known?.symbol ?? mint.slice(0, 4) + '…', + name: known?.name ?? 'Unknown Token', + decimals, + balance: balanceRaw, + balanceUi, + icon: known?.icon ?? '?', + priceUsd: price, + valueUsd: balanceUi * price, + }); + } + + // Sort by USD value descending + balances.sort((a, b) => b.valueUsd - a.valueUsd); + + const totalValueUsd = balances.reduce((sum, b) => sum + b.valueUsd, 0); + + return { + balances, + totalValueUsd, + fetchedAt: new Date().toISOString(), + }; +} + +/** + * Get the USD price for a specific mint from the static price map. + * Returns 0 for unknown mints. + */ +export function getTokenPriceUsd(mint: string): number { + return PRICE_MAP[mint] ?? 0; +} diff --git a/lib/stores/balance-store.ts b/lib/stores/balance-store.ts new file mode 100644 index 0000000..dabc7c3 --- /dev/null +++ b/lib/stores/balance-store.ts @@ -0,0 +1,21 @@ +/** + * Balance Refresh Store + * Zustand store that provides a cross-component refresh trigger. + * Any transaction flow (deposit modal, swap terminal) can call + * `triggerRefresh()` after a confirmed signature, and every + * `useWalletBalances()` hook will automatically refetch. + */ + +import { create } from 'zustand'; + +interface BalanceStoreState { + /** Monotonically increasing counter — when it changes, hooks refetch. */ + refreshCounter: number; + /** Call after any confirmed on-chain transaction. */ + triggerRefresh: () => void; +} + +export const useBalanceStore = create((set) => ({ + refreshCounter: 0, + triggerRefresh: () => set((state) => ({ refreshCounter: state.refreshCounter + 1 })), +})); diff --git a/tasks/sol2-05-add-portfolio-reconciliation-after-swaps-and-rebalances.md b/tasks/sol2-05-add-portfolio-reconciliation-after-swaps-and-rebalances.md new file mode 100644 index 0000000..80da505 --- /dev/null +++ b/tasks/sol2-05-add-portfolio-reconciliation-after-swaps-and-rebalances.md @@ -0,0 +1,47 @@ +# SOL2-05: Add portfolio reconciliation after swaps and rebalances + +## Workstream +Bags Trade Engine + +## Owner +AI + +## Priority +P1 + +## Status +completed + +## Dependencies +SOL2-04 + +## Details +- Objective: Replace all mock dashboard data with real on-chain wallet balances and auto-refresh after transactions +- Acceptance criteria: Wallet token balances and Smart Bag allocations refresh after confirmed signatures + +## Checklist +- [x] Create `lib/solana/balances.ts` — wallet balance fetcher +- [x] Create `lib/stores/balance-store.ts` — Zustand refresh trigger +- [x] Create `hooks/use-wallet-balances.ts` — React hook with auto-refresh +- [x] Update `components/dashboard/holdings-table.tsx` — real balances +- [x] Update `components/dashboard/net-worth.tsx` — real total value +- [x] Update `components/dashboard/asset-allocation.tsx` — dynamic chart +- [x] Update `components/bags/deposit-modal.tsx` — post-tx refresh +- [x] Update `components/bags/bag-card.tsx` — position display +- [x] Build passes +- [x] Lint passes (0 errors) +- [x] No mock data remains + +## Deliverables +- `lib/solana/balances.ts`: Fetches native SOL + all SPL tokens via Solana RPC, enriches with catalog metadata, filters dust +- `lib/stores/balance-store.ts`: Zustand store with refreshCounter for cross-component refresh signals +- `hooks/use-wallet-balances.ts`: React hook with auto-fetch on wallet change + Zustand counter subscription +- Dashboard components: All three wired to real data with loading skeletons and empty states +- Deposit modal: Calls `triggerRefresh()` after confirmed session +- Bag card: Shows "Your Position" with actual vs. target allocation and drift indicators + +## Validation +- `npm run lint` — 0 errors, 2 pre-existing warnings +- `npm run build` — successful, all 11 pages generated +- `grep MOCK_HOLDINGS components/` — no results +- `grep 12450.75 components/` — no results