diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 483cd26..57c6967 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,8 +18,8 @@ import ShortcutHelpModal from "./components/ShortcutHelpModal"; import "./index.css"; import * as Sentry from "@sentry/react"; -import { fetchUsdcBalance } from "./lib/stellarAccount"; import { useTranslation } from "./i18n"; +import { useUsdcBalance } from "./hooks/useBalanceData"; const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); @@ -67,7 +67,7 @@ const AppErrorFallback = () => { function AppContent() { const [walletAddress, setWalletAddress] = useState(null); - const [usdcBalance, setUsdcBalance] = useState(0); + const { data: usdcBalance = 0 } = useUsdcBalance(walletAddress); const handleConnect = (address: string) => { setWalletAddress(address); @@ -75,7 +75,6 @@ function AppContent() { const handleDisconnect = () => { setWalletAddress(null); - setUsdcBalance(0); }; useEffect(() => { @@ -159,13 +158,24 @@ function AppContent() { } + element={ + + } /> } + element={ + + } /> } /> + Settings Page} /> } /> } /> diff --git a/frontend/src/components/VaultDashboard.tsx b/frontend/src/components/VaultDashboard.tsx index 95dc875..d871ae2 100644 --- a/frontend/src/components/VaultDashboard.tsx +++ b/frontend/src/components/VaultDashboard.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useEffect, useState } from "react"; import { Activity, @@ -16,6 +17,10 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "./Tabs"; import { FormField, SubmitButton } from "../forms"; import CopyButton from "./CopyButton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "./Tabs"; +import { + useDepositMutation, + useWithdrawMutation, +} from "../hooks/useVaultMutations"; interface VaultDashboardProps { walletAddress: string | null; @@ -98,14 +103,19 @@ const VaultDashboard: React.FC = ({ const toast = useToast(); const [activeTab, setActiveTab] = useState<"deposit" | "withdraw">("deposit"); const [amount, setAmount] = useState(""); - const [isProcessing, setIsProcessing] = useState<"deposit" | "withdraw" | null>(null); - const [pendingBalanceChange, setPendingBalanceChange] = useState(0); - const baseBalance = walletAddress ? (usdcBalance > 0 ? usdcBalance : 1250.5) : 0; - const availableBalance = Math.max(0, baseBalance + pendingBalanceChange); + const depositMutation = useDepositMutation(); + const withdrawMutation = useWithdrawMutation(); + + const isProcessing = depositMutation.isPending + ? "deposit" + : withdrawMutation.isPending + ? "withdraw" + : null; + const availableBalance = walletAddress ? usdcBalance : 0; const strategy = summary.strategy; - const handleTransaction = (actionType: "deposit" | "withdraw") => { + const handleTransaction = async (actionType: "deposit" | "withdraw") => { const value = Number(amount); if (!walletAddress) { @@ -117,6 +127,11 @@ const VaultDashboard: React.FC = ({ } if (!amount || Number.isNaN(value) || value <= 0) { + toast.warning({ + title: "Enter a valid amount", + description: + "Choose a valid USDC amount before submitting the transaction.", + }); toast.warning({ title: "Enter a valid amount", description: "Choose a valid USDC amount before submitting the transaction.", @@ -125,6 +140,11 @@ const VaultDashboard: React.FC = ({ } if (actionType === "withdraw" && value > availableBalance) { + toast.warning({ + title: "Insufficient balance", + description: + "The withdrawal amount exceeds your available USDC balance.", + }); toast.warning({ title: "Insufficient balance", description: "The withdrawal amount exceeds your available USDC balance.", @@ -132,13 +152,32 @@ const VaultDashboard: React.FC = ({ return; } - setIsProcessing(actionType); + try { + if (actionType === "deposit") { + await depositMutation.mutateAsync({ walletAddress, amount: value }); + } else { + await withdrawMutation.mutateAsync({ walletAddress, amount: value }); + } - window.setTimeout(() => { - setPendingBalanceChange((prev) => - actionType === "deposit" ? prev + value : prev - value, - ); setAmount(""); + toast.success({ + title: + actionType === "deposit" + ? "Deposit Successful" + : "Withdrawal Successful", + description: + actionType === "deposit" + ? `${value.toFixed(2)} USDC has been deposited into the vault.` + : `${value.toFixed(2)} USDC has been withdrawn from the vault.`, + }); + } catch (err: any) { + toast.error({ + title: "Transaction Failed", + description: + err.message || + "An error occurred during the transaction. Your balance has been restored.", + }); + } setIsProcessing(null); toast.success({ title: actionType === "deposit" ? "Deposit queued" : "Withdrawal queued", @@ -172,7 +211,9 @@ const VaultDashboard: React.FC = ({
-
+
Current APY
= ({ }} /> -
+
= ({ letterSpacing: "0.05em", }} > - + {isLoading ? "Syncing" : "Live"}
@@ -487,7 +534,13 @@ const VaultDashboard: React.FC = ({
-
+
Underlying Asset
@@ -499,7 +552,10 @@ const VaultDashboard: React.FC = ({
-
+

= ({ lineHeight: "1.6", }} > - This vault pools USDC and deploys it into verified tokenized sovereign bonds - available on the Stellar network. Yields are algorithmically harvested and - auto-compounded daily into the vault token price. + This vault pools USDC and deploys it into verified tokenized + sovereign bonds available on the Stellar network. Yields are + algorithmically harvested and auto-compounded daily into the vault + token price.

-
- Strategy: {strategy.name} ({strategy.issuer}) +
+ Strategy:{" "} + + {strategy.name} + {" "} + ({strategy.issuer})
Strategy ID: - {strategy.id} + + {strategy.id} +
-
- RPC: {hasCustomRpcConfig ? "Custom" : "Default"} - {networkConfig.rpcUrl} +
+ RPC: {hasCustomRpcConfig ? "Custom" : "Default"} -{" "} + {networkConfig.rpcUrl}
@@ -569,6 +649,7 @@ const VaultDashboard: React.FC = ({ pointerEvents: "none", }} /> + {!walletAddress && (
= ({ style={{ marginBottom: "16px", opacity: 0.8 }} />

Wallet Not Connected

-

- Please connect your Freighter wallet to deposit USDC and earn RWA yields. +

+ Please connect your Freighter wallet to deposit USDC and earn + RWA yields.

)} @@ -612,6 +700,12 @@ const VaultDashboard: React.FC = ({ {(["deposit", "withdraw"] as const).map((tab) => ( +
+
+
{tab === "deposit" ? "Amount to deposit" : "Amount to withdraw"} @@ -658,56 +752,84 @@ const VaultDashboard: React.FC = ({ - USDC - - setAmount(event.target.value)} - /> - + Balance:{" "} + + {walletAddress ? availableBalance.toFixed(2) : "0.00"} + +
-
-
-
- - BENJI Strategy - - - {strategy.status === "active" ? "Active" : "Inactive"} - -
-
- - Exchange Rate - - - 1 yvUSDC = {summary.exchangeRate.toFixed(3)} USDC - +
+
+ + USDC + + setAmount(e.target.value)} + disabled={isProcessing !== null} + /> + +
-
- + +
+ Network Fee @@ -720,10 +842,12 @@ const VaultDashboard: React.FC = ({ className="btn btn-primary" style={{ width: "100%", padding: "16px", fontSize: "1.1rem" }} onClick={() => handleTransaction(tab)} - disabled={isProcessing !== null || !amount || Number(amount) <= 0} + disabled={ + isProcessing !== null || !amount || Number(amount) <= 0 + } > {isProcessing === tab - ? "Processing Transaction..." + ? "Processing..." : tab === "deposit" ? "Approve & Deposit" : "Withdraw Funds"} diff --git a/frontend/src/hooks/useVaultMutations.ts b/frontend/src/hooks/useVaultMutations.ts index 7f676d3..489f41d 100644 --- a/frontend/src/hooks/useVaultMutations.ts +++ b/frontend/src/hooks/useVaultMutations.ts @@ -1,8 +1,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "../lib/queryClient"; +import { PortfolioHolding } from "../lib/portfolioApi"; /** - * Simulated deposit mutation with cache invalidation. + * Simulated deposit mutation with optimistic UI updates. * In production, this would call the actual contract interaction. */ export function useDepositMutation() { @@ -12,10 +13,77 @@ export function useDepositMutation() { mutationFn: async (params: { walletAddress: string; amount: number }) => { // Simulate API call delay await new Promise((resolve) => setTimeout(resolve, 2000)); + // Randomly fail to test rollback (1 in 10 chance) + if (Math.random() < 0.1) { + throw new Error("Deposit failed (Simulated Error)"); + } return { success: true, ...params }; }, - onSuccess: (_, variables) => { - // Invalidate related queries to trigger refetch + onMutate: async (variables) => { + const { walletAddress, amount } = variables; + + // Cancel outgoing queries to avoid overwriting optimistic update + await queryClient.cancelQueries({ + queryKey: queryKeys.balance.usdc(walletAddress), + }); + await queryClient.cancelQueries({ + queryKey: queryKeys.portfolio.holdings(walletAddress), + }); + + // Snapshot the previous values + const prevBalance = queryClient.getQueryData( + queryKeys.balance.usdc(walletAddress), + ); + const prevHoldings = queryClient.getQueryData( + queryKeys.portfolio.holdings(walletAddress), + ); + + // Optimistically update the balance + if (prevBalance !== undefined) { + queryClient.setQueryData( + queryKeys.balance.usdc(walletAddress), + prevBalance - amount, + ); + } + + // Optimistically update holdings + if (prevHoldings) { + const updatedHoldings = prevHoldings.map((h) => { + if (h.symbol === "yvUSDC") { + return { + ...h, + shares: h.shares + amount, + valueUsd: h.valueUsd + amount, + }; + } + return h; + }); + queryClient.setQueryData( + queryKeys.portfolio.holdings(walletAddress), + updatedHoldings, + ); + } + + return { prevBalance, prevHoldings }; + }, + onError: (err, variables, context) => { + const { walletAddress } = variables; + // Rollback to snapshots + if (context?.prevBalance !== undefined) { + queryClient.setQueryData( + queryKeys.balance.usdc(walletAddress), + context.prevBalance, + ); + } + if (context?.prevHoldings) { + queryClient.setQueryData( + queryKeys.portfolio.holdings(walletAddress), + context.prevHoldings, + ); + } + }, + onSettled: (_, __, variables) => { + // Invalidate related queries to ensure consistency queryClient.invalidateQueries({ queryKey: queryKeys.balance.usdc(variables.walletAddress), }); @@ -33,7 +101,7 @@ export function useDepositMutation() { } /** - * Simulated withdrawal mutation with cache invalidation. + * Simulated withdrawal mutation with optimistic UI updates. * In production, this would call the actual contract interaction. */ export function useWithdrawMutation() { @@ -43,10 +111,77 @@ export function useWithdrawMutation() { mutationFn: async (params: { walletAddress: string; amount: number }) => { // Simulate API call delay await new Promise((resolve) => setTimeout(resolve, 2000)); + // Randomly fail to test rollback (1 in 10 chance) + if (Math.random() < 0.1) { + throw new Error("Withdrawal failed (Simulated Error)"); + } return { success: true, ...params }; }, - onSuccess: (_, variables) => { - // Invalidate related queries to trigger refetch + onMutate: async (variables) => { + const { walletAddress, amount } = variables; + + // Cancel outgoing queries + await queryClient.cancelQueries({ + queryKey: queryKeys.balance.usdc(walletAddress), + }); + await queryClient.cancelQueries({ + queryKey: queryKeys.portfolio.holdings(walletAddress), + }); + + // Snapshot + const prevBalance = queryClient.getQueryData( + queryKeys.balance.usdc(walletAddress), + ); + const prevHoldings = queryClient.getQueryData( + queryKeys.portfolio.holdings(walletAddress), + ); + + // Optimistically update balance (withdrawal increases USDC balance in this simulation) + if (prevBalance !== undefined) { + queryClient.setQueryData( + queryKeys.balance.usdc(walletAddress), + prevBalance + amount, + ); + } + + // Optimistically update holdings + if (prevHoldings) { + const updatedHoldings = prevHoldings.map((h) => { + if (h.symbol === "yvUSDC") { + return { + ...h, + shares: Math.max(0, h.shares - amount), + valueUsd: Math.max(0, h.valueUsd - amount), + }; + } + return h; + }); + queryClient.setQueryData( + queryKeys.portfolio.holdings(walletAddress), + updatedHoldings, + ); + } + + return { prevBalance, prevHoldings }; + }, + onError: (err, variables, context) => { + const { walletAddress } = variables; + // Rollback + if (context?.prevBalance !== undefined) { + queryClient.setQueryData( + queryKeys.balance.usdc(walletAddress), + context.prevBalance, + ); + } + if (context?.prevHoldings) { + queryClient.setQueryData( + queryKeys.portfolio.holdings(walletAddress), + context.prevHoldings, + ); + } + }, + onSettled: (_, __, variables) => { + // Invalidate queryClient.invalidateQueries({ queryKey: queryKeys.balance.usdc(variables.walletAddress), });