diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 346ee323..69f4948d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,5 @@ +import { lazy, Suspense, useState } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import * as Sentry from "@sentry/react"; @@ -144,6 +146,55 @@ function AppContent() { }, [clearSessionExpired]); return ( + + + Skip to main content + +
+ +
+ }> + + + } + /> + + } + /> + + + + } + /> + } /> + } /> + } /> + } /> + + +
+ +
+
diff --git a/frontend/src/components/ApiStatusBanner.tsx b/frontend/src/components/ApiStatusBanner.tsx index e394b0c6..1c6b9684 100644 --- a/frontend/src/components/ApiStatusBanner.tsx +++ b/frontend/src/components/ApiStatusBanner.tsx @@ -15,6 +15,24 @@ const ApiStatusBanner: FC = ({ error }) => { : `Failed to load vault data. ${error.userMessage}`; return ( +
+
+ {t("apiBanner.title")} +
+
+ {error.userMessage} +
+
Failed to load vault data
+
void; + walletAddress: string | null; + usdcBalance?: number; + onConnect: (address: string) => void; + onDisconnect: () => void; currentPath?: "/" | "/analytics" | "/portfolio"; onNavigate?: (path: "/" | "/analytics" | "/portfolio") => void; walletAddress: string | null; diff --git a/frontend/src/components/TransactionStatusModal.test.tsx b/frontend/src/components/TransactionStatusModal.test.tsx new file mode 100644 index 00000000..e692efae --- /dev/null +++ b/frontend/src/components/TransactionStatusModal.test.tsx @@ -0,0 +1,237 @@ +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import TransactionStatusModal from "./TransactionStatusModal"; + +describe("TransactionStatusModal", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + // Mock navigator.clipboard + Object.defineProperty(navigator, "clipboard", { + value: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + writable: true, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("renders correctly in submitting state when txHash is null", () => { + render( + , + ); + + expect(screen.getByText("Sign Transaction")).toBeInTheDocument(); + expect(screen.getByText(/Please approve the transaction in your Freighter/i)).toBeInTheDocument(); + expect(screen.getByText("Transaction Type")).toBeInTheDocument(); + expect(screen.getByText("deposit")).toBeInTheDocument(); + expect(screen.getByText("150.75 USDC")).toBeInTheDocument(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("renders confirming state and displays hash when non-null txHash is provided", () => { + render( + , + ); + + expect(screen.getByText("Confirming on Ledger")).toBeInTheDocument(); + expect(screen.getByText(/mock_has...cdef/i)).toBeInTheDocument(); + }); + + it("copies transaction hash when Copy button is clicked", async () => { + render( + , + ); + + const copyBtn = screen.getByTitle("Copy transaction hash"); + fireEvent.click(copyBtn); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("mock_hash_1234567890abcdef"); + }); + + it("simulates mock polling and transitions to success state", async () => { + const onSuccess = vi.fn(); + // Force mock mode and patch Math.random to always succeed (value < 0.9) + vi.spyOn(Math, "random").mockReturnValue(0.5); + + render( + , + ); + + // Initial state confirming + expect(screen.getByText("Confirming on Ledger")).toBeInTheDocument(); + + // Advance 3 poll intervals (3 x 2s = 6s) + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + }); + + expect(screen.getByText("Transaction Successful")).toBeInTheDocument(); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it("simulates mock polling and transitions to failure state on simulated random failure", async () => { + const onFailure = vi.fn(); + // Force mock mode and patch Math.random to fail (value >= 0.9) + vi.spyOn(Math, "random").mockReturnValue(0.95); + + render( + , + ); + + // Advance 3 poll intervals + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + }); + + expect(screen.getByText("Transaction Failed")).toBeInTheDocument(); + expect(screen.getByText("Mock transaction failed ledger verification.")).toBeInTheDocument(); + expect(onFailure).toHaveBeenCalledWith("Mock transaction failed ledger verification."); + }); + + it("polls real Horizon API and succeeds when response is successful", async () => { + const onSuccess = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 404 })) // 1st poll: pending + .mockResolvedValueOnce( + new Response(JSON.stringify({ successful: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), // 2nd poll: success + ); + + render( + , + ); + + // 1st poll + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(screen.getByText("Confirming on Ledger")).toBeInTheDocument(); + + // 2nd poll + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(screen.getByText("Transaction Successful")).toBeInTheDocument(); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it("polls real Horizon API and fails when polling times out", async () => { + const onFailure = vi.fn(); + // Always return 404 (pending) + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 404 })), + ); + + render( + , + ); + + // Advance 15 poll intervals (30 seconds) + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + }); + + expect(screen.getByText("Transaction Failed")).toBeInTheDocument(); + expect(screen.getByText(/Transaction polling timed out/i)).toBeInTheDocument(); + expect(onFailure).toHaveBeenCalledWith("Transaction polling timed out."); + }); + + it("calls onClose when Close button is clicked in success or failure states", () => { + const onClose = vi.fn(); + render( + , + ); + + // Immediately shows failure due to external error + expect(screen.getByText("Transaction Failed")).toBeInTheDocument(); + + const closeBtn = screen.getByRole("button", { name: "Close" }); + fireEvent.click(closeBtn); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("has correct ARIA role and attributes for accessibility", () => { + render( + , + ); + + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(dialog).toHaveAttribute("aria-labelledby", "tx-modal-title"); + expect(dialog).toHaveAttribute("aria-describedby", "tx-modal-desc"); + }); +}); diff --git a/frontend/src/components/TransactionStatusModal.tsx b/frontend/src/components/TransactionStatusModal.tsx new file mode 100644 index 00000000..edd433d2 --- /dev/null +++ b/frontend/src/components/TransactionStatusModal.tsx @@ -0,0 +1,455 @@ +import React, { useEffect, useRef, useState, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { Loader2, Check, AlertCircle, Copy, X } from "./icons"; +import { getStellarExplorerUrl, sanitizeExternalLink } from "../lib/security"; + +export type TransactionStatusState = "submitting" | "confirming" | "success" | "failure"; + +interface TransactionStatusModalProps { + isOpen: boolean; + onClose: () => void; + txHash: string | null; + actionType: "deposit" | "withdraw"; + amount: number; + error?: string | null; + onSuccess?: () => void; + onFailure?: (error: string) => void; + mockMode?: boolean; +} + +const HORIZON_BASE_URL = "https://horizon-testnet.stellar.org"; + +function resolveNetworkMode(): "testnet" | "mainnet" { + const networkPassphrase = + import.meta.env.VITE_STELLAR_NETWORK_PASSPHRASE ?? ""; + return networkPassphrase.toLowerCase().includes("public") + ? "mainnet" + : "testnet"; +} + +const TransactionStatusModal: React.FC = ({ + isOpen, + onClose, + txHash, + actionType, + amount, + error: externalError, + onSuccess, + onFailure, + mockMode, +}) => { + const modalRef = useRef(null); + const previousFocusRef = useRef(null); + + const [state, setState] = useState("submitting"); + const [internalError, setInternalError] = useState(null); + const [pollCount, setPollCount] = useState(0); + const [copied, setCopied] = useState(false); + + // Determine actual error message to display + const displayError = externalError || internalError; + + // Handle focus trapping and keyboard esc + useEffect(() => { + if (isOpen) { + previousFocusRef.current = document.activeElement as HTMLElement; + requestAnimationFrame(() => { + const firstInteractive = modalRef.current?.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + firstInteractive?.focus(); + }); + } + + return () => { + if (isOpen) { + previousFocusRef.current?.focus(); + } + }; + }, [isOpen]); + + // Keydown listener for modal trapping + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape" && (state === "success" || state === "failure")) { + onClose(); + return; + } + + if (event.key !== "Tab" || !modalRef.current) { + return; + } + + const focusable = modalRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (focusable.length === 0) { + event.preventDefault(); + return; + } + + const firstElement = focusable[0]; + const lastElement = focusable[focusable.length - 1]; + const activeElement = document.activeElement as HTMLElement | null; + + if (event.shiftKey && activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }; + + // Determine if it's a simulated transaction + const isMock = mockMode !== undefined ? mockMode : (!txHash || txHash.startsWith("mock_")); + + // Check transaction status on Stellar Horizon + const checkTxStatus = useCallback(async (hash: string): Promise => { + const url = `${HORIZON_BASE_URL}/transactions/${hash}`; + try { + const response = await fetch(url); + if (response.ok) { + const data = await response.json(); + return !!data.successful; + } + if (response.status === 404) { + // Still pending + return false; + } + throw new Error(`Horizon API returned status ${response.status}`); + } catch (err: any) { + // Don't fail immediately on network flakes, let polling retry unless we hit max attempts + console.warn("Horizon poll attempt failed:", err.message); + return false; + } + }, []); + + // Update state based on external props + useEffect(() => { + if (externalError) { + setState("failure"); + } else if (txHash && state === "submitting") { + setState("confirming"); + setPollCount(0); + } + }, [txHash, externalError, state]); + + // Reset modal state on reopen + useEffect(() => { + if (isOpen) { + setState(txHash ? "confirming" : "submitting"); + setInternalError(null); + setPollCount(0); + setCopied(false); + } + }, [isOpen, txHash]); + + // Polling logic + useEffect(() => { + if (!isOpen || state !== "confirming" || !txHash) return; + + let timer: NodeJS.Timeout; + const maxPolls = 15; // 30 seconds total at 2s interval + + const poll = async () => { + if (isMock) { + // Simulated polling + setPollCount((prev) => { + const next = prev + 1; + if (next >= 3) { + // Mock transaction success (90% chance) or fail (10% chance) + const isSuccess = Math.random() < 0.9; + if (isSuccess) { + setState("success"); + onSuccess?.(); + } else { + setInternalError("Mock transaction failed ledger verification."); + setState("failure"); + onFailure?.("Mock transaction failed ledger verification."); + } + } + return next; + }); + } else { + // Real Stellar network polling + const isConfirmed = await checkTxStatus(txHash); + if (isConfirmed) { + setState("success"); + onSuccess?.(); + } else { + setPollCount((prev) => { + const next = prev + 1; + if (next >= maxPolls) { + setInternalError("Transaction polling timed out. Please check Stellar Explorer."); + setState("failure"); + onFailure?.("Transaction polling timed out."); + } + return next; + }); + } + } + }; + + timer = setInterval(poll, 2000); + + return () => { + clearInterval(timer); + }; + }, [isOpen, state, txHash, isMock, checkTxStatus, onSuccess, onFailure]); + + // Copy hash handler + const handleCopyHash = () => { + if (!txHash) return; + navigator.clipboard.writeText(txHash); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (!isOpen) return null; + + const truncateHash = (hash: string) => { + if (!hash) return ""; + return `${hash.slice(0, 8)}...${hash.slice(-4)}`; + }; + + const explorerUrl = txHash + ? sanitizeExternalLink(getStellarExplorerUrl(txHash, resolveNetworkMode())) + : null; + + return createPortal( +
+
+ {/* Close button for final states */} + {(state === "success" || state === "failure") && ( + + )} + + {/* State Icon Indicator */} +
+ {state === "success" && } + {state === "failure" && } + {(state === "submitting" || state === "confirming") && ( + + )} +
+ + {/* Status Messaging */} +
+

+ {state === "submitting" && "Sign Transaction"} + {state === "confirming" && "Confirming on Ledger"} + {state === "success" && "Transaction Successful"} + {state === "failure" && "Transaction Failed"} +

+

+ {state === "submitting" && "Please approve the transaction in your Freighter wallet extension."} + {state === "confirming" && + `Broadcasting to Stellar. Waiting for ledger confirmation... (poll #${pollCount})`} + {state === "success" && + `Your transaction has been confirmed on the Stellar network.`} + {state === "failure" && (displayError || "An unexpected error occurred.")} +

+
+ + {/* Transaction Info Panel */} +
+
+ Transaction Type + + {actionType} + +
+ +
+ Amount + + {amount.toFixed(2)} USDC + +
+ + {txHash && ( +
+ Transaction Hash +
+ + {truncateHash(txHash)} + + +
+
+ )} +
+ + {/* Action Button */} +
+ {state === "success" && ( + + )} + + {state === "failure" && ( + + )} + + {(state === "submitting" || state === "confirming") && ( + + )} +
+ + {/* Local styling for animations */} + +
+
, + document.body, + ); +}; + +export default TransactionStatusModal; diff --git a/frontend/src/components/VaultDashboard.tsx b/frontend/src/components/VaultDashboard.tsx index c3240306..f50a847f 100644 --- a/frontend/src/components/VaultDashboard.tsx +++ b/frontend/src/components/VaultDashboard.tsx @@ -1,3 +1,6 @@ +import React, { useEffect, useState } from "react"; +import { Activity, ShieldCheck, TrendingUp, Wallet as WalletIcon } from "./icons"; +import { AlertTriangle, Info } from "lucide-react"; import React, { useEffect, useRef, useState } from "react"; import { ArrowDownUp, ArrowUpRight, Clock3, Menu, X } from "lucide-react"; import { @@ -18,6 +21,10 @@ import SharePriceDisplay from "./SharePriceDisplay"; import VaultPerformanceChart from "./VaultPerformanceChart"; import { useToast } from "../context/ToastContext"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./Tabs"; +import { FormField, SubmitButton } from "../forms"; +import CopyButton from "./CopyButton"; +import { useDepositMutation, useWithdrawMutation } from "../hooks/useVaultMutations"; +import TransactionStatusModal from "./TransactionStatusModal"; import { FormField } from "../forms"; import { isApiError, isValidationError } from "../lib/api"; import { useForm } from "../forms/useForm"; @@ -195,11 +202,32 @@ const VaultCapWarning: React.FC<{ utilization: number; isReached: boolean }> = ( ); }; +function buildFakeTxHash(walletAddress: string, action: "deposit" | "withdraw", amount: number): string { + const seed = `${walletAddress}-${action}-${amount.toFixed(2)}-${Date.now()}`; + let hash = ""; + for (let i = 0; i < 64; i += 1) { + const code = seed.charCodeAt(i % seed.length); + hash += ((code + i * 13) % 16).toString(16); + } + return hash; +} + + + const VaultDashboard: React.FC = ({ walletAddress, usdcBalance = 0, xlmBalance = 0, }) => { + const { formattedTvl, formattedApy, summary, error, isLoading, utilization, isCapReached, isCapWarning } = useVault(); + const toast = useToast(); + const [activeTab, setActiveTab] = useState<"deposit" | "withdraw">("deposit"); + const [amount, setAmount] = useState(""); + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalTxHash, setModalTxHash] = useState(null); + const [modalAmount, setModalAmount] = useState(0); + const [modalActionType, setModalActionType] = useState<"deposit" | "withdraw">("deposit"); + const [modalError, setModalError] = useState(null); const navigate = useNavigate(); const dashboardUrl = useDashboardUrlState(); const { @@ -402,6 +430,9 @@ const VaultDashboard: React.FC = ({ : null; const isBusy = isProcessing !== null; + const isBusy = isProcessing !== null; + + const availableBalance = walletAddress ? usdcBalance : 0; const strategy = summary.strategy; const enteredAmount = Number(amount); const activeAmountError = errors.amount; @@ -572,6 +603,17 @@ const VaultDashboard: React.FC = ({ return; } + setModalAmount(value); + setModalActionType(actionType); + setModalError(null); + setModalTxHash(null); + setIsModalOpen(true); + + try { + // Simulate Freighter signature delay + await new Promise((resolve) => setTimeout(resolve, 800)); + const hash = buildFakeTxHash(walletAddress, actionType, value); + setModalTxHash(hash); if (!options.isRetry) { setRetryCount(0); } @@ -676,6 +718,8 @@ const VaultDashboard: React.FC = ({ ? t("vaultDashboard.depositMessage").replace("{{amount}}", value.toFixed(2)) : t("vaultDashboard.withdrawMessage").replace("{{amount}}", value.toFixed(2)), }); + } catch (err: any) { + setModalError(err.message || "An error occurred during the transaction."); } catch (err: unknown) { if (isTransactionConflict(err)) { setActiveConflict({ @@ -1685,6 +1729,58 @@ const VaultDashboard: React.FC = ({ + + + setAmount(event.target.value)} + disabled={isBusy || (tab === "deposit" && isCapReached)} + /> + +
+ Asset: USDC + +
+ + +
+
+ Estimated protocol fee + + {isValidAmount ? `${estimatedFee.toFixed(4)} USDC` : "0.0000 USDC"} + +
+
+ + {tab === "deposit" ? "Estimated net deposit" : "Estimated net withdrawal"} + + + {isValidAmount ? `${estimatedNetAmount.toFixed(4)} USDC` : "0.0000 USDC"} + +
+
+ Network fee: {summary.networkFeeEstimate} +
+
+ + + )} {dashboardUrl.state.step === "result" && ( @@ -1766,6 +1862,14 @@ const VaultDashboard: React.FC = ({ + setIsModalOpen(false)} + txHash={modalTxHash} + actionType={modalActionType} + amount={modalAmount} + error={modalError} + /> {mobileActionsOpen && (