diff --git a/react/src/screens/VaultsScreen.test.tsx b/react/src/screens/VaultsScreen.test.tsx index 39fdf22a..c8b30487 100644 --- a/react/src/screens/VaultsScreen.test.tsx +++ b/react/src/screens/VaultsScreen.test.tsx @@ -142,48 +142,6 @@ describe("VaultsScreen", () => { expect(withdrawBtn).toBeDisabled(); }); - it("shows a live preview of the estimated vault shares for a deposit", async () => { - (calculateApy as jest.Mock).mockResolvedValue(null); - - render(); - - const depositInput = screen.getByPlaceholderText( - /enter positive integer amount/i, - ); - fireEvent.change(depositInput, { target: { value: "500" } }); - - await waitFor(() => { - expect( - screen.getByText(/you will receive ~250 vault shares/i), - ).toBeInTheDocument(); - }); - }); - - it("fills the withdraw input with the connected share balance when MAX is clicked and blocks oversized withdrawals", async () => { - (calculateApy as jest.Mock).mockResolvedValue(null); - - render(); - - await waitFor(() => { - expect(screen.getByTestId("share-balance")).toHaveTextContent("100"); - }); - - fireEvent.click(screen.getByRole("button", { name: /^max$/i })); - expect( - screen.getByPlaceholderText(/enter positive integer shares/i), - ).toHaveValue("100"); - - fireEvent.change( - screen.getByPlaceholderText(/enter positive integer shares/i), - { target: { value: "101" } }, - ); - fireEvent.click(screen.getByRole("button", { name: /^withdraw$/i })); - - expect(await screen.findByRole("alert")).toHaveTextContent( - "Withdraw amount cannot exceed your available shares", - ); - }); - it("submitting a valid deposit calls VaultClient.deposit exactly once", async () => { (calculateApy as jest.Mock).mockResolvedValue(null); @@ -199,6 +157,7 @@ describe("VaultsScreen", () => { const depositBtn = screen.getByRole("button", { name: /^deposit$/i }); fireEvent.change(depositInput, { target: { value: "500" } }); + await screen.findByText("Estimated shares: 250"); fireEvent.click(depositBtn); await waitFor(() => { @@ -209,10 +168,15 @@ describe("VaultsScreen", () => { mockAdapter.publicKey, 500n, mockAdapter, + 248n, ); await waitFor(() => { - expect(screen.getByTestId("tx-hash")).toHaveTextContent( + expect(screen.getByTestId("transaction-toast")).toHaveAttribute( + "data-status", + "success", + ); + expect(screen.getByTestId("transaction-toast-hash")).toHaveTextContent( "0xabc123deposit", ); }); @@ -233,6 +197,7 @@ describe("VaultsScreen", () => { const withdrawBtn = screen.getByRole("button", { name: /^withdraw$/i }); fireEvent.change(withdrawInput, { target: { value: "25" } }); + await screen.findByText("You will receive ~50 tokens (no withdrawal fee)"); fireEvent.click(withdrawBtn); await waitFor(() => { @@ -243,15 +208,51 @@ describe("VaultsScreen", () => { mockAdapter.publicKey, 25n, mockAdapter, + 49n, ); await waitFor(() => { - expect(screen.getByTestId("tx-hash")).toHaveTextContent( + expect(screen.getByTestId("transaction-toast")).toHaveAttribute( + "data-status", + "success", + ); + expect(screen.getByTestId("transaction-toast-hash")).toHaveTextContent( "0xdef456withdraw", ); }); }); + it("applies custom slippage using integer floor arithmetic", async () => { + (calculateApy as jest.Mock).mockResolvedValue(null); + + render(); + await screen.findByTestId("share-balance"); + + fireEvent.change( + screen.getByPlaceholderText(/enter positive integer amount/i), + { + target: { value: "500" }, + }, + ); + await screen.findByText("Estimated shares: 250"); + fireEvent.change(screen.getByLabelText("Slippage tolerance"), { + target: { value: "custom" }, + }); + fireEvent.change(screen.getByLabelText("Custom slippage (%)"), { + target: { value: "2.25" }, + }); + fireEvent.click(screen.getByRole("button", { name: /^deposit$/i })); + + await waitFor(() => { + expect(mockClient.deposit).toHaveBeenCalledWith( + mockAdapter.publicKey, + 500n, + mockAdapter, + 244n, + ); + }); + }); + it("shows an error Alert when trying to deposit non-positive or invalid amounts", async () => { render(); @@ -296,6 +297,7 @@ describe("VaultsScreen", () => { const depositBtn = screen.getByRole("button", { name: /^deposit$/i }); fireEvent.change(depositInput, { target: { value: "100" } }); + await screen.findByText("Estimated shares: 50"); fireEvent.click(depositBtn); expect(await screen.findByRole("alert")).toHaveTextContent( diff --git a/react/src/screens/VaultsScreen.tsx b/react/src/screens/VaultsScreen.tsx index beb1f53c..96827307 100644 --- a/react/src/screens/VaultsScreen.tsx +++ b/react/src/screens/VaultsScreen.tsx @@ -1,10 +1,15 @@ // SPDX-License-Identifier: MIT -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { VaultClient, calculateApy } from '@bc-forge/sdk'; -import type { ApyResult, WalletAdapter } from '@bc-forge/sdk'; -import { Alert } from '../components/Alert'; -import { useOptionalBcForgeClient, useWalletContext } from '../context'; -import { isPositiveInteger } from '../utils'; +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { VaultClient, calculateApy } from "@bc-forge/sdk"; +import type { ApyResult, WalletAdapter } from "@bc-forge/sdk"; +import { Alert } from "../components/Alert"; +import { + TransactionToast, + type TransactionToastStatus, +} from "../components/TransactionToast"; +import { useOptionalBcForgeClient, useWalletContext } from "../context"; +import { isPositiveInteger } from "../utils"; + export interface VaultsScreenProps { /** Optional pre-instantiated VaultClient instance */ @@ -30,6 +35,28 @@ export interface VaultsScreenProps { export const EMPTY_STATE_APY_MESSAGE = "Vault has no outstanding shares or insufficient historical data."; +function parseSlippageBps(value: string): bigint | null { + const match = /^(\d{1,3})(?:\.(\d{1,2}))?$/.exec(value.trim()); + if (!match) return null; + const whole = BigInt(match[1] ?? "0"); + const fraction = BigInt((match[2] ?? "").padEnd(2, "0") || "0"); + const bps = whole * 100n + fraction; + return bps <= 10_000n ? bps : null; +} + +function minimumAfterSlippage(amount: bigint, slippageBps: bigint): bigint { + return (amount * (10_000n - slippageBps)) / 10_000n; +} + +function quoteUnderlying( + shares: bigint, + assets: bigint | null, + supply: bigint | null, +): bigint | null { + if (assets === null || supply === null || supply <= 0n) return null; + return (shares * assets) / supply; +} + export const VaultsScreen: React.FC = ({ client, rpcUrl, @@ -49,8 +76,14 @@ export const VaultsScreen: React.FC = ({ const [depositAmount, setDepositAmount] = useState(""); const [withdrawAmount, setWithdrawAmount] = useState(""); + const [slippageChoice, setSlippageChoice] = useState("0.5"); + const [customSlippage, setCustomSlippage] = useState(""); const [error, setError] = useState(null); - const [successTx, setSuccessTx] = useState(null); + const [toast, setToast] = useState<{ + status: TransactionToastStatus; + hash?: string; + message?: string; + } | null>(null); const [isSubmitting, setIsSubmitting] = useState(false); const [isLoadingMetrics, setIsLoadingMetrics] = useState(false); @@ -67,23 +100,32 @@ export const VaultsScreen: React.FC = ({ effectiveAdapter?.publicKey; const isConnected = Boolean(connectedAddress); + const slippageBps = parseSlippageBps( + slippageChoice === "custom" ? customSlippage : slippageChoice, + ); + const parsedDepositAmount = isPositiveInteger(depositAmount) + ? BigInt(depositAmount.trim()) + : null; + const estimatedDepositShares = + parsedDepositAmount === null + ? null + : totalShares === 0n + ? parsedDepositAmount + : totalAssets !== null && totalAssets > 0n && totalShares !== null + ? (parsedDepositAmount * totalShares) / totalAssets + : null; + const parsedWithdrawAmount = isPositiveInteger(withdrawAmount) + ? BigInt(withdrawAmount.trim()) + : null; + const withdrawEstimate = + parsedWithdrawAmount === null + ? null + : quoteUnderlying(parsedWithdrawAmount, totalAssets, totalShares); + const positionValue = + shareBalance === null + ? null + : quoteUnderlying(shareBalance, totalAssets, totalShares); - const depositPreviewShares = useMemo(() => { - const trimmed = depositAmount.trim(); - if ( - !trimmed || - !isPositiveInteger(trimmed) || - sharePrice === null || - sharePrice === 0n - ) { - return null; - } - const amount = BigInt(trimmed); - return amount / sharePrice; - }, [depositAmount, sharePrice]); - - const hasZeroShareBalance = shareBalance === 0n; - const canWithdraw = isConnected && !hasZeroShareBalance && !isSubmitting; const activeClient = useMemo(() => { if (client) return client; @@ -169,7 +211,7 @@ export const VaultsScreen: React.FC = ({ const handleDeposit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - setSuccessTx(null); + setToast(null); if (!connectedAddress || !activeClient) { setError("Wallet must be connected to deposit"); @@ -183,28 +225,42 @@ export const VaultsScreen: React.FC = ({ } const amountBigInt = BigInt(trimmed); + if (slippageBps === null) { + setError("Enter a slippage tolerance between 0 and 100%."); + return; + } + if (estimatedDepositShares === null) { + setError( + "Deposit quote is unavailable. Refresh vault metrics and try again.", + ); + return; + } + setToast({ status: "pending" }); setIsSubmitting(true); try { const result = await activeClient.deposit( connectedAddress, amountBigInt, - (effectiveAdapter ?? walletAdapter) as unknown as import('@stellar/stellar-sdk').Keypair, + (effectiveAdapter ?? walletAdapter) as unknown as import("@stellar/stellar-sdk").Keypair, + minimumAfterSlippage(estimatedDepositShares, slippageBps), ); if (result && result.success) { - setSuccessTx(result.hash); + setToast({ status: "success", hash: result.hash }); setDepositAmount(""); await loadData(); } else { - setError( - result?.hash + setToast({ + status: "error", + hash: result?.hash, + message: result?.hash ? `Deposit failed with transaction hash: ${result.hash}` : "Deposit transaction failed", - ); + }); } } catch (err) { const message = err instanceof Error ? err.message : String(err); - setError(message); + setToast({ status: "error", message }); } finally { setIsSubmitting(false); } @@ -213,7 +269,7 @@ export const VaultsScreen: React.FC = ({ const handleWithdraw = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - setSuccessTx(null); + setToast(null); if (!connectedAddress || !activeClient) { setError("Wallet must be connected to withdraw"); @@ -227,32 +283,44 @@ export const VaultsScreen: React.FC = ({ } const sharesBigInt = BigInt(trimmed); - if (shareBalance !== null && sharesBigInt > shareBalance) { - setError("Withdraw amount cannot exceed your available shares"); + const quote = + parsedWithdrawAmount === sharesBigInt ? withdrawEstimate : null; + if (quote === null) { + setError( + "Withdrawal quote is unavailable. Please wait for the estimate and try again.", + ); + return; + } + if (slippageBps === null) { + setError("Enter a slippage tolerance between 0 and 100%."); return; } + setToast({ status: "pending" }); setIsSubmitting(true); try { const result = await activeClient.withdraw( connectedAddress, sharesBigInt, - (effectiveAdapter ?? walletAdapter) as unknown as import('@stellar/stellar-sdk').Keypair, + (effectiveAdapter ?? walletAdapter) as unknown as import("@stellar/stellar-sdk").Keypair, + minimumAfterSlippage(quote, slippageBps), ); if (result && result.success) { - setSuccessTx(result.hash); + setToast({ status: "success", hash: result.hash }); setWithdrawAmount(""); await loadData(); } else { - setError( - result?.hash + setToast({ + status: "error", + hash: result?.hash, + message: result?.hash ? `Withdraw failed with transaction hash: ${result.hash}` : "Withdraw transaction failed", - ); + }); } } catch (err) { const message = err instanceof Error ? err.message : String(err); - setError(message); + setToast({ status: "error", message }); } finally { setIsSubmitting(false); } @@ -288,14 +356,14 @@ export const VaultsScreen: React.FC = ({ )} - {successTx && ( - setSuccessTx(null)} - > - Transaction confirmed: {successTx} - + {toast && ( + setToast(null)} + /> )} {/* Metrics Section */} @@ -425,6 +493,45 @@ export const VaultsScreen: React.FC = ({ +
+

+ Your Vault Position +

+
+ Shares owned:{" "} + + {shareBalance?.toString() ?? "—"} + +
+
+ Estimated value:{" "} + + {positionValue?.toString() ?? "—"} + {" "} + tokens +
+
+ Estimated yield vs. 1:1 share basis:{" "} + + {positionValue === null || shareBalance === null + ? "—" + : (positionValue - shareBalance).toString()} + {" "} + tokens +
+
+ {/* APY Section */}
= ({ }} /> + + {slippageChoice === "custom" && ( + + )}
- {depositAmount.trim() && - isPositiveInteger(depositAmount.trim()) && - sharePrice !== null && - sharePrice !== 0n - ? `You will receive ~${depositPreviewShares?.toString() ?? "0"} vault shares` - : "Enter an amount to preview the shares minted."} + {estimatedDepositShares === null + ? "Enter an amount to preview shares." + : `Estimated shares: ${estimatedDepositShares.toString()}`}
-
+ setWithdrawAmount(e.target.value)} + disabled={!isConnected || isSubmitting} + style={{ + padding: "8px 12px", + border: "1px solid #d1d5db", + borderRadius: "6px", + fontSize: "14px", + outline: "none", + }} + /> +
+ {withdrawEstimate !== null + ? `You will receive ~${withdrawEstimate.toString()} tokens (no withdrawal fee)` + : "Enter an amount to preview tokens returned."} +