diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 14cc04c6..946b7ca7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,7 +13,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.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/components/VaultDashboard.test.tsx b/frontend/src/components/VaultDashboard.test.tsx index acd54167..6a6ce519 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"), }; }); @@ -66,6 +67,7 @@ function renderDashboard( walletAddress: string | null, usdcBalance = 1250.5, initialEntry = "/", + xlmBalance = 10.0, ) { const queryClient = new QueryClient({ defaultOptions: { @@ -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 e6b2533c..aaa53733 100644 --- a/frontend/src/components/VaultDashboard.tsx +++ b/frontend/src/components/VaultDashboard.tsx @@ -89,6 +89,7 @@ const StepIndicator: React.FC<{ currentStep: TransactionStep }> = ({ currentStep interface VaultDashboardProps { walletAddress: string | null; usdcBalance?: number; + xlmBalance?: number; } const MIN_DEPOSIT_AMOUNT = 1; @@ -143,11 +144,52 @@ const VaultCapWarning: React.FC<{ utilization: number; isReached: boolean }> = ( ); }; +function getAmountValidationError( + actionType: TransactionTab, + rawAmount: string, + availableBalance: number, + isCapReached: boolean, + xlmBalance: number, + feeXlm: number, +): string | null { + if (!rawAmount.trim()) { + return "Amount is required."; + } + const value = Number(rawAmount); + if (Number.isNaN(value) || !Number.isFinite(value)) { + return "Enter a valid number."; + } + + if (value <= 0) { + return "Amount must be greater than 0."; + } + + if (actionType === "deposit" && value < MIN_DEPOSIT_AMOUNT) { + return `Minimum deposit is ${MIN_DEPOSIT_AMOUNT.toFixed(2)} USDC.`; + } + + if (value > availableBalance) { + return actionType === "deposit" + ? "Deposit amount cannot exceed your available USDC balance." + : "The withdrawal amount exceeds your available USDC balance."; + } + + if (actionType === "deposit" && isCapReached) { + 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; +} const VaultDashboard: React.FC = ({ walletAddress, usdcBalance = 0, + xlmBalance = 0, }) => { const [searchParams, setSearchParams] = useSearchParams(); const { @@ -268,11 +310,20 @@ const VaultDashboard: React.FC = ({ }; const goToReview = () => { - if (errors.amount || !amount) { - setFieldError("amount", errors.amount || "Amount is required."); + const validationError = getAmountValidationError( + activeTab, + amount, + availableBalance, + isCapReached, + xlmBalance, + feeXlm, + ); + + if (validationError) { + setFieldError("amount", validationError); toast.warning({ title: "Enter a valid amount", - description: errors.amount || "Amount is required.", + description: validationError, }); return; } @@ -312,9 +363,22 @@ const VaultDashboard: React.FC = ({ const strategy = summary.strategy; const enteredAmount = Number(amount); +<<<<<<< HEAD + const activeAmountError = getAmountValidationError( + activeTab, + amount, + availableBalance, + isCapReached, + xlmBalance, + feeXlm, + ); + const isValidAmount = !activeAmountError; + const showInlineError = touched[activeTab] && Boolean(activeAmountError); +======= const activeAmountError = errors.amount; const isValidAmount = !activeAmountError && amount.length > 0; const showInlineError = touched.amount && Boolean(activeAmountError); +>>>>>>> origin/main const managementFeeBps = 35; const estimatedFee = isValidAmount ? (enteredAmount * managementFeeBps) / 10_000 @@ -972,6 +1036,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 }) => {

- + ); }; diff --git a/frontend/src/pages/Portfolio.emptystate.test.tsx b/frontend/src/pages/Portfolio.emptystate.test.tsx index 535388e5..5efd2843 100644 --- a/frontend/src/pages/Portfolio.emptystate.test.tsx +++ b/frontend/src/pages/Portfolio.emptystate.test.tsx @@ -1,4 +1,4 @@ -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"; 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( - + ); 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[]) {