Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(() => {
Expand Down Expand Up @@ -105,6 +106,7 @@ function AppContent() {
<Home
walletAddress={walletAddress}
usdcBalance={usdcBalance}
xlmBalance={xlmBalance}
/>
}
/>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/VaultDashboard.emptystate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand All @@ -69,6 +70,7 @@ function renderDashboard(
<VaultDashboard
walletAddress={walletAddress}
usdcBalance={usdcBalance}
xlmBalance={xlmBalance}
/>
</VaultProvider>
</ToastProvider>
Expand Down
67 changes: 54 additions & 13 deletions frontend/src/components/VaultDashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ vi.mock("../lib/vaultApi", async (importOriginal) => {
return {
...actual,
submitDeposit: vi.fn(),
estimateNetworkFee: vi.fn().mockResolvedValue("0.05"),
};
});

Expand Down Expand Up @@ -66,6 +67,7 @@ function renderDashboard(
walletAddress: string | null,
usdcBalance = 1250.5,
initialEntry = "/",
xlmBalance = 10.0,
) {
const queryClient = new QueryClient({
defaultOptions: {
Expand All @@ -77,7 +79,11 @@ function renderDashboard(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<VaultProvider>
<VaultDashboard walletAddress={walletAddress} usdcBalance={usdcBalance} />
<VaultDashboard
walletAddress={walletAddress}
usdcBalance={usdcBalance}
xlmBalance={xlmBalance}
/>
<LocationSearchProbe />
</VaultProvider>
</ToastProvider>
Expand Down Expand Up @@ -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();
});
});
});
92 changes: 88 additions & 4 deletions frontend/src/components/VaultDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<VaultDashboardProps> = ({
walletAddress,
usdcBalance = 0,
xlmBalance = 0,
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const {
Expand Down Expand Up @@ -268,11 +310,20 @@ const VaultDashboard: React.FC<VaultDashboardProps> = ({
};

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;
}
Expand Down Expand Up @@ -312,9 +363,22 @@ const VaultDashboard: React.FC<VaultDashboardProps> = ({

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
Expand Down Expand Up @@ -972,6 +1036,25 @@ const VaultDashboard: React.FC<VaultDashboardProps> = ({
</div>
)}

{tab === "deposit" && xlmBalance < feeXlm && (
<div
className="flex items-start gap-sm"
style={{
marginBottom: "20px",
padding: "12px",
borderRadius: "8px",
background: "rgba(255, 69, 58, 0.1)",
border: "1px solid rgba(255, 69, 58, 0.2)",
}}
>
<AlertTriangle size={16} color="var(--text-error)" style={{ marginTop: "2px" }} />
<div style={{ fontSize: "0.82rem", color: "var(--text-error)", lineHeight: "1.4" }}>
<strong style={{ display: "block", marginBottom: "2px" }}>Insufficient XLM balance</strong>
You do not have enough XLM to cover the estimated network fee.
</div>
</div>
)}

{tab === "deposit" && isValidAmount && needsApproval(enteredAmount) && (
<div
className="glass-panel"
Expand Down Expand Up @@ -1050,7 +1133,8 @@ const VaultDashboard: React.FC<VaultDashboardProps> = ({
onClick={() => void handleTransaction(tab)}
disabled={
isBusy ||
(tab === "deposit" && needsApproval(enteredAmount) && approvalStatus !== "confirmed")
(tab === "deposit" && needsApproval(enteredAmount) && approvalStatus !== "confirmed") ||
(tab === "deposit" && xlmBalance < feeXlm)
}
>
{isBusy ? (
Expand Down
25 changes: 24 additions & 1 deletion frontend/src/hooks/useBalanceData.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -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
});
}
2 changes: 2 additions & 0 deletions frontend/src/lib/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
13 changes: 13 additions & 0 deletions frontend/src/lib/stellarAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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;
}

5 changes: 3 additions & 2 deletions frontend/src/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { usePageHeadingFocus } from "../hooks/usePageHeadingFocus";
interface HomeProps {
walletAddress: string | null;
usdcBalance: number;
xlmBalance: number;
}

const Home: React.FC<HomeProps> = ({ walletAddress, usdcBalance }) => {
const Home: React.FC<HomeProps> = ({ walletAddress, usdcBalance, xlmBalance }) => {
const headingRef = usePageHeadingFocus<HTMLHeadingElement>();

return (
Expand All @@ -26,7 +27,7 @@ const Home: React.FC<HomeProps> = ({ walletAddress, usdcBalance }) => {
</p>
</header>

<VaultDashboard walletAddress={walletAddress} usdcBalance={usdcBalance} />
<VaultDashboard walletAddress={walletAddress} usdcBalance={usdcBalance} xlmBalance={xlmBalance} />
</>
);
};
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/Portfolio.emptystate.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/tests/VaultDashboardWizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe("VaultDashboard Wizard", () => {
it("navigates through the deposit wizard steps", async () => {
render(
<Wrapper>
<VaultDashboard walletAddress="GB..." usdcBalance={100} />
<VaultDashboard walletAddress="GB..." usdcBalance={100} xlmBalance={10} />
</Wrapper>
);

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/tests/routing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) {
Expand Down
Loading