From c1e5482062fbcee3d3e1bf3020eef8b17bc4834d Mon Sep 17 00:00:00 2001 From: Senior Engineer Date: Wed, 27 May 2026 07:01:09 +0100 Subject: [PATCH 1/4] [363] Frontend: Add wallet balance insufficiency guard on deposit form. Closes #363 --- frontend/src/App.tsx | 4 +- .../src/components/VaultDashboard.test.tsx | 67 +++++++++++++++---- frontend/src/components/VaultDashboard.tsx | 34 +++++++++- frontend/src/hooks/useBalanceData.ts | 25 ++++++- frontend/src/lib/queryClient.ts | 2 + frontend/src/lib/stellarAccount.ts | 13 ++++ frontend/src/pages/Home.tsx | 5 +- 7 files changed, 132 insertions(+), 18 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6b6358b..fb9e8705 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,7 @@ import { FeatureGate } from "./components/FeatureGate"; import { FeatureFlagProvider } from "./context/FeatureFlagContext"; import { AuthProvider, useAuth } from "./context/AuthContext"; import { PreferencesProvider } from "./context/PreferencesContext"; -import { useUsdcBalance } from "./hooks/useBalanceData"; +import { useUsdcBalance, useXlmBalance } from "./hooks/useBalanceData"; import { queryClient } from "./lib/queryClient"; import { clearWalletSessionState } from "./lib/sessionCleanup"; import ErrorFallback from "./components/ErrorFallback"; @@ -40,6 +40,7 @@ function AppContent() { const location = useLocation(); const { sessionState, intendedPath, setSessionExpired, clearSessionExpired, dismissSessionWarning } = useAuth(); const { data: usdcBalance = 0 } = useUsdcBalance(walletAddress); + const { data: xlmBalance = 0 } = useXlmBalance(walletAddress); const { tvl } = useVault(); useEffect(() => { @@ -105,6 +106,7 @@ function AppContent() { } /> diff --git a/frontend/src/components/VaultDashboard.test.tsx b/frontend/src/components/VaultDashboard.test.tsx index acd54167..b2cc3c2c 100644 --- a/frontend/src/components/VaultDashboard.test.tsx +++ b/frontend/src/components/VaultDashboard.test.tsx @@ -18,6 +18,7 @@ vi.mock("../lib/vaultApi", async (importOriginal) => { return { ...actual, submitDeposit: vi.fn(), + estimateNetworkFee: vi.fn().mockResolvedValue("0.05"), }; }); @@ -65,6 +66,7 @@ function LocationSearchProbe() { function renderDashboard( walletAddress: string | null, usdcBalance = 1250.5, + xlmBalance = 10.0, initialEntry = "/", ) { const queryClient = new QueryClient({ @@ -77,7 +79,11 @@ function renderDashboard( - + @@ -284,21 +290,56 @@ describe("VaultDashboard", () => { const input = await screen.findByPlaceholderText("0.00"); await waitFor(() => { expect((input as HTMLInputElement).value).toBe(""); + expect(screen.getByTestId("location-search")).toHaveTextContent(""); }); - expect(screen.getByTestId("location-search")).toHaveTextContent(""); }); - it("clears amount input when switching tabs", async () => { - renderDashboard("GABC123"); + it("clears amount input when switching tabs", async () => { + renderDashboard("GABC123"); - const input = await screen.findByPlaceholderText("0.00"); - fireEvent.change(input, { target: { value: "100" } }); - expect(input).toHaveValue(100); + const input = await screen.findByPlaceholderText("0.00"); + fireEvent.change(input, { target: { value: "100" } }); + expect(input).toHaveValue(100); - const withdrawTab = screen.getByText("Withdraw"); - fireEvent.click(withdrawTab); + const withdrawTab = screen.getByText("Withdraw"); + fireEvent.click(withdrawTab); - const clearedInput = screen.getByPlaceholderText("0.00"); - expect(clearedInput).toHaveValue(null); - }); - }); + const clearedInput = screen.getByPlaceholderText("0.00"); + expect(clearedInput).toHaveValue(null); + }); + + it("shows inline error and disables submit when XLM balance is insufficient for network fees", async () => { + renderDashboard("GABC123", 1250.5, 0.01); + + expect(await screen.findByText(/Review Transaction/i)).toBeInTheDocument(); + + const input = screen.getByPlaceholderText("0.00"); + fireEvent.change(input, { target: { value: "100" } }); + fireEvent.blur(input); + + await waitFor(() => { + expect( + screen.getByText(/Insufficient XLM balance for network fees./i), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Review Transaction" })).toBeDisabled(); + }); + }); + + it("shows warning banner and disables confirm button on review step when XLM balance is insufficient", async () => { + renderDashboard("GABC123", 1250.5, 0.01); + + expect(await screen.findByText(/Review Transaction/i)).toBeInTheDocument(); + + const inputField = screen.getByPlaceholderText("0.00"); + fireEvent.change(inputField, { target: { value: "100" } }); + + const reviewBtn = screen.getByRole("button", { name: "Review Transaction" }); + fireEvent.click(reviewBtn); + + await waitFor(() => { + expect(screen.getByText("Insufficient XLM balance")).toBeInTheDocument(); + expect(screen.getByText("You do not have enough XLM to cover the estimated network fee.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Confirm deposit/i })).toBeDisabled(); + }); + }); + }); diff --git a/frontend/src/components/VaultDashboard.tsx b/frontend/src/components/VaultDashboard.tsx index a1305386..2a533427 100644 --- a/frontend/src/components/VaultDashboard.tsx +++ b/frontend/src/components/VaultDashboard.tsx @@ -81,6 +81,7 @@ const StepIndicator: React.FC<{ currentStep: TransactionStep }> = ({ currentStep interface VaultDashboardProps { walletAddress: string | null; usdcBalance?: number; + xlmBalance?: number; } const MIN_DEPOSIT_AMOUNT = 1; @@ -145,6 +146,8 @@ function getAmountValidationError( rawAmount: string, availableBalance: number, isCapReached: boolean, + xlmBalance: number, + feeXlm: number, ): string | null { if (!rawAmount.trim()) { return "Amount is required."; @@ -173,6 +176,10 @@ function getAmountValidationError( return "Deposits are temporarily disabled because the vault is at capacity."; } + if (actionType === "deposit" && xlmBalance < feeXlm) { + return "Insufficient XLM balance for network fees."; + } + return null; } @@ -180,6 +187,7 @@ function getAmountValidationError( const VaultDashboard: React.FC = ({ walletAddress, usdcBalance = 0, + xlmBalance = 0, }) => { const [searchParams, setSearchParams] = useSearchParams(); const { @@ -261,6 +269,8 @@ const VaultDashboard: React.FC = ({ amount, availableBalance, isCapReached, + xlmBalance, + feeXlm, ); if (validationError) { @@ -302,6 +312,8 @@ const VaultDashboard: React.FC = ({ amount, availableBalance, isCapReached, + xlmBalance, + feeXlm, ); const isValidAmount = !activeAmountError; const showInlineError = touched[activeTab] && Boolean(activeAmountError); @@ -834,6 +846,25 @@ const VaultDashboard: React.FC = ({ )} + {tab === "deposit" && xlmBalance < feeXlm && ( +
+ +
+ Insufficient XLM balance + You do not have enough XLM to cover the estimated network fee. +
+
+ )} + {tab === "deposit" && isValidAmount && needsApproval(enteredAmount) && (
= ({ onClick={() => void handleTransaction(tab)} disabled={ isBusy || - (tab === "deposit" && needsApproval(enteredAmount) && approvalStatus !== "confirmed") + (tab === "deposit" && needsApproval(enteredAmount) && approvalStatus !== "confirmed") || + (tab === "deposit" && xlmBalance < feeXlm) } > {isBusy ? ( diff --git a/frontend/src/hooks/useBalanceData.ts b/frontend/src/hooks/useBalanceData.ts index 7c891106..98f5da2f 100644 --- a/frontend/src/hooks/useBalanceData.ts +++ b/frontend/src/hooks/useBalanceData.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { fetchUsdcBalance } from "../lib/stellarAccount"; +import { fetchUsdcBalance, fetchXlmBalance } from "../lib/stellarAccount"; import { queryKeys } from "../lib/queryClient"; /** @@ -24,3 +24,26 @@ export function useUsdcBalance(walletAddress: string | null) { enabled: !!walletAddress, // Only fetch when wallet is connected }); } + +/** + * Hook for fetching native XLM balance with caching. + * Stale time: 10s + * Only fetches when wallet is connected. + */ +export function useXlmBalance(walletAddress: string | null) { + return useQuery({ + queryKey: queryKeys.balance.xlm(walletAddress), + queryFn: async () => { + if (!walletAddress) { + return 0; + } + try { + return await fetchXlmBalance(walletAddress); + } catch { + return 0; + } + }, + staleTime: 10000, // 10 seconds + enabled: !!walletAddress, // Only fetch when wallet is connected + }); +} diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts index 7db450c7..bbb2faa0 100644 --- a/frontend/src/lib/queryClient.ts +++ b/frontend/src/lib/queryClient.ts @@ -54,5 +54,7 @@ export const queryKeys = { all: ["balance"] as const, usdc: (walletAddress?: string | null) => [...queryKeys.balance.all, "usdc", walletAddress] as const, + xlm: (walletAddress?: string | null) => + [...queryKeys.balance.all, "xlm", walletAddress] as const, }, } as const; diff --git a/frontend/src/lib/stellarAccount.ts b/frontend/src/lib/stellarAccount.ts index 96a52e7c..a4441348 100644 --- a/frontend/src/lib/stellarAccount.ts +++ b/frontend/src/lib/stellarAccount.ts @@ -69,3 +69,16 @@ export async function fetchUsdcBalance( return usdc ? Number(usdc.balance) : 0; } + +export async function fetchXlmBalance( + walletAddress: string, + rpcUrl = import.meta.env.VITE_SOROBAN_RPC_URL || `https://${TESTNET_SOROBAN_RPC}`, +): Promise { + const horizonUrl = toHorizonUrl(rpcUrl); + const server = new Horizon.Server(horizonUrl); + const account = await server.accounts().accountId(walletAddress).call(); + + const native = account.balances.find((balance) => balance.asset_type === "native"); + return native ? Number(native.balance) : 0; +} + diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index eedf3ed3..876b7aea 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -5,9 +5,10 @@ import { usePageHeadingFocus } from "../hooks/usePageHeadingFocus"; interface HomeProps { walletAddress: string | null; usdcBalance: number; + xlmBalance: number; } -const Home: React.FC = ({ walletAddress, usdcBalance }) => { +const Home: React.FC = ({ walletAddress, usdcBalance, xlmBalance }) => { const headingRef = usePageHeadingFocus(); return ( @@ -26,7 +27,7 @@ const Home: React.FC = ({ walletAddress, usdcBalance }) => {

- + ); }; From 3a3138685b5f5a4a4ea9271121689569bd5e8d54 Mon Sep 17 00:00:00 2001 From: Senior Engineer Date: Wed, 27 May 2026 07:03:06 +0100 Subject: [PATCH 2/4] [363] Frontend: Fix wizard and emptystate tests by passing xlmBalance prop --- frontend/src/components/VaultDashboard.emptystate.test.tsx | 2 ++ frontend/src/tests/VaultDashboardWizard.test.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/VaultDashboard.emptystate.test.tsx b/frontend/src/components/VaultDashboard.emptystate.test.tsx index 33262614..316994c4 100644 --- a/frontend/src/components/VaultDashboard.emptystate.test.tsx +++ b/frontend/src/components/VaultDashboard.emptystate.test.tsx @@ -57,6 +57,7 @@ const mockSummary: VaultSummary = { function renderDashboard( walletAddress: string | null, usdcBalance = 0, + xlmBalance = 10.0, ) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -69,6 +70,7 @@ function renderDashboard( diff --git a/frontend/src/tests/VaultDashboardWizard.test.tsx b/frontend/src/tests/VaultDashboardWizard.test.tsx index 7ef13f53..d4d1c806 100644 --- a/frontend/src/tests/VaultDashboardWizard.test.tsx +++ b/frontend/src/tests/VaultDashboardWizard.test.tsx @@ -60,7 +60,7 @@ describe("VaultDashboard Wizard", () => { it("navigates through the deposit wizard steps", async () => { render( - + ); From dafb5f3cbe83378d6b1dd6562d5ddc3d04c692df Mon Sep 17 00:00:00 2001 From: Senior Engineer Date: Wed, 27 May 2026 07:07:19 +0100 Subject: [PATCH 3/4] [363] Frontend: Adjust routing and VaultDashboard tests for XLM balance guard --- frontend/src/components/VaultDashboard.test.tsx | 6 +++--- frontend/src/pages/Portfolio.emptystate.test.tsx | 13 ++++++------- frontend/src/tests/routing.test.tsx | 3 ++- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/VaultDashboard.test.tsx b/frontend/src/components/VaultDashboard.test.tsx index b2cc3c2c..6a6ce519 100644 --- a/frontend/src/components/VaultDashboard.test.tsx +++ b/frontend/src/components/VaultDashboard.test.tsx @@ -66,8 +66,8 @@ function LocationSearchProbe() { function renderDashboard( walletAddress: string | null, usdcBalance = 1250.5, - xlmBalance = 10.0, initialEntry = "/", + xlmBalance = 10.0, ) { const queryClient = new QueryClient({ defaultOptions: { @@ -309,7 +309,7 @@ describe("VaultDashboard", () => { }); it("shows inline error and disables submit when XLM balance is insufficient for network fees", async () => { - renderDashboard("GABC123", 1250.5, 0.01); + renderDashboard("GABC123", 1250.5, "/", 0.01); expect(await screen.findByText(/Review Transaction/i)).toBeInTheDocument(); @@ -326,7 +326,7 @@ describe("VaultDashboard", () => { }); it("shows warning banner and disables confirm button on review step when XLM balance is insufficient", async () => { - renderDashboard("GABC123", 1250.5, 0.01); + renderDashboard("GABC123", 1250.5, "/", 0.01); expect(await screen.findByText(/Review Transaction/i)).toBeInTheDocument(); diff --git a/frontend/src/pages/Portfolio.emptystate.test.tsx b/frontend/src/pages/Portfolio.emptystate.test.tsx index 68f80da8..5efd2843 100644 --- a/frontend/src/pages/Portfolio.emptystate.test.tsx +++ b/frontend/src/pages/Portfolio.emptystate.test.tsx @@ -1,14 +1,13 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import Portfolio from "./Portfolio"; import { ToastProvider } from "../context/ToastContext"; import * as portfolioApi from "../lib/portfolioApi"; -import * as referralHooks from "../hooks/useReferral"; import type { PortfolioHolding } from "../lib/portfolioApi"; -// ── Mocks ────────────────────────────────────────────────────────────────── +// ── Mocks ────────────────────────────────────────────────────────────────── vi.mock("../lib/portfolioApi", async (importOriginal) => { const actual = await importOriginal(); @@ -28,7 +27,7 @@ vi.mock("../components/ShareModal", () => ({ default: () => null, })); -// ── Helpers ──────────────────────────────────────────────────────────────── +// ── Helpers ──────────────────────────────────────────────────────────────── function renderPortfolio(walletAddress: string | null) { const queryClient = new QueryClient({ @@ -58,9 +57,9 @@ const mockHolding: PortfolioHolding = { status: "active", }; -// ── Tests ────────────────────────────────────────────────────────────────── +// ── Tests ────────────────────────────────────────────────────────────────── -describe("Portfolio — empty state", () => { +describe("Portfolio — empty state", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -107,7 +106,7 @@ describe("Portfolio — empty state", () => { }); it("does NOT show the empty state while loading is in progress", () => { - // Never resolves — simulates in-flight request + // Never resolves — simulates in-flight request vi.mocked(portfolioApi.getPortfolioHoldings).mockReturnValue( new Promise(() => undefined), ); diff --git a/frontend/src/tests/routing.test.tsx b/frontend/src/tests/routing.test.tsx index b6c2bbf1..03600804 100644 --- a/frontend/src/tests/routing.test.tsx +++ b/frontend/src/tests/routing.test.tsx @@ -38,7 +38,8 @@ vi.mock('../i18n', () => ({ // Mock hooks to avoid network requests vi.mock('../hooks/useBalanceData', () => ({ - useUsdcBalance: () => ({ data: 1000, isLoading: false }) + useUsdcBalance: () => ({ data: 1000, isLoading: false }), + useXlmBalance: () => ({ data: 10.0, isLoading: false }), })); function renderWithProviders(initialEntries: string[]) { From d8d82764a0a66feed12298c8b8004e8f2007f775 Mon Sep 17 00:00:00 2001 From: Senior Engineer Date: Wed, 27 May 2026 07:23:27 +0100 Subject: [PATCH 4/4] [363] Frontend: Fix duplicate imports in App.tsx and Navbar.test.tsx --- frontend/src/App.tsx | 1 - frontend/src/components/Navbar.test.tsx | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fb9e8705..3f178cc7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,7 +17,6 @@ import { queryClient } from "./lib/queryClient"; import { clearWalletSessionState } from "./lib/sessionCleanup"; import ErrorFallback from "./components/ErrorFallback"; import RouteLoadingFallback from "./components/RouteLoadingFallback"; -import { PreferencesProvider } from "./context/PreferencesContext"; import NetworkWarningBanner from "./components/NetworkWarningBanner"; import OfflineBanner from "./components/OfflineBanner"; import { useVault, VaultProvider } from "./context/VaultContext"; diff --git a/frontend/src/components/Navbar.test.tsx b/frontend/src/components/Navbar.test.tsx index 887c7af7..09c39ddb 100644 --- a/frontend/src/components/Navbar.test.tsx +++ b/frontend/src/components/Navbar.test.tsx @@ -6,8 +6,7 @@ import Navbar from './Navbar'; import { ThemeProvider } from '../context/ThemeContext'; import { ToastProvider } from '../context/ToastContext'; import { MemoryRouter } from 'react-router-dom'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { PreferencesProvider } from '../context/PreferencesContext'; + describe('Navbar', () => { const mockOnConnect = vi.fn();