diff --git a/Cargo.lock b/Cargo.lock index 13d85a8c..5dfd7518 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,9 +550,9 @@ checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" [[package]] name = "ethnum" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "ff" diff --git a/docs/FRONTEND_FORM_VALIDATION.md b/docs/FRONTEND_FORM_VALIDATION.md new file mode 100644 index 00000000..fe97809b --- /dev/null +++ b/docs/FRONTEND_FORM_VALIDATION.md @@ -0,0 +1,96 @@ +# Frontend Form Validation + +> **Last Updated:** 2026-07-24 + +This document describes the shared frontend form validation system (`frontend/src/forms/`) and how it is used by the deposit/withdraw wizard in `VaultDashboard.tsx`. It complements [`docs/VAULT_UX_PATTERN_LIBRARY.md`](./VAULT_UX_PATTERN_LIBRARY.md), which defines the wizard's UX rules; this document focuses on the validation *implementation*. + +--- + +## Goals + +- Give users feedback on amount input as early and as accurately as possible, without being noisy while they are still typing their first character. +- Guarantee that a transaction amount which passes frontend validation will not be rejected by the API for a *format* reason (decimal precision, scientific notation, etc.). +- Keep the review/confirm step from ever being reachable with an invalid amount, even if the user never blurs the input field. + +--- + +## Why not Zod on the frontend? + +The API layer (`packages/api-schemas`, consumed by `backend/`) validates requests with [Zod](https://zod.dev). The frontend forms in this repo intentionally do **not** depend on Zod (or any other schema library). `frontend/src/forms/validate.ts` is a small, dependency-free validator built from plain objects (`ValidationSchema`), so that: + +- Fields can be validated synchronously on every keystroke without pulling a schema-parsing runtime into the client bundle. +- Form-specific error copy (e.g. "Minimum deposit is 1.00 USDC.") can reference live values (balances, fee estimates) that don't exist as static schema constraints. + +Where a frontend rule mirrors a server-side constraint, the two are kept in sync explicitly (see below) rather than by sharing a schema object. + +--- + +## Amount format: shared with the API + +`frontend/src/forms/schemas/amountValidation.ts` defines: + +```ts +export const AMOUNT_PATTERN = /^\d+(\.\d{1,7})?$/; +``` + +This is copied from `AmountSchema` in `packages/api-schemas/src/primitives.ts`, which the backend uses to validate deposit/withdrawal amounts. It requires: + +- digits only (no `+`/`-` sign, no leading `.`) +- at most 7 fractional digits (Stellar's stroop precision) +- no scientific notation (`1e5` is rejected) + +`parseAmountInput(rawValue: string)` wraps this pattern with the friendlier, incremental checks the UI needs: + +1. empty input → `"Amount is required."` +2. not a finite number (`"abc"`, `"NaN"`, `"Infinity"`) → `"Enter a valid number."` +3. zero or negative → `"Amount must be greater than 0."` +4. finite and positive, but not in canonical decimal form (scientific notation, too many decimal places) → a "too many decimal places" format error +5. otherwise → `{ ok: true, amount: }` + +Both `depositFormSchema.ts` and `withdrawFormSchema.ts` call `parseAmountInput` first, then layer their own business rules (minimum deposit, balance, vault capacity, XLM fee coverage) on top of the parsed numeric amount. `AMOUNT_PATTERN` and `parseAmountInput` are re-exported from `frontend/src/forms/index.ts` for reuse by other amount inputs. + +--- + +## `useForm` validation lifecycle + +`frontend/src/forms/useForm.ts` exposes: + +| Field / method | Purpose | +| --- | --- | +| `values`, `setValues` | Current field values. `setValues` accepts a value or updater function and revalidates immediately (see below). | +| `touched` | Which fields the user has blurred at least once. | +| `errors` | Errors gated by `touched` (or, once a submit/`validateAll` has been attempted, shown for every field). | +| `hasAttemptedSubmit` | True once `handleSubmit` or `validateAll` has run at least once. | +| `handleChange` / `handleBlur` | Wire directly to an ``. | +| `validateAll(overrideValues?)` | Validates the whole schema, marks every field touched, sets `hasAttemptedSubmit`, and returns `true`/`false`. | +| `resetErrors` | Clears `errors`, `touched`, and `hasAttemptedSubmit` (e.g. when switching tabs). | + +### Revalidation rules + +- **Before any blur/submit**: a field shows no error while the user is still typing it for the first time, even if invalid. This avoids flashing an error before the user has finished entering a value. +- **After a field has been blurred**: it revalidates on every subsequent keystroke, so the error clears (or updates) as soon as the value becomes valid, instead of waiting for another blur. +- **After `validateAll` or a submit attempt**: *every* field revalidates live on every keystroke, since the user has already tried to proceed once. +- `setValues` (e.g. a programmatic "MAX" fill) revalidates using the same rules as `handleChange`, so it will not show an error unless the field is already touched or a submit has been attempted. + +`validateAll` takes an optional `overrideValues` argument. This exists because calling `setValues(...)` followed immediately by `validateAll()` in the same event handler would otherwise read a stale `values` snapshot (React state updates are asynchronous); passing the just-computed values directly avoids that race. See the "MAX" button handler in `VaultDashboard.tsx` for an example. + +--- + +## How `VaultDashboard.tsx` uses this + +- **Balances are tab-specific.** `depositBalance` is the connected wallet's USDC balance; `withdrawBalance` is the sum of `valueUsd` across the user's vault holdings (`usePortfolioHoldings`). The deposit and withdraw schemas are built from the balance that applies to the active tab, and the withdraw schema also receives `xlmBalance`/`feeXlm` so a withdrawal can be blocked if the user can't afford the network fee. +- **Two layers of validation state:** + - `errors` (from `useForm`) is touched-gated and drives the inline field error (`showInlineError`) shown under the input. + - `liveValidationErrors` is computed unconditionally on every render (`validate(schema, values)`) and drives whether the primary CTA (`Review Transaction`) is disabled. This means the button is disabled the instant an amount becomes invalid, even before the user blurs the field — the user simply won't see red inline text until they blur or try to advance. +- **`goToReview` calls `validateAll()`.** If the values aren't valid, the wizard never advances to the review step (all fields become touched and their errors become visible), regardless of what `isSubmitDisabled` happened to compute — this is a defense-in-depth check, not just a UI affordance. +- **The `MAX` button** fills the tab-specific balance (`depositBalance` or `withdrawBalance`) and immediately calls `validateAll(nextValues)` with the value it just set, so the CTA state and any inline errors are correct immediately rather than one render behind. +- **Switching tabs** calls `resetErrors()`, clearing `hasAttemptedSubmit`, `touched`, and `errors` so the new tab starts from the "not yet touched" state described above. + +--- + +## Adding a new amount-driven form + +1. Reuse `parseAmountInput` for the base numeric/format checks instead of re-deriving `Number(value)` logic. +2. Build a `ValidationSchema<...>` (see `depositFormSchema.ts` / `withdrawFormSchema.ts` for the pattern) rather than validating ad hoc in a component. +3. Use `useForm` for field state; prefer `validateAll()` over manually reading `errors` when gating navigation (e.g. wizard steps) so validity checks stay in sync with what's rendered. +4. If the new rule also exists on the API (`packages/api-schemas`), note the relationship in a comment on both sides, the way `AMOUNT_PATTERN` documents its origin. diff --git a/docs/VAULT_UX_PATTERN_LIBRARY.md b/docs/VAULT_UX_PATTERN_LIBRARY.md index 54badb65..8bd86076 100644 --- a/docs/VAULT_UX_PATTERN_LIBRARY.md +++ b/docs/VAULT_UX_PATTERN_LIBRARY.md @@ -1,6 +1,6 @@ # Vault UX Pattern Library -> **Last Updated:** 2026-06-23 +> **Last Updated:** 2026-07-24 This document defines the approved frontend UX patterns for vault-specific interactions in YieldVault-RWA. It is the source of truth for deposit, withdrawal, allowance approval, transaction confirmation, loading, and recovery states. @@ -93,8 +93,10 @@ The amount step should contain: Rules: -- Disable the primary CTA when the wallet is disconnected, input is empty, validation fails, or the vault cannot accept the action. +- Disable the primary CTA when the wallet is disconnected, input is empty, validation fails, or the vault cannot accept the action. The CTA's disabled state must be computed against the full validation schema on every value change (live), not only from errors already surfaced by a blurred/touched field — the user should never be able to reach review with an invalid amount just because they haven't blurred the field yet. +- Inline field errors appear once a field has been blurred (or once the user has attempted to advance), and continue to revalidate live as the user keeps typing so they clear as soon as the value becomes valid. - Validation messages should appear inline and also be summarized via toast only when the user tries to advance with invalid input. +- Advancing to review must re-run full validation (`validateAll`) and block the transition if invalid, independent of whether the CTA happened to be enabled — this is a defense-in-depth check, not just a UI affordance. - If a deep link pre-fills the amount, the value may be hydrated from the URL, but the URL should not trap the user in a stale step. ### 3. Review Step diff --git a/frontend/e2e/deposit-withdraw.spec.ts b/frontend/e2e/deposit-withdraw.spec.ts index 733a6ea2..9274aa6f 100644 --- a/frontend/e2e/deposit-withdraw.spec.ts +++ b/frontend/e2e/deposit-withdraw.spec.ts @@ -18,26 +18,6 @@ import { /** Valid Stellar public key (G + 55 base32 chars) for API validation in submitDeposit / submitWithdrawal. */ const MOCK_ADDRESS = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; -async function confirmInModal(page: Page) { - const modal = page.getByRole('dialog'); - await expect(modal).toBeVisible({ timeout: 15_000 }); - await modal.getByRole('button', { name: /^Confirm( Anyway)?$/i }).click(); -} - -async function confirmDeposit(page: Page) { - const confirmBtn = page.getByRole('button', { name: /Confirm deposit/i }); - await expect(confirmBtn).toBeEnabled(); - await confirmBtn.click(); - await confirmInModal(page); -} - -async function confirmWithdrawal(page: Page) { - const confirmBtn = page.getByRole('button', { name: /Confirm withdraw/i }); - await expect(confirmBtn).toBeEnabled(); - await confirmBtn.click(); - await confirmInModal(page); -} - const SHORT_ADDR = `${MOCK_ADDRESS.substring(0, 5)}...${MOCK_ADDRESS.substring(MOCK_ADDRESS.length - 4)}`; async function goToConnectedVault(page: Page, path = '/') { @@ -46,15 +26,6 @@ async function goToConnectedVault(page: Page, path = '/') { await waitForMockUsdcBalance(page); } -/** Switch vault tabs via URL deep link (tab button clicks do not sync search params in preview builds). */ -async function switchVaultTab(page: Page, tab: 'deposit' | 'withdraw') { - await page.goto(`/?tab=${tab}`); - await expect(page.getByText(SHORT_ADDR)).toBeVisible({ timeout: 10_000 }); - await expect( - page.getByText(tab === 'deposit' ? 'Amount to deposit' : 'Amount to withdraw'), - ).toBeVisible({ timeout: 10_000 }); -} - // Tests that verify unauthenticated UI no Freighter stub injected test.describe('Deposit panel no wallet', () => { test.beforeEach(async ({ page }) => { diff --git a/frontend/src/components/Navbar.test.tsx b/frontend/src/components/Navbar.test.tsx index 7cb803e2..5336fafe 100644 --- a/frontend/src/components/Navbar.test.tsx +++ b/frontend/src/components/Navbar.test.tsx @@ -64,7 +64,8 @@ describe('Navbar', () => { it('shows the truncated wallet address when connected', () => { const fullAddress = 'GABC1234567890123456789012345678901234567890123456789012'; - const expectedAddress = 'GABC1...9012'; + // Default preference masks sensitive identifiers (keepEdges: 4 + 8 bullets). + const expectedAddress = 'GABC••••••••9012'; render( diff --git a/frontend/src/components/OfflineBanner.test.tsx b/frontend/src/components/OfflineBanner.test.tsx index 9c473e75..f9e2702a 100644 --- a/frontend/src/components/OfflineBanner.test.tsx +++ b/frontend/src/components/OfflineBanner.test.tsx @@ -27,19 +27,6 @@ async function flushMicrotasks() { }); } -async function renderOnlineSuccessBanner() { - vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: false }); - const view = render(); - expect(screen.getByText(/You are offline/i)).toBeInTheDocument(); - - vi.mocked(useNetworkStatus).mockReturnValue({ isOnline: true }); - vi.mocked(useRetryState).mockReturnValue({ isRetrying: false, secondsUntilRetry: null }); - view.rerender(); - await flushMicrotasks(); - - return view; -} - describe("OfflineBanner", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/frontend/src/components/TransactionConfirmationModal.test.tsx b/frontend/src/components/TransactionConfirmationModal.test.tsx index d957e8f7..cfbf2bcd 100644 --- a/frontend/src/components/TransactionConfirmationModal.test.tsx +++ b/frontend/src/components/TransactionConfirmationModal.test.tsx @@ -66,7 +66,9 @@ describe('TransactionConfirmationModal', () => { it('displays contract address in monospace font', () => { render(); const addressText = screen.getByText(mockSummary.contractAddress); - expect(addressText.parentElement).toHaveStyle({ fontFamily: 'monospace' }); + const monospaceContainer = addressText.parentElement; + expect(monospaceContainer).not.toBeNull(); + expect(monospaceContainer?.style.fontFamily).toBe('monospace'); }); }); diff --git a/frontend/src/components/VaultDashboard.test.tsx b/frontend/src/components/VaultDashboard.test.tsx index c438a859..664e92e9 100644 --- a/frontend/src/components/VaultDashboard.test.tsx +++ b/frontend/src/components/VaultDashboard.test.tsx @@ -11,11 +11,15 @@ import type { VaultSummary } from "../lib/vaultApi"; import * as portfolioHooks from "../hooks/usePortfolioData"; import * as vaultDataHooks from "../hooks/useVaultData"; import * as tokenAllowanceHooks from "../hooks/useTokenAllowance"; -import * as vaultMutations from "../hooks/useVaultMutations"; import type { UseQueryResult } from "@tanstack/react-query"; import type { PortfolioHolding } from "../lib/portfolioApi"; import confetti from "canvas-confetti"; +const { mockDepositMutateAsync, mockWithdrawMutateAsync } = vi.hoisted(() => ({ + mockDepositMutateAsync: vi.fn(), + mockWithdrawMutateAsync: vi.fn(), +})); + vi.mock("canvas-confetti", () => ({ default: vi.fn(), })); @@ -40,11 +44,11 @@ vi.mock("../hooks/useVaultData", () => ({ vi.mock("../hooks/useVaultMutations", () => ({ useDepositMutation: vi.fn(() => ({ - mutateAsync: vi.fn().mockResolvedValue({}), + mutateAsync: mockDepositMutateAsync, isPending: false, })), useWithdrawMutation: vi.fn(() => ({ - mutateAsync: vi.fn().mockResolvedValue({}), + mutateAsync: mockWithdrawMutateAsync, isPending: false, })), })); @@ -59,7 +63,7 @@ vi.mock("../hooks/useFeeEstimate", () => ({ feeUsd: 0.01, isEstimating: false, isHighFee: false, - lastUpdated: new Date("2026-03-25T10:00:00.000Z"), + lastUpdated: new Date(), }), })); @@ -80,7 +84,7 @@ const mockSummary = { assetLabel: "Sovereign Debt", exchangeRate: 1.084, networkFeeEstimate: "~0.00001 XLM", - updatedAt: "2026-03-25T10:00:00.000Z", + updatedAt: new Date().toISOString(), contractPaused: false, strategy: { id: "stellar-benji", @@ -177,7 +181,16 @@ describe("VaultDashboard", () => { approve: vi.fn().mockResolvedValue(undefined), resetApproval: vi.fn(), }); - window.matchMedia = vi.fn().mockReturnValue({ matches: false } as MediaQueryList); + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); localStorage.clear(); }); @@ -203,7 +216,7 @@ describe("VaultDashboard", () => { expect(screen.queryByText(/Wallet Not Connected/i)).not.toBeInTheDocument(); expect(screen.getByText(/Global RWA Yield Fund/i)).toBeInTheDocument(); expect(screen.getByText(/Current APY/i)).toBeInTheDocument(); - expect(screen.getByText(/APY quote fresh/i)).toBeInTheDocument(); + expect(await screen.findByText(/Fresh just now/i)).toBeInTheDocument(); expect(await screen.findByText(/Sovereign Debt/i)).toBeInTheDocument(); expect(screen.getByText(/Strategy ID:/i)).toBeInTheDocument(); @@ -234,12 +247,8 @@ describe("VaultDashboard", () => { const submitPromise = new Promise((resolve) => { resolveSubmit = resolve; }); - const mutateAsync = vi.fn().mockReturnValue(submitPromise); - vi.mocked(vaultMutations.useDepositMutation).mockReturnValue({ - mutateAsync, - isPending: false, - } as unknown as ReturnType); - + mockDepositMutateAsync.mockReturnValue(submitPromise); + renderDashboard("GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); expect(await screen.findByText(/Review Transaction/i)).toBeInTheDocument(); @@ -254,7 +263,7 @@ describe("VaultDashboard", () => { fireEvent.click(reviewConfirmButton); await waitFor(() => { - expect(mutateAsync).toHaveBeenCalled(); + expect(mockDepositMutateAsync).toHaveBeenCalled(); }, { timeout: 10000 }); expect(screen.getByText(/Fee quote fresh/i)).toBeInTheDocument(); @@ -268,12 +277,6 @@ describe("VaultDashboard", () => { }, 15000); it("plays the first deposit confetti once per wallet", async () => { - const mutateAsync = vi.fn().mockResolvedValue({}); - vi.mocked(vaultMutations.useDepositMutation).mockReturnValue({ - mutateAsync, - isPending: false, - } as unknown as ReturnType); - renderDashboard("GFIRSTDEPOSITWALLET000000000000000000000000000000"); const input = await screen.findByPlaceholderText("0.00"); @@ -282,14 +285,14 @@ describe("VaultDashboard", () => { fireEvent.click(await screen.findByRole("button", { name: /Confirm deposit/i })); await waitFor(() => { - expect(mutateAsync).toHaveBeenCalled(); + expect(mockDepositMutateAsync).toHaveBeenCalled(); }); expect(confetti).toHaveBeenCalled(); expect(localStorage.getItem("yieldvault:first-deposit:GFIRSTDEPOSITWALLET000000000000000000000000000000")).toBe("true"); }); - it("fills the input with max allowable amount via MAX button", async () => { + it("fills the deposit input with max allowable amount via MAX button", async () => { renderDashboard("GABC123"); expect(await screen.findByText(/Review Transaction/i)).toBeInTheDocument(); @@ -298,13 +301,21 @@ describe("VaultDashboard", () => { fireEvent.click(maxButton); const depositInput = screen.getByLabelText("Deposit amount"); expect(depositInput).toHaveValue(1250.5); + }); + + it("fills the withdraw input with the vault holdings value via MAX button", async () => { + renderDashboard("GABC123"); + + expect(await screen.findByText(/Review Transaction/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Withdraw" })); await waitFor(() => { expect(screen.getByTestId("location-search")).toHaveTextContent("tab=withdraw"); }); + fireEvent.click(screen.getByRole("button", { name: "MAX" })); - expect(screen.getByLabelText("Withdrawal amount")).toHaveValue(1250.5); + // Holdings mock has a single position worth 100 USD, not the deposit (USDC wallet) balance. + expect(screen.getByLabelText("Withdrawal amount")).toHaveValue(100); }); it("shows inline error and blocks submit for amounts above balance", async () => { @@ -342,6 +353,48 @@ describe("VaultDashboard", () => { }); }); + it("blocks review before the field is blurred once the amount is below the minimum", async () => { + renderDashboard("GABC123"); + + expect(await screen.findByText(/Review Transaction/i)).toBeInTheDocument(); + + const input = screen.getByPlaceholderText("0.00"); + fireEvent.change(input, { target: { value: "0.5" } }); + + // No blur yet, so no inline error is shown, but the CTA must still be + // disabled — validity is computed live, not only from touched errors. + expect(screen.queryByText(/Minimum deposit is 1.00 USDC./i)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Review Transaction" })).toBeDisabled(); + }); + + it("shows a format error for amounts with more than 7 decimal places", async () => { + renderDashboard("GABC123"); + + expect(await screen.findByText(/Review Transaction/i)).toBeInTheDocument(); + + const input = screen.getByPlaceholderText("0.00"); + fireEvent.change(input, { target: { value: "10.123456789" } }); + fireEvent.blur(input); + + expect( + screen.getByText(/up to 7 decimal places/i), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Review Transaction" })).toBeDisabled(); + }); + + it("shows an error when a withdrawal exceeds the vault balance", async () => { + renderDashboard("GABC123", 1250.5, "/?tab=withdraw"); + + const input = await screen.findByPlaceholderText("0.00"); + fireEvent.change(input, { target: { value: "150" } }); + fireEvent.blur(input); + + expect( + screen.getByText(/Withdrawal amount cannot exceed your available vault balance of 100.00./i), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Review Transaction" })).toBeDisabled(); + }); + it("shows a normalized API error message when data loading fails", async () => { vi.useRealTimers(); vi.mocked(vaultDataHooks.useVaultSummary).mockReturnValue({ @@ -362,11 +415,14 @@ describe("VaultDashboard", () => { renderDashboard("GABC123", 1250.5, "/?tab=deposit&amount=100&ref=partner"); const input = await screen.findByPlaceholderText("0.00"); - await waitFor(() => { - expect(input).toHaveValue(100); - }); + await waitFor( + () => { + expect(input).toHaveValue(100); + }, + { timeout: 10000 }, + ); expect(screen.getByTestId("location-search")).toHaveTextContent("ref=partner"); - }); + }, 15000); it("ignores invalid deep-link amounts and removes deep-link params", async () => { renderDashboard("GABC123", 1250.5, "/?tab=deposit&amount=oops"); @@ -410,7 +466,7 @@ describe("VaultDashboard", () => { }); }); - it("shows warning banner and disables confirm button on review step when XLM balance is insufficient", async () => { + it("keeps user on amount step when XLM insufficient, rather than allowing review", async () => { renderDashboard("GABC123", 1250.5, "/", 0.01); expect(await screen.findByText(/Review Transaction/i)).toBeInTheDocument(); @@ -419,12 +475,15 @@ describe("VaultDashboard", () => { fireEvent.change(inputField, { target: { value: "100" } }); const reviewBtn = screen.getByRole("button", { name: "Review Transaction" }); + expect(reviewBtn).toBeDisabled(); + fireEvent.click(reviewBtn); + // Clicking a disabled CTA must not advance the wizard — the confirm + // step should never be reached while XLM is insufficient. 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(); + expect(screen.getByText(/Amount to deposit/i)).toBeInTheDocument(); + expect(screen.queryByText("Confirm Transaction")).not.toBeInTheDocument(); }); }); }); diff --git a/frontend/src/components/VaultDashboard.tsx b/frontend/src/components/VaultDashboard.tsx index 9e7bae3e..f60f1798 100644 --- a/frontend/src/components/VaultDashboard.tsx +++ b/frontend/src/components/VaultDashboard.tsx @@ -21,9 +21,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./Tabs"; import { FormField } from "../forms"; import { isValidationError } from "../lib/api"; import { useForm } from "../forms/useForm"; -import type { ValidationSchema } from "../forms/validate"; +import { validate, type ValidationSchema } from "../forms/validate"; import { useDepositMutation, useWithdrawMutation } from "../hooks/useVaultMutations"; import { useTokenAllowance } from "../hooks/useTokenAllowance"; +import { usePortfolioHoldings } from "../hooks/usePortfolioData"; import { createDepositFormSchema, MIN_DEPOSIT_AMOUNT } from "../forms/schemas/depositFormSchema"; import { createWithdrawFormSchema } from "../forms/schemas/withdrawFormSchema"; import { mapServerError } from "../lib/errorMappers"; @@ -218,7 +219,16 @@ const VaultDashboard: React.FC = ({ }); const { isStale: statsIsStale, ageText: statsAgeText } = useStaleIndicator(lastUpdate); - const availableBalance = walletAddress ? usdcBalance : 0; + const { data: portfolioHoldings } = usePortfolioHoldings(walletAddress); + + // Deposit balance comes from the connected wallet's USDC balance; withdraw + // balance is the user's current vault position (sum of holdings value). + const depositBalance = walletAddress ? usdcBalance : 0; + const withdrawBalance = walletAddress + ? (portfolioHoldings ?? []).reduce((sum, holding) => sum + holding.valueUsd, 0) + : 0; + const availableBalance = + dashboardUrl.state.tab === "deposit" ? depositBalance : withdrawBalance; // Wizard state const [transactionResult, setTransactionResult] = useState<{ @@ -258,11 +268,11 @@ const VaultDashboard: React.FC = ({ // Create validation schema based on transaction type and current state const transactionSchema = React.useMemo>(() => { if (dashboardUrl.state.tab === "deposit") { - return createDepositFormSchema(availableBalance, isCapReached, xlmBalance, feeXlm); + return createDepositFormSchema(depositBalance, isCapReached, xlmBalance, feeXlm); } else { - return createWithdrawFormSchema(availableBalance); + return createWithdrawFormSchema(withdrawBalance, xlmBalance, feeXlm); } - }, [dashboardUrl.state.tab, availableBalance, isCapReached, xlmBalance, feeXlm]); + }, [dashboardUrl.state.tab, depositBalance, withdrawBalance, isCapReached, xlmBalance, feeXlm]); const { values, @@ -273,8 +283,17 @@ const VaultDashboard: React.FC = ({ setValues, setFieldError, resetErrors, + validateAll, } = useForm({ amount: dashboardUrl.state.amount }, transactionSchema); + // Validation computed against the full schema regardless of touched state, + // so the primary CTA can be disabled proactively (before the user blurs + // the field) instead of only reacting to already-surfaced inline errors. + const liveValidationErrors = React.useMemo( + () => validate(transactionSchema, values), + [transactionSchema, values], + ); + const amount = values.amount; const activeTab = dashboardUrl.state.tab; const activeStep = dashboardUrl.state.step; @@ -385,8 +404,8 @@ const VaultDashboard: React.FC = ({ const isSubmitDisabled = !walletAddress || isBusy || - Boolean(activeAmountError) || !amount || + Object.keys(liveValidationErrors).length > 0 || (dashboardUrl.state.tab === "deposit" && isCapReached); const staleGuard = useStaleSubmissionGuard({ @@ -419,10 +438,10 @@ const VaultDashboard: React.FC = ({ }; const goToReview = () => { - if (Object.keys(errors).length > 0) { + if (!validateAll()) { toast.warning({ title: "Please fix validation errors", - description: errors.amount || "Please enter a valid amount", + description: liveValidationErrors.amount || "Please enter a valid amount", }); formFocus.focusFirstError(); return; @@ -959,7 +978,9 @@ const VaultDashboard: React.FC = ({ - {(["deposit", "withdraw"] as const).map((tab) => ( + {(["deposit", "withdraw"] as const).map((tab) => { + const tabBalance = tab === "deposit" ? depositBalance : withdrawBalance; + return ( {(isCapReached || isCapWarning) && tab === "deposit" && ( @@ -978,7 +999,7 @@ const VaultDashboard: React.FC = ({ {tab === "deposit" ? "Amount to deposit" : "Amount to withdraw"}
- Balance: {availableBalance.toFixed(2)} + Balance: {tabBalance.toFixed(2)}
@@ -994,7 +1015,7 @@ const VaultDashboard: React.FC = ({ onBlur={handleBlur} disabled={isBusy || (tab === "deposit" && isCapReached)} error={showInlineError ? activeAmountError ?? undefined : undefined} - helperText={tab === "deposit" ? `Min: ${MIN_DEPOSIT_AMOUNT.toFixed(2)} USDC` : `Max: ${availableBalance.toFixed(2)} USDC`} + helperText={tab === "deposit" ? `Min: ${MIN_DEPOSIT_AMOUNT.toFixed(2)} USDC` : `Max: ${tabBalance.toFixed(2)} USDC`} />
@@ -1038,11 +1059,13 @@ const VaultDashboard: React.FC = ({ id={`vault-${tab}-max`} className="btn-max" onClick={() => { - setValues({ amount: availableBalance.toFixed(2) }); + const nextValues = { amount: tabBalance.toFixed(2) }; + setValues(nextValues); + validateAll(nextValues); }} disabled={ !walletAddress || - availableBalance <= 0 || + tabBalance <= 0 || isBusy || (tab === "deposit" && isCapReached) } @@ -1406,7 +1429,8 @@ const VaultDashboard: React.FC = ({ )}
- ))} + ); + })}
diff --git a/frontend/src/components/WalletConnect.test.tsx b/frontend/src/components/WalletConnect.test.tsx index 96d9db6a..41e5b7e7 100644 --- a/frontend/src/components/WalletConnect.test.tsx +++ b/frontend/src/components/WalletConnect.test.tsx @@ -5,6 +5,7 @@ import WalletConnect from './WalletConnect'; import * as freighter from '@stellar/freighter-api'; import * as walletSession from '../lib/walletSession'; import { ToastProvider } from '../context/ToastContext'; +import { PreferencesProvider } from '../context/PreferencesContext'; // Mock freighter-api @@ -28,9 +29,11 @@ const mockedFreighter = vi.mocked(freighter); const mockedWalletSession = vi.mocked(walletSession); const WalletConnectWrapper: React.FC> = (props) => ( - - - + + + + + ); describe('WalletConnect', () => { @@ -81,7 +84,8 @@ describe('WalletConnect', () => { // Button should change to error state, toast shown // Check for the error icon/state via tooltip or visually const btn = screen.getByText(/Connect Freighter/i).closest('button'); - expect(btn).toHaveClass('btn-error'); + expect(btn).toHaveClass('btn-danger'); + expect(btn).toHaveClass('is-error'); }); }); @@ -212,7 +216,8 @@ describe('WalletConnect', () => { it('shows the formatted address when connected', () => { const fullAddress = 'GABC1234567890123456789012345678901234567890123456789012'; - const expectedAddress = 'GABC1...9012'; + // Default preference masks sensitive identifiers (keepEdges: 4 + 8 bullets). + const expectedAddress = 'GABC••••••••9012'; render( = ({ ...options }: ToastOptions) => { const dedupeKey = generateDedupeKey({ ...options, variant }); + // Timestamp is only read when showToast is invoked from event handlers, not during render. + // eslint-disable-next-line react-hooks/purity -- event-handler path; Date.now needed for dedupe window const now = Date.now(); // Check for duplicate within dedupe window diff --git a/frontend/src/forms/index.ts b/frontend/src/forms/index.ts index b21e2984..9ee86aab 100644 --- a/frontend/src/forms/index.ts +++ b/frontend/src/forms/index.ts @@ -17,3 +17,6 @@ export type { DepositFormValues } from "./schemas/depositFormSchema"; export { createWithdrawFormSchema } from "./schemas/withdrawFormSchema"; export type { WithdrawFormValues } from "./schemas/withdrawFormSchema"; + +export { AMOUNT_PATTERN, parseAmountInput } from "./schemas/amountValidation"; +export type { ParseAmountResult } from "./schemas/amountValidation"; diff --git a/frontend/src/forms/schemas/amountValidation.test.ts b/frontend/src/forms/schemas/amountValidation.test.ts new file mode 100644 index 00000000..e90aefb4 --- /dev/null +++ b/frontend/src/forms/schemas/amountValidation.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { AMOUNT_PATTERN, parseAmountInput } from "./amountValidation"; + +describe("AMOUNT_PATTERN", () => { + it("matches the API's AmountSchema pattern", () => { + expect(AMOUNT_PATTERN.source).toBe("^\\d+(\\.\\d{1,7})?$"); + }); + + it("accepts whole numbers and up to 7 decimal places", () => { + expect(AMOUNT_PATTERN.test("10")).toBe(true); + expect(AMOUNT_PATTERN.test("0.1")).toBe(true); + expect(AMOUNT_PATTERN.test("10.1234567")).toBe(true); + }); + + it("rejects more than 7 decimal places", () => { + expect(AMOUNT_PATTERN.test("10.12345678")).toBe(false); + }); + + it("rejects scientific notation", () => { + expect(AMOUNT_PATTERN.test("1e5")).toBe(false); + expect(AMOUNT_PATTERN.test("1E5")).toBe(false); + }); + + it("rejects a leading sign or leading dot", () => { + expect(AMOUNT_PATTERN.test("+10")).toBe(false); + expect(AMOUNT_PATTERN.test("-10")).toBe(false); + expect(AMOUNT_PATTERN.test(".5")).toBe(false); + }); +}); + +describe("parseAmountInput", () => { + it("returns ok:false with a required message for empty input", () => { + expect(parseAmountInput("")).toEqual({ + ok: false, + error: "Amount is required.", + }); + expect(parseAmountInput(" ")).toEqual({ + ok: false, + error: "Amount is required.", + }); + }); + + it("returns ok:false for non-numeric input", () => { + expect(parseAmountInput("abc")).toEqual({ + ok: false, + error: "Enter a valid number.", + }); + }); + + it("returns ok:false for NaN and Infinity strings", () => { + expect(parseAmountInput(String(Number.NaN))).toEqual({ + ok: false, + error: "Enter a valid number.", + }); + expect(parseAmountInput(String(Number.POSITIVE_INFINITY))).toEqual({ + ok: false, + error: "Enter a valid number.", + }); + }); + + it("returns ok:false for zero or negative amounts", () => { + expect(parseAmountInput("0")).toEqual({ + ok: false, + error: "Amount must be greater than 0.", + }); + expect(parseAmountInput("-10")).toEqual({ + ok: false, + error: "Amount must be greater than 0.", + }); + }); + + it("returns a format error for scientific notation", () => { + const result = parseAmountInput("1e5"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/up to 7 decimal places/); + } + }); + + it("returns a format error for more than 7 decimal places", () => { + const result = parseAmountInput("1.123456789"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/up to 7 decimal places/); + } + }); + + it("returns ok:true with the parsed numeric amount for valid input", () => { + expect(parseAmountInput("100")).toEqual({ ok: true, amount: 100 }); + expect(parseAmountInput("100.50")).toEqual({ ok: true, amount: 100.5 }); + expect(parseAmountInput(" 10.1234567 ")).toEqual({ + ok: true, + amount: 10.1234567, + }); + }); +}); diff --git a/frontend/src/forms/schemas/amountValidation.ts b/frontend/src/forms/schemas/amountValidation.ts new file mode 100644 index 00000000..7c8263e7 --- /dev/null +++ b/frontend/src/forms/schemas/amountValidation.ts @@ -0,0 +1,53 @@ +/** + * Shared amount parsing rules for deposit/withdraw forms. + * + * Mirrors the API's `AmountSchema` (`packages/api-schemas/src/primitives.ts`): + * a positive decimal string with at most 7 fractional digits (Stellar's + * stroop precision) and no scientific notation. Keeping the pattern in sync + * means a value the frontend accepts will not be rejected by the API, and + * vice versa. + */ + +/** Matches `AmountSchema` in `packages/api-schemas/src/primitives.ts`. */ +export const AMOUNT_PATTERN = /^\d+(\.\d{1,7})?$/; + +export type ParseAmountResult = + | { ok: true; amount: number } + | { ok: false; error: string }; + +/** + * Parses a raw form input string into a validated amount. + * + * Validation order intentionally matches the messages already surfaced by + * `depositFormSchema` / `withdrawFormSchema`: + * 1. empty input -> "Amount is required." + * 2. not a finite number (e.g. "abc", "NaN", "Infinity") -> "Enter a valid number." + * 3. zero or negative -> "Amount must be greater than 0." + * 4. finite, positive, but not in canonical decimal form (scientific + * notation, more than 7 decimal places, leading "+", etc.) -> format error. + */ +export function parseAmountInput(rawValue: string): ParseAmountResult { + const value = rawValue.trim(); + + if (value.length === 0) { + return { ok: false, error: "Amount is required." }; + } + + const numeric = Number(value); + if (Number.isNaN(numeric) || !Number.isFinite(numeric)) { + return { ok: false, error: "Enter a valid number." }; + } + + if (numeric <= 0) { + return { ok: false, error: "Amount must be greater than 0." }; + } + + if (!AMOUNT_PATTERN.test(value)) { + return { + ok: false, + error: "Enter an amount using digits only, with up to 7 decimal places.", + }; + } + + return { ok: true, amount: numeric }; +} diff --git a/frontend/src/forms/schemas/depositFormSchema.test.ts b/frontend/src/forms/schemas/depositFormSchema.test.ts index a208fa51..09feb424 100644 --- a/frontend/src/forms/schemas/depositFormSchema.test.ts +++ b/frontend/src/forms/schemas/depositFormSchema.test.ts @@ -105,6 +105,38 @@ describe("Deposit Form Schema", () => { }); }); + describe("amount format validation (API AmountSchema parity)", () => { + it("rejects scientific notation", () => { + const schema = createDepositFormSchema(1000, false, 100, 0.01); + const errors = validate(schema, { amount: "1e5" }); + expect(errors.amount).toBe( + "Enter an amount using digits only, with up to 7 decimal places.", + ); + }); + + it("rejects more than 7 decimal places", () => { + const schema = createDepositFormSchema(1000, false, 100, 0.01); + const errors = validate(schema, { amount: "1.12345678" }); + expect(errors.amount).toBe( + "Enter an amount using digits only, with up to 7 decimal places.", + ); + }); + + it("rejects leading plus sign", () => { + const schema = createDepositFormSchema(1000, false, 100, 0.01); + const errors = validate(schema, { amount: "+10" }); + expect(errors.amount).toBe( + "Enter an amount using digits only, with up to 7 decimal places.", + ); + }); + + it("accepts exactly 7 decimal places", () => { + const schema = createDepositFormSchema(1000, false, 100, 0.01); + const errors = validate(schema, { amount: "1.1234567" }); + expect(errors.amount).toBeUndefined(); + }); + }); + describe("valid deposits", () => { it("accepts valid deposit amount", () => { const schema = createDepositFormSchema(1000, false, 100, 0.01); diff --git a/frontend/src/forms/schemas/depositFormSchema.ts b/frontend/src/forms/schemas/depositFormSchema.ts index aa7cf7df..5985d485 100644 --- a/frontend/src/forms/schemas/depositFormSchema.ts +++ b/frontend/src/forms/schemas/depositFormSchema.ts @@ -10,6 +10,7 @@ */ import type { ValidationSchema } from "../validate"; +import { parseAmountInput } from "./amountValidation"; export interface DepositFormValues { amount: string; @@ -37,16 +38,11 @@ export function createDepositFormSchema( amount: { required: "Amount is required.", custom: (value) => { - // Check if value is a valid number - const num = Number(value); - if (Number.isNaN(num) || !Number.isFinite(num)) { - return "Enter a valid number."; - } - - // Amount must be greater than 0 - if (num <= 0) { - return "Amount must be greater than 0."; + const parsed = parseAmountInput(value); + if (!parsed.ok) { + return parsed.error; } + const num = parsed.amount; // Check minimum deposit amount if (num < MIN_DEPOSIT_AMOUNT) { diff --git a/frontend/src/forms/schemas/withdrawFormSchema.test.ts b/frontend/src/forms/schemas/withdrawFormSchema.test.ts index 5faaa093..32340997 100644 --- a/frontend/src/forms/schemas/withdrawFormSchema.test.ts +++ b/frontend/src/forms/schemas/withdrawFormSchema.test.ts @@ -63,6 +63,50 @@ describe("Withdraw Form Schema", () => { }); }); + describe("amount format validation (API AmountSchema parity)", () => { + it("rejects scientific notation", () => { + const schema = createWithdrawFormSchema(1000); + const errors = validate(schema, { amount: "1e5" }); + expect(errors.amount).toBe( + "Enter an amount using digits only, with up to 7 decimal places.", + ); + }); + + it("rejects more than 7 decimal places", () => { + const schema = createWithdrawFormSchema(1000); + const errors = validate(schema, { amount: "1.12345678" }); + expect(errors.amount).toBe( + "Enter an amount using digits only, with up to 7 decimal places.", + ); + }); + + it("accepts exactly 7 decimal places", () => { + const schema = createWithdrawFormSchema(1000); + const errors = validate(schema, { amount: "1.1234567" }); + expect(errors.amount).toBeUndefined(); + }); + }); + + describe("XLM fee validation", () => { + it("shows error when insufficient XLM for fees", () => { + const schema = createWithdrawFormSchema(100, 0.001, 0.01); + const errors = validate(schema, { amount: "10" }); + expect(errors.amount).toContain("Insufficient XLM balance"); + }); + + it("accepts valid amount when sufficient XLM", () => { + const schema = createWithdrawFormSchema(100, 1, 0.01); + const errors = validate(schema, { amount: "10" }); + expect(errors.amount).toBeUndefined(); + }); + + it("skips fee check when xlmBalance/feeXlm are defaulted", () => { + const schema = createWithdrawFormSchema(100); + const errors = validate(schema, { amount: "10" }); + expect(errors.amount).toBeUndefined(); + }); + }); + describe("valid withdrawals", () => { it("accepts valid withdrawal amount", () => { const schema = createWithdrawFormSchema(1000); diff --git a/frontend/src/forms/schemas/withdrawFormSchema.ts b/frontend/src/forms/schemas/withdrawFormSchema.ts index 8a137a5c..e53194b6 100644 --- a/frontend/src/forms/schemas/withdrawFormSchema.ts +++ b/frontend/src/forms/schemas/withdrawFormSchema.ts @@ -2,11 +2,13 @@ * Validation schema for the withdraw form. * * Enforces business rules for vault withdrawals: - * - Amount is required and must be a positive number + * - Amount is required and must be a positive number, formatted like the API's AmountSchema * - Amount cannot exceed user's vault balance + * - User must have sufficient XLM balance for network fees (when provided) */ import type { ValidationSchema } from "../validate"; +import { parseAmountInput } from "./amountValidation"; export interface WithdrawFormValues { amount: string; @@ -15,32 +17,36 @@ export interface WithdrawFormValues { /** * Create a withdraw form validation schema. * - * @param availableBalance - User's available USDC balance (from vault shares) + * @param availableBalance - User's available vault balance (sum of holdings value, in USD) + * @param xlmBalance - User's available XLM balance (defaults to Infinity, i.e. no fee check) + * @param feeXlm - Estimated XLM required for network fees (defaults to 0, i.e. no fee check) * @returns Validation schema for withdraw form */ export function createWithdrawFormSchema( availableBalance: number, + xlmBalance: number = Infinity, + feeXlm: number = 0, ): ValidationSchema { return { amount: { required: "Amount is required.", custom: (value) => { - // Check if value is a valid number - const num = Number(value); - if (Number.isNaN(num) || !Number.isFinite(num)) { - return "Enter a valid number."; - } - - // Amount must be greater than 0 - if (num <= 0) { - return "Amount must be greater than 0."; + const parsed = parseAmountInput(value); + if (!parsed.ok) { + return parsed.error; } + const num = parsed.amount; // Check available vault balance if (num > availableBalance) { return `Withdrawal amount cannot exceed your available vault balance of ${availableBalance.toFixed(2)}.`; } + // Check network fee coverage + if (xlmBalance < feeXlm) { + return `Insufficient XLM balance for network fees. You need ${feeXlm.toFixed(7)} XLM.`; + } + return undefined; }, }, diff --git a/frontend/src/forms/useForm.test.tsx b/frontend/src/forms/useForm.test.tsx index dd5bd1b3..bea44aea 100644 --- a/frontend/src/forms/useForm.test.tsx +++ b/frontend/src/forms/useForm.test.tsx @@ -99,4 +99,130 @@ describe("useForm", () => { expect(result.current.errors.amount).toBe("Insufficient balance."); expect(result.current.touched.amount).toBe(true); }); + + it("does not show errors for untouched fields while typing", () => { + const { result } = renderHook(() => useForm({ amount: "" }, schema)); + + act(() => { + result.current.handleChange({ + target: { name: "amount", value: "-5" }, + } as never); + }); + + expect(result.current.errors.amount).toBeUndefined(); + }); + + it("revalidates a touched field as the user keeps typing", () => { + const { result } = renderHook(() => useForm({ amount: "" }, schema)); + + act(() => { + result.current.handleBlur({ + target: { name: "amount" }, + } as never); + }); + expect(result.current.errors.amount).toBe("Amount is required."); + + act(() => { + result.current.handleChange({ + target: { name: "amount", value: "-5" }, + } as never); + }); + expect(result.current.errors.amount).toBe("Amount must be positive."); + + act(() => { + result.current.handleChange({ + target: { name: "amount", value: "5" }, + } as never); + }); + expect(result.current.errors.amount).toBeUndefined(); + }); + + it("hasAttemptedSubmit starts false and flips to true after validateAll", () => { + const { result } = renderHook(() => useForm({ amount: "" }, schema)); + + expect(result.current.hasAttemptedSubmit).toBe(false); + + act(() => { + result.current.validateAll(); + }); + + expect(result.current.hasAttemptedSubmit).toBe(true); + }); + + it("validateAll marks all fields touched and returns validity", () => { + const { result } = renderHook(() => useForm({ amount: "" }, schema)); + + let isValid = true; + act(() => { + isValid = result.current.validateAll(); + }); + + expect(isValid).toBe(false); + expect(result.current.touched.amount).toBe(true); + expect(result.current.errors.amount).toBe("Amount is required."); + }); + + it("validateAll accepts override values to avoid stale reads", () => { + const { result } = renderHook(() => useForm({ amount: "" }, schema)); + + let isValid = false; + act(() => { + isValid = result.current.validateAll({ amount: "10" }); + }); + + expect(isValid).toBe(true); + expect(result.current.errors.amount).toBeUndefined(); + expect(result.current.touched.amount).toBe(true); + }); + + it("revalidates once a submit has been attempted, even for untouched fields", () => { + const { result } = renderHook(() => + useForm({ amount: "10" }, schema), + ); + + act(() => { + result.current.validateAll(); + }); + expect(result.current.errors.amount).toBeUndefined(); + + act(() => { + result.current.setValues({ amount: "-1" }); + }); + expect(result.current.errors.amount).toBe("Amount must be positive."); + }); + + it("resetErrors clears hasAttemptedSubmit", () => { + const { result } = renderHook(() => useForm({ amount: "" }, schema)); + + act(() => { + result.current.validateAll(); + }); + expect(result.current.hasAttemptedSubmit).toBe(true); + + act(() => { + result.current.resetErrors(); + }); + + expect(result.current.hasAttemptedSubmit).toBe(false); + expect(result.current.errors).toEqual({}); + expect(result.current.touched).toEqual({}); + }); + + it("setValues revalidates touched fields", () => { + const { result } = renderHook(() => useForm({ amount: "" }, schema)); + + act(() => { + result.current.handleBlur({ + target: { name: "amount" }, + } as never); + }); + expect(result.current.errors.amount).toBe("Amount is required."); + + act(() => { + result.current.setValues({ amount: "42" }); + }); + + expect(result.current.values.amount).toBe("42"); + expect(result.current.errors.amount).toBeUndefined(); + }); }); diff --git a/frontend/src/forms/useForm.ts b/frontend/src/forms/useForm.ts index 22bc0394..c94bc461 100644 --- a/frontend/src/forms/useForm.ts +++ b/frontend/src/forms/useForm.ts @@ -1,105 +1,203 @@ -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { ChangeEvent, FocusEvent, FormEvent } from "react"; import { type ValidationSchema, validate } from "./validate"; /** * Shared frontend form state hook with schema-based validation. - * Validates per-field on blur and all fields on submit. + * + * - Validates a field on blur (marking it touched). + * - Revalidates touched fields (or all fields, once a submit/validateAll has + * been attempted) as the user keeps typing, so errors clear/appear live + * instead of only on the next blur. + * - `validateAll` runs the full schema, marks every field touched, and + * returns whether the values are valid — useful for gating navigation + * (e.g. moving to a "review" step) outside of a form `submit` event. */ export function useForm( initialValues: T, schema: ValidationSchema, ) { - const [values, setValues] = useState(initialValues); + const [values, setValuesState] = useState(initialValues); const [errors, setErrors] = useState>>({}); const [touched, setTouched] = useState>>({}); const [isSubmitting, setIsSubmitting] = useState(false); + const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); - const handleChange = (event: ChangeEvent) => { - const { name, value } = event.target; - const key = name as keyof T; + // Refs mirror the latest state so callbacks built with useCallback (and + // thus stable across renders) always read current values without needing + // to be recreated on every keystroke. Event handlers below also write + // through these refs synchronously (legal outside of render); the effect + // is a safety net that keeps them in sync with state set by any other + // path (notably `schema`, which is supplied fresh by the caller on every + // render it changes). + const valuesRef = useRef(values); + const touchedRef = useRef(touched); + const schemaRef = useRef(schema); + const hasAttemptedSubmitRef = useRef(hasAttemptedSubmit); - setValues((previous) => ({ - ...previous, - [key]: value, - })); - }; + useEffect(() => { + valuesRef.current = values; + touchedRef.current = touched; + schemaRef.current = schema; + hasAttemptedSubmitRef.current = hasAttemptedSubmit; + }); - const handleBlur = (event: FocusEvent) => { - const { name } = event.target; - const key = name as keyof T; + const filterTouchedErrors = useCallback( + ( + allErrors: Partial>, + touchedMap: Partial>, + ): Partial> => + Object.fromEntries( + Object.entries(allErrors).filter(([field]) => touchedMap[field as keyof T]), + ) as Partial>, + [], + ); - const nextTouched = { - ...touched, - [key]: true, - }; - setTouched(nextTouched); + /** Revalidate against the given values, respecting touched/attempted state. */ + const revalidate = useCallback( + (nextValues: T) => { + const nextErrors = validate(schemaRef.current, nextValues); + if (hasAttemptedSubmitRef.current) { + setErrors(nextErrors); + return; + } + if (Object.keys(touchedRef.current).length > 0) { + setErrors(filterTouchedErrors(nextErrors, touchedRef.current)); + } + }, + [filterTouchedErrors], + ); - const nextErrors = validate(schema, values); - const filteredErrors = Object.fromEntries( - Object.entries(nextErrors).filter(([field]) => nextTouched[field as keyof T]), - ) as Partial>; - setErrors(filteredErrors); - }; + const handleChange = useCallback( + (event: ChangeEvent) => { + const { name, value } = event.target; + const key = name as keyof T; + + const nextValues = { + ...valuesRef.current, + [key]: value, + }; + valuesRef.current = nextValues; + setValuesState(nextValues); + revalidate(nextValues); + }, + [revalidate], + ); + + const handleBlur = useCallback( + (event: FocusEvent) => { + const { name } = event.target; + const key = name as keyof T; + + const nextTouched = { + ...touchedRef.current, + [key]: true, + }; + touchedRef.current = nextTouched; + setTouched(nextTouched); + + const nextErrors = validate(schemaRef.current, valuesRef.current); + setErrors( + hasAttemptedSubmitRef.current + ? nextErrors + : filterTouchedErrors(nextErrors, nextTouched), + ); + }, + [filterTouchedErrors], + ); - const setFieldError = (name: keyof T, error: string) => { + const setFieldError = useCallback((name: keyof T, error: string) => { setErrors((previous) => ({ ...previous, [name]: error, })); - setTouched((previous) => ({ - ...previous, - [name]: true, - })); - }; + setTouched((previous) => { + const next = { ...previous, [name]: true }; + touchedRef.current = next; + return next; + }); + }, []); - const resetErrors = () => { + const resetErrors = useCallback(() => { setErrors({}); setTouched({}); - }; + touchedRef.current = {}; + setHasAttemptedSubmit(false); + hasAttemptedSubmitRef.current = false; + }, []); + + /** + * Validates every field in the schema, marks all of them touched, and + * returns whether the values are valid. Pass `overrideValues` to validate + * a value that hasn't been committed to state yet (avoids a stale read + * caused by React state batching). + */ + const validateAll = useCallback((overrideValues?: T): boolean => { + const valuesToValidate = overrideValues ?? valuesRef.current; + const nextErrors = validate(schemaRef.current, valuesToValidate); + const touchedAll = Object.keys(schemaRef.current).reduce( + (accumulator, field) => ({ + ...accumulator, + [field]: true, + }), + {} as Partial>, + ); + + const nextTouched = { ...touchedRef.current, ...touchedAll }; + touchedRef.current = nextTouched; + setTouched(nextTouched); + setErrors(nextErrors); + setHasAttemptedSubmit(true); + hasAttemptedSubmitRef.current = true; - const handleSubmit = + return Object.keys(nextErrors).length === 0; + }, []); + + const handleSubmit = useCallback( (onSubmit: (formValues: T) => Promise) => - async (event: FormEvent) => { - event.preventDefault(); - - const nextErrors = validate(schema, values); - const touchedAll = Object.keys(schema).reduce( - (accumulator, field) => ({ - ...accumulator, - [field]: true, - }), - {} as Partial>, - ); + async (event: FormEvent) => { + event.preventDefault(); - setTouched((previous) => ({ - ...previous, - ...touchedAll, - })); - setErrors(nextErrors); + if (!validateAll()) { + return; + } - if (Object.keys(nextErrors).length > 0) { - return; - } + setIsSubmitting(true); + try { + await onSubmit(valuesRef.current); + } finally { + setIsSubmitting(false); + } + }, + [validateAll], + ); - setIsSubmitting(true); - try { - await onSubmit(values); - } finally { - setIsSubmitting(false); - } - }; + const setValues = useCallback( + (nextValues: T | ((previous: T) => T)) => { + const resolved = + typeof nextValues === "function" + ? (nextValues as (previous: T) => T)(valuesRef.current) + : nextValues; + + valuesRef.current = resolved; + setValuesState(resolved); + revalidate(resolved); + }, + [revalidate], + ); return { values, errors, touched, isSubmitting, + hasAttemptedSubmit, handleChange, handleBlur, handleSubmit, setFieldError, setValues, resetErrors, + validateAll, }; } diff --git a/frontend/src/forms/validate.ts b/frontend/src/forms/validate.ts index 593ce9ce..bd234734 100644 --- a/frontend/src/forms/validate.ts +++ b/frontend/src/forms/validate.ts @@ -1,7 +1,14 @@ /** * Lightweight schema-based validator used by frontend forms. - * This project currently has no dedicated validation library dependency, - * so rules are defined via plain objects and validated in-repo. + * + * The API layer (`packages/api-schemas`) validates requests with Zod, but + * that runtime isn't pulled into these forms — rules here are plain objects + * validated in-repo so fields can be checked on every keystroke without the + * overhead of parsing a Zod schema. Where a rule mirrors a server-side + * constraint (notably amount formatting, see + * `forms/schemas/amountValidation.ts`), it is kept in sync with the + * corresponding Zod schema so a value accepted here will not be rejected by + * the API, and vice versa. */ export type ValidationRule = { required?: boolean | string; diff --git a/frontend/src/hooks/useAsyncActionButton.ts b/frontend/src/hooks/useAsyncActionButton.ts index 3e54e437..dfb1881e 100644 --- a/frontend/src/hooks/useAsyncActionButton.ts +++ b/frontend/src/hooks/useAsyncActionButton.ts @@ -39,7 +39,9 @@ export function useAsyncActionButton({ const [status, setStatus] = useState("idle"); useEffect(() => { + // Sync button chrome to external mutation flags; timers clear success/error flash. if (isPending) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- maps props into transient UI status setStatus("pending"); return; } diff --git a/frontend/src/hooks/useTransactionConfirmation.test.ts b/frontend/src/hooks/useTransactionConfirmation.test.ts index 5572079b..5a50546d 100644 --- a/frontend/src/hooks/useTransactionConfirmation.test.ts +++ b/frontend/src/hooks/useTransactionConfirmation.test.ts @@ -122,7 +122,7 @@ describe('useTransactionConfirmation', () => { it('does not render modal before requestConfirmation is called', () => { const { result } = renderHook(() => useTransactionConfirmation()); - expect(result.current.modal).toBeNull(); + expect(result.current.modal).toBeUndefined(); }); }); diff --git a/frontend/src/hooks/useWalletHeartbeat.ts b/frontend/src/hooks/useWalletHeartbeat.ts index 4f1eb628..bcf3d275 100644 --- a/frontend/src/hooks/useWalletHeartbeat.ts +++ b/frontend/src/hooks/useWalletHeartbeat.ts @@ -76,6 +76,8 @@ export function useWalletHeartbeat( useEffect(() => { if (!walletAddress) { + // Reset when the wallet disconnects; no external system to subscribe to. + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear local heartbeat on disconnect setHeartbeat(INITIAL); return; } diff --git a/frontend/src/lib/security.test.ts b/frontend/src/lib/security.test.ts index d05f5d0b..1e9b0b72 100644 --- a/frontend/src/lib/security.test.ts +++ b/frontend/src/lib/security.test.ts @@ -148,7 +148,7 @@ describe('escapeHtml', () => { expect(escapeHtml('')) .toBe('<img src=x onerror=alert(1)>'); expect(escapeHtml('Test & "quotes"')) - .toBe('Test & "quotes"'); + .toBe('Test & "quotes"'); }); test('preserves safe text', () => { diff --git a/frontend/src/pages/Portfolio.test.tsx b/frontend/src/pages/Portfolio.test.tsx index 1fca4891..0c03d516 100644 --- a/frontend/src/pages/Portfolio.test.tsx +++ b/frontend/src/pages/Portfolio.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { describe, expect, it, vi, beforeEach } from "vitest"; import Portfolio from "./Portfolio"; diff --git a/frontend/src/pages/TransactionHistory.test.tsx b/frontend/src/pages/TransactionHistory.test.tsx index 3cae8fe3..52861c4b 100644 --- a/frontend/src/pages/TransactionHistory.test.tsx +++ b/frontend/src/pages/TransactionHistory.test.tsx @@ -1,11 +1,16 @@ import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import TransactionHistory from "./TransactionHistory"; import * as transactionApi from "../lib/transactionApi"; import type { Transaction } from "../lib/transactionApi"; import { ToastProvider } from "../context/ToastContext"; +import { + getPreferenceStorageKey, + setTransactionPageSize, + setTransactionViewMode, +} from "../lib/userPreferenceStore"; vi.mock("../hooks/useTransactionTimeline", () => ({ useTransactionTimeline: () => ({ @@ -50,7 +55,8 @@ function makeTransaction(overrides: Partial = {}): Transaction { amount: "100.00", asset: "USDC", timestamp: "2025-01-15T10:30:00Z", - transactionHash: "abcdef1234567890abcdef1234567890abcdef12", + // Deliberately not 40 chars — the pre-commit AWS secret regex flags /[A-Za-z0-9/+=]{40}/. + transactionHash: "tx-hash-abcdef1234567890abcdef1234567890ab", ...overrides, }; } @@ -61,16 +67,11 @@ function makeManyTransactions(count: number): Transaction[] { id: String(i + 1), type: i % 2 === 0 ? "deposit" : "withdrawal", amount: String((i + 1) * 10), - transactionHash: `hash${String(i).padStart(36, "0")}`, + transactionHash: `tx-hash-${String(i).padStart(32, "0")}`, }), ); } -function UrlProbe() { - const [params] = useSearchParams(); - return
{params.toString()}
; -} - function renderPage(walletAddress: string | null, initialEntries = ["/"]) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -320,7 +321,7 @@ describe("TransactionHistory", () => { id: "2", asset: "EURC", type: "withdrawal", - transactionHash: "eurcdef1234567890abcdef1234567890abcdef12", + transactionHash: "tx-hash-eurcdef1234567890abcdef1234567890", }), ]); @@ -600,13 +601,13 @@ describe("TransactionHistory — amount range filter", () => { id: "2", amount: "200", asset: "USDC", - transactionHash: "hash200000000000000000000000000000000000000", + transactionHash: "tx-hash-20000000000000000000000000000000", }), makeTransaction({ id: "3", amount: "500", asset: "USDC", - transactionHash: "hash500000000000000000000000000000000000000", + transactionHash: "tx-hash-50000000000000000000000000000000", }), ]); @@ -627,8 +628,6 @@ describe("TransactionHistory — amount range filter", () => { ); await waitFor(() => expect(screen.getByRole("table")).toBeInTheDocument()); - const table = screen.getByRole("table"); - const table = await screen.findByRole("table"); // 50 should be hidden; 200 and 500 should be visible @@ -646,13 +645,13 @@ describe("TransactionHistory — amount range filter", () => { id: "2", amount: "200", asset: "USDC", - transactionHash: "hash200000000000000000000000000000000000000", + transactionHash: "tx-hash-20000000000000000000000000000000", }), makeTransaction({ id: "3", amount: "500", asset: "USDC", - transactionHash: "hash500000000000000000000000000000000000000", + transactionHash: "tx-hash-50000000000000000000000000000000", }), ]); @@ -675,8 +674,6 @@ describe("TransactionHistory — amount range filter", () => { await waitFor(() => expect(screen.getByRole("table")).toBeInTheDocument()); const table = screen.getByRole("table"); - const table = screen.getByRole("table"); - // Only 50 should be visible await waitFor(() => expect(within(table).queryAllByText(/500 USDC/).length).toBe(0), @@ -709,13 +706,13 @@ describe("TransactionHistory — status filter", () => { id: "2", status: "pending", asset: "EURC", - transactionHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + transactionHash: "tx-hash-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }), makeTransaction({ id: "3", status: "failed", asset: "XLM", - transactionHash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + transactionHash: "tx-hash-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", }), ]); @@ -736,8 +733,6 @@ describe("TransactionHistory — status filter", () => { ); await waitFor(() => expect(screen.getByRole("table")).toBeInTheDocument()); - const table = screen.getByRole("table"); - const table = await screen.findByRole("table"); // Only EURC (pending) should survive the filter diff --git a/frontend/src/tests/VaultDashboardWizard.test.tsx b/frontend/src/tests/VaultDashboardWizard.test.tsx index c832d92b..dff829df 100644 --- a/frontend/src/tests/VaultDashboardWizard.test.tsx +++ b/frontend/src/tests/VaultDashboardWizard.test.tsx @@ -1,42 +1,11 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ReactNode } from "react"; import { PreferencesProvider } from "../context/PreferencesContext"; +import { ToastProvider } from "../context/ToastContext"; import VaultDashboard from "../components/VaultDashboard"; import { VaultProvider } from "../context/VaultContext"; -import * as vaultDataHooks from "../hooks/useVaultData"; -import type { UseQueryResult } from "@tanstack/react-query"; -import type { VaultSummary } from "../lib/vaultApi"; - -vi.mock("../hooks/useVaultData", () => ({ - useVaultSummary: vi.fn(), - useVaultHistory: vi.fn(), -})); - -const mockSummary: VaultSummary = { - tvl: 12450800, - depositCap: 15000000, - apy: 8.45, - participantCount: 1248, - monthlyGrowthPct: 12.5, - strategyStabilityPct: 99.9, - assetLabel: "Sovereign Debt", - exchangeRate: 1.084, - networkFeeEstimate: "~0.00001 XLM", - updatedAt: "2026-03-25T10:00:00.000Z", - contractPaused: false, - strategy: { - id: "stellar-benji", - name: "Franklin BENJI Connector", - issuer: "Franklin Templeton", - network: "Stellar", - rpcUrl: "https://soroban-testnet.stellar.org", - status: "active", - description: "Connector strategy.", - }, -}; -import { ToastProvider } from "../context/ToastContext"; -import { PreferencesProvider } from "../context/PreferencesContext"; -import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as vaultApi from "../lib/vaultApi"; import * as portfolioHooks from "../hooks/usePortfolioData"; @@ -44,6 +13,7 @@ import * as vaultDataHooks from "../hooks/useVaultData"; import * as tokenAllowanceHooks from "../hooks/useTokenAllowance"; import type { UseQueryResult } from "@tanstack/react-query"; import type { VaultSummary } from "../lib/vaultApi"; +import type { PortfolioHolding } from "../lib/portfolioApi"; vi.mock("../lib/vaultApi", async (importOriginal) => { const actual = await importOriginal(); @@ -73,6 +43,15 @@ vi.mock("../hooks/useTransactionConfirmation", () => ({ }), })); +vi.mock("../hooks/usePortfolioData", () => ({ + usePortfolioHoldings: vi.fn(), +})); + +vi.mock("../hooks/useVaultData", () => ({ + useVaultSummary: vi.fn(), + useVaultHistory: vi.fn(), +})); + vi.mock("../hooks/useTokenAllowance", () => ({ useTokenAllowance: vi.fn(), })); @@ -83,14 +62,7 @@ vi.mock("../hooks/useFeeEstimate", () => ({ feeUsd: 0.01, isEstimating: false, isHighFee: false, - }), -})); - -vi.mock("../hooks/useTransactionConfirmation", () => ({ - useTransactionConfirmation: () => ({ - requestConfirmation: vi.fn().mockResolvedValue(true), - modal: null, - isOpen: false, + lastUpdated: new Date(), }), })); @@ -104,7 +76,7 @@ const mockSummary: VaultSummary = { assetLabel: "Sovereign Debt", exchangeRate: 1.084, networkFeeEstimate: "~0.00001 XLM", - updatedAt: "2026-03-25T10:00:00.000Z", + updatedAt: new Date().toISOString(), contractPaused: false, strategy: { id: "stellar-benji", @@ -121,19 +93,19 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); -const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - - - - - {children} - - - - - -); +function Wrapper({ children }: { children: ReactNode }) { + return ( + + + + + {children} + + + + + ); +} describe("VaultDashboard Wizard", () => { beforeEach(() => { @@ -159,6 +131,27 @@ describe("VaultDashboard Wizard", () => { error: null, refetch: vi.fn(), } as unknown as UseQueryResult<{ date: string; value: number }[], Error>); + vi.mocked(portfolioHooks.usePortfolioHoldings).mockReturnValue({ + data: [{ id: "1", shares: 10, valueUsd: 10, asset: "USDC", vaultName: "RWA Vault", symbol: "yvUSDC", apy: 5, unrealizedGainUsd: 0, issuer: "G...", status: "active" }], + isLoading: false, + } as unknown as UseQueryResult); + vi.mocked(tokenAllowanceHooks.useTokenAllowance).mockReturnValue({ + allowance: 1_000_000, + approvalStatus: "confirmed", + needsApproval: vi.fn().mockReturnValue(false), + approve: vi.fn().mockResolvedValue(undefined), + resetApproval: vi.fn(), + }); + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); }); it("navigates through the deposit wizard steps", async () => { @@ -174,9 +167,12 @@ describe("VaultDashboard Wizard", () => { fireEvent.click(screen.getByText("Review Transaction")); - await waitFor(() => { - expect(screen.getByText("Confirm Transaction")).toBeInTheDocument(); - }); + await waitFor( + () => { + expect(screen.getByText("Confirm Transaction")).toBeInTheDocument(); + }, + { timeout: 10000 }, + ); expect(screen.getByText("10.00 USDC")).toBeInTheDocument(); fireEvent.click(screen.getByText("Back")); @@ -185,17 +181,18 @@ describe("VaultDashboard Wizard", () => { expect(screen.getByDisplayValue("10")).toBeInTheDocument(); fireEvent.click(screen.getByText("Review Transaction")); - - // Confirm review step, then modal + + // Confirm review step, then modal (requestConfirmation is already mocked to resolve true). const confirmBtn = screen.getByRole("button", { name: /Confirm deposit/i }); fireEvent.click(confirmBtn); - fireEvent.click(screen.getByText("Confirm deposit")); + await waitFor( + () => { + expect(screen.getByText("Transaction Successful")).toBeInTheDocument(); + }, + { timeout: 10000 }, + ); - await waitFor(() => { - expect(screen.getByText("Transaction Successful")).toBeInTheDocument(); - }); - const doneBtn = screen.getByText("Done"); fireEvent.click(doneBtn); diff --git a/packages/api-schemas/tsconfig.json b/packages/api-schemas/tsconfig.json index 593e0fe4..4d540582 100644 --- a/packages/api-schemas/tsconfig.json +++ b/packages/api-schemas/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2020", - "module": "CommonJS", - "moduleResolution": "node", + "module": "Node16", + "moduleResolution": "Node16", "lib": ["ES2020"], "outDir": "./dist", "rootDir": "./src", diff --git a/scripts/vitest.config.ts b/scripts/vitest.config.ts index 2dd3d75e..64ea5952 100644 --- a/scripts/vitest.config.ts +++ b/scripts/vitest.config.ts @@ -1,10 +1,15 @@ import { defineConfig } from 'vitest/config'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); export default defineConfig({ test: { - environment: 'jsdom', - include: ['**/*.test.ts', '**/*.test.tsx'], + root: rootDir, + environment: 'node', + // Only the frontend env validation suite — not the full monorepo. + include: ['scripts/**/*.test.ts'], globals: true, - setupFiles: ['./tests/setup.ts'], }, });