diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index d0ebce7c..19240c8e 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -1,4 +1,4 @@ -name: Cypress Smoke Tests +name: Cypress Smoke Tests on: pull_request: @@ -32,6 +32,10 @@ jobs: - name: Build shared API schemas working-directory: packages/api-schemas + run: | + npm ci + # TypeScript 5.9 removed moduleResolution=node10; build with 5.5 for compatibility + npx -p typescript@5.5.4 tsc -p tsconfig.json run: npm ci && npm run build - name: Install dependencies diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 184d2b78..21c0733d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,6 +14,10 @@ jobs: uses: actions/checkout@v4 - name: Setup Rust + uses: dtolnay/rust-toolchain@1.84.0 + + - name: Generate Rust docs + run: cargo doc -p share-price-math --no-deps uses: dtolnay/rust-toolchain@1.85.0 - name: Generate Rust docs diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 35e83f20..2fafbb6b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,4 +1,4 @@ -name: E2E Tests +name: E2E Tests on: pull_request: @@ -31,6 +31,10 @@ jobs: - name: Build shared API schemas working-directory: packages/api-schemas + run: | + npm ci + # TypeScript 5.9 removed moduleResolution=node10; build with 5.5 for compatibility + npx -p typescript@5.5.4 tsc -p tsconfig.json run: npm ci && npm run build - name: Install dependencies diff --git a/docs/FRONTEND_STATE_MANAGEMENT.md b/docs/FRONTEND_STATE_MANAGEMENT.md index f17d6e74..5b42bcd0 100644 --- a/docs/FRONTEND_STATE_MANAGEMENT.md +++ b/docs/FRONTEND_STATE_MANAGEMENT.md @@ -1,6 +1,6 @@ # YieldVault-RWA — Frontend State Management -> **Last Updated:** 2026-05-29 +> **Last Updated:** 2026-07-24 A shared reference for the frontend state management architecture used across the YieldVault-RWA codebase. The purpose of this document is to clarify state ownership, data fetching boundaries, and UI synchronization patterns to ensure a consistent and maintainable developer experience. @@ -65,6 +65,23 @@ Instead of relying on backend endpoints for every data permutation, raw data is - **Vault Metrics**: `VaultContext` consumes raw `useVaultSummary` and `useVaultHistory` data, computes the active APY and Vault Utilization, and provides these derived metrics to all dashboard components. - **Table Filtering**: `TransactionHistory` fetches a raw list of transactions (max 200) and uses `useClientDataTable` to handle sorting, filtering, and pagination entirely in memory. +### Optimistic Mutations And Rollback +Deposit and withdrawal mutations update React Query caches immediately so the UI feels responsive, then reconcile with the server. + +Shared helpers live in `frontend/src/lib/optimisticVaultCache.ts` and are consumed by `useDepositMutation` / `useWithdrawMutation`: + +1. **Cancel** in-flight reads for balance, holdings, vault summary, and transactions. +2. **Snapshot** each key, including whether the key existed (so rollback can `removeQueries` when there was no prior cache). +3. **Apply** optimistic patches: + - Deposit: wallet USDC ↓, holdings/TVL ↑, prepend pending tx row + - Withdrawal: wallet USDC ↑, holdings/TVL ↓, prepend pending tx row +4. **On error**: restore the snapshot exactly (cache consistency first). +5. **On settled** (success or failure): invalidate the same keys so server truth replaces optimistic rows. + +`VaultDashboard` still owns user-facing feedback (result step + toast). Cache rollback must not depend on toast timing. + +See also [`docs/VAULT_UX_PATTERN_LIBRARY.md`](./VAULT_UX_PATTERN_LIBRARY.md) for pending / optimistic UX rules. + --- ## Key Contexts @@ -93,7 +110,7 @@ Custom hooks in `frontend/src/hooks/` encapsulate all complex logic. - **`useVaultSummary` & `useVaultHistory`**: Fetch global vault stats and historical data. **Mutation Hooks (React Query):** -- **`useVaultMutations`**: Exposes `useDepositMutation` and `useWithdrawMutation` for executing Soroban contract calls. Automatically invalidates related query caches (balances, transactions) on success. +- **`useVaultMutations`**: Exposes `useDepositMutation` and `useWithdrawMutation` for executing Soroban contract calls. Applies optimistic cache updates via `optimisticVaultCache`, rolls back on failure, and invalidates related query caches on settle. **Utility Hooks:** - **`useClientDataTable`**: Handles client-side pagination, sorting, and text-based filtering of arrays. @@ -127,6 +144,7 @@ To maintain a clean and scalable frontend architecture, adhere to the following 3. **Use the URL as the Source of Truth:** For shareable states like search queries, filters, or active tabs, use the URL parameters instead of internal `useState`. 4. **Don't Duplicate Server State:** Avoid copying React Query data into local `useState`. Derive values directly from the query data during render. 5. **Colocate Form State:** Use the custom `useForm` hook for transaction inputs and validation. Avoid storing form inputs in global contexts. +6. **Optimistic Updates Must Roll Back:** When mutating vault state, patch React Query caches through `optimisticVaultCache` helpers. Always snapshot before patching and restore on failure; never leave pending optimistic rows after an error. --- diff --git a/docs/VAULT_UX_PATTERN_LIBRARY.md b/docs/VAULT_UX_PATTERN_LIBRARY.md index 6a731f9b..6a9ee8e9 100644 --- a/docs/VAULT_UX_PATTERN_LIBRARY.md +++ b/docs/VAULT_UX_PATTERN_LIBRARY.md @@ -358,5 +358,8 @@ The current approved implementation lives in: - `frontend/src/components/VaultDashboard.tsx` - `frontend/src/components/TransactionConfirmationModal.tsx` - `frontend/src/hooks/useVaultMutations.ts` +- `frontend/src/lib/optimisticVaultCache.ts` (snapshot / apply / rollback helpers) + +Optimistic deposit and withdrawal patches credit or debit wallet USDC in opposite directions, mark holdings and pending transaction rows as `pending`, and restore the pre-mutation snapshot when the network or contract call fails. Settled mutations always invalidate related React Query keys so the UI converges on server truth. If a future implementation intentionally diverges from this library, update this document in the same change set and explain the reason in the pull request. diff --git a/frontend/e2e/deposit-withdraw.spec.ts b/frontend/e2e/deposit-withdraw.spec.ts index 41393fe7..6b23e706 100644 --- a/frontend/e2e/deposit-withdraw.spec.ts +++ b/frontend/e2e/deposit-withdraw.spec.ts @@ -18,6 +18,12 @@ import { /** Valid Stellar public key (G + 55 base32 chars) for API validation in submitDeposit / submitWithdrawal. */ const MOCK_ADDRESS = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; +/** Truncated or privacy-masked forms of MOCK_ADDRESS shown in the navbar. */ +const SHORT_ADDR = /GBBD4?\.\.\.FLA5|GBBD•{8}FLA5/; + +async function goToConnectedVault(page: Page, path = '/') { + await page.goto(path); + await expect(page.getByText(SHORT_ADDR)).toBeVisible({ timeout: 10_000 }); const SHORT_ADDR = `${MOCK_ADDRESS.substring(0, 5)}...${MOCK_ADDRESS.substring(MOCK_ADDRESS.length - 4)}`; async function goToConnectedVault(page: Page, path = '/') { diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts index bc2922e9..65e9d39a 100644 --- a/frontend/e2e/fixtures.ts +++ b/frontend/e2e/fixtures.ts @@ -257,6 +257,11 @@ async function fulfillHorizonRoute(route: import('@playwright/test').Route) { export async function interceptApiRoutes(page: Page) { await page.addInitScript(() => { window.localStorage.setItem('hasSeenWalkthrough', 'true'); + // Privacy mode defaults to masked identifiers; disable for stable E2E address matchers. + window.localStorage.setItem( + 'yieldvault-preferences:guest', + JSON.stringify({ maskSensitiveValues: false }), + ); // Match Cypress: skip service-worker registration so Playwright route mocks // are not bypassed by cross-origin fetches issued from the SW context. (window as Window & { Cypress?: boolean }).Cypress = true; @@ -407,6 +412,14 @@ export async function completeVaultReviewStep( */ export async function stubFreighterConnected(page: Page, address: string) { await page.addInitScript((addr) => { + window.localStorage.setItem( + `yieldvault-preferences:${addr}`, + JSON.stringify({ maskSensitiveValues: false }), + ); + window.localStorage.setItem( + 'yieldvault-preferences:guest', + JSON.stringify({ maskSensitiveValues: false }), + ); try { const raw = localStorage.getItem('yieldvault-preferences:guest'); const base = raw ? JSON.parse(raw) : { maskSensitiveValues: false }; @@ -478,6 +491,14 @@ export async function stubFreighterConnected(page: Page, address: string) { */ export async function stubFreighterManualConnect(page: Page, address: string) { await page.addInitScript((addr) => { + window.localStorage.setItem( + `yieldvault-preferences:${addr}`, + JSON.stringify({ maskSensitiveValues: false }), + ); + window.localStorage.setItem( + 'yieldvault-preferences:guest', + JSON.stringify({ maskSensitiveValues: false }), + ); try { const raw = localStorage.getItem('yieldvault-preferences:guest'); const base = raw ? JSON.parse(raw) : { maskSensitiveValues: false }; diff --git a/frontend/e2e/portfolio.spec.ts b/frontend/e2e/portfolio.spec.ts index b0c3a87e..32708401 100644 --- a/frontend/e2e/portfolio.spec.ts +++ b/frontend/e2e/portfolio.spec.ts @@ -4,7 +4,7 @@ import { test, expect, interceptApiRoutes, stubFreighterConnected } from './fixtures'; const MOCK_ADDRESS = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; -const SHORT_ADDR = `${MOCK_ADDRESS.substring(0, 5)}...${MOCK_ADDRESS.substring(MOCK_ADDRESS.length - 4)}`; +const SHORT_ADDR = /GBBD4?\.\.\.FLA5|GBBD•{8}FLA5/; test.describe('Portfolio page unauthenticated', () => { test('shows connect-wallet prompt when no wallet is connected', async ({ page }) => { diff --git a/frontend/src/components/Navbar.test.tsx b/frontend/src/components/Navbar.test.tsx index 92685432..8ab9d7e5 100644 --- a/frontend/src/components/Navbar.test.tsx +++ b/frontend/src/components/Navbar.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { PreferencesProvider } from '../context/PreferencesContext'; import Navbar from './Navbar'; @@ -14,6 +14,13 @@ describe('Navbar', () => { defaultOptions: { queries: { retry: false } }, }); + beforeEach(() => { + localStorage.setItem( + 'yieldvault-preferences:guest', + JSON.stringify({ maskSensitiveValues: false }), + ); + }); + it('renders the navbar with navigation links', () => { render( diff --git a/frontend/src/components/TransactionConfirmationModal.test.tsx b/frontend/src/components/TransactionConfirmationModal.test.tsx index 7f56f58a..556e2aab 100644 --- a/frontend/src/components/TransactionConfirmationModal.test.tsx +++ b/frontend/src/components/TransactionConfirmationModal.test.tsx @@ -66,6 +66,8 @@ describe('TransactionConfirmationModal', () => { it('displays contract address in monospace font', () => { render(); const addressText = screen.getByText(mockSummary.contractAddress); + const styledParent = addressText.parentElement; + expect(styledParent?.style.fontFamily).toMatch(/monospace/i); expect(addressText.parentElement?.getAttribute("style") ?? "").toMatch(/monospace/i); }); }); diff --git a/frontend/src/components/VaultDashboard.test.tsx b/frontend/src/components/VaultDashboard.test.tsx index 016962c3..f4aebd27 100644 --- a/frontend/src/components/VaultDashboard.test.tsx +++ b/frontend/src/components/VaultDashboard.test.tsx @@ -54,6 +54,11 @@ vi.mock("../hooks/useVaultData", () => ({ useVaultHistory: vi.fn(), })); +const { mockDepositMutateAsync, mockWithdrawMutateAsync } = vi.hoisted(() => ({ + mockDepositMutateAsync: vi.fn().mockResolvedValue({}), + mockWithdrawMutateAsync: vi.fn().mockResolvedValue({}), +})); + vi.mock("../hooks/useVaultMutations", () => ({ useDepositMutation: vi.fn(() => ({ mutateAsync: mockDepositMutateAsync, @@ -89,6 +94,7 @@ vi.mock("../hooks/useTransactionConfirmation", () => ({ const mockSummary = { tvl: 12450800, + depositCap: 15000000, apy: 8.45, participantCount: 1248, monthlyGrowthPct: 12.5, @@ -193,6 +199,9 @@ describe("VaultDashboard", () => { approve: vi.fn().mockResolvedValue(undefined), resetApproval: vi.fn(), }); + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + media: "", window.matchMedia = vi.fn().mockImplementation((query: string) => ({ matches: false, media: query, @@ -202,6 +211,7 @@ describe("VaultDashboard", () => { addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), + } as MediaQueryList); })); localStorage.clear(); }); diff --git a/frontend/src/components/WalletConnect.test.tsx b/frontend/src/components/WalletConnect.test.tsx index 62842024..fb170623 100644 --- a/frontend/src/components/WalletConnect.test.tsx +++ b/frontend/src/components/WalletConnect.test.tsx @@ -43,6 +43,10 @@ describe('WalletConnect', () => { beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); + localStorage.setItem( + 'yieldvault-preferences:guest', + JSON.stringify({ maskSensitiveValues: false }), + ); mockedFreighter.isConnected.mockResolvedValue({ isConnected: true }); mockedWalletSession.getLastWalletProvider.mockReturnValue(null); mockedWalletSession.isProviderAvailable.mockResolvedValue(true); diff --git a/frontend/src/context/ToastContext.tsx b/frontend/src/context/ToastContext.tsx index 9c9a2a49..896f2a51 100644 --- a/frontend/src/context/ToastContext.tsx +++ b/frontend/src/context/ToastContext.tsx @@ -73,6 +73,7 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ ...options }: ToastOptions) => { const dedupeKey = generateDedupeKey({ ...options, variant }); + // eslint-disable-next-line react-hooks/purity -- showToast runs on user/system events // Timestamp is intentionally captured at toast creation time for dedupe windows. // eslint-disable-next-line react-hooks/purity -- event-handler side effect, not render output const now = Date.now(); diff --git a/frontend/src/hooks/useAsyncActionButton.ts b/frontend/src/hooks/useAsyncActionButton.ts index 93c47e9e..2ca0eb39 100644 --- a/frontend/src/hooks/useAsyncActionButton.ts +++ b/frontend/src/hooks/useAsyncActionButton.ts @@ -41,6 +41,7 @@ export function useAsyncActionButton({ useEffect(() => { /* eslint-disable react-hooks/set-state-in-effect -- mirror external async flags into button status */ if (isPending) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- maps external async flags into button chrome // Sync button chrome with mutation status flags from the caller. // eslint-disable-next-line react-hooks/set-state-in-effect -- derived UI status from external flags setStatus("pending"); diff --git a/frontend/src/hooks/useVaultMutations.test.tsx b/frontend/src/hooks/useVaultMutations.test.tsx index 69b81787..ad928176 100644 --- a/frontend/src/hooks/useVaultMutations.test.tsx +++ b/frontend/src/hooks/useVaultMutations.test.tsx @@ -1,6 +1,7 @@ /** * Tests for optimistic UI updates in useDepositMutation / useWithdrawMutation. - * Covers: pending insert, rollback on error, reconcile on success, stable ordering. + * Covers: pending insert, balance/holdings/TVL patches, rollback on error, + * reconcile on settled, and withdraw wallet-credit direction. */ import { renderHook, act, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -10,6 +11,8 @@ import { useDepositMutation, useWithdrawMutation } from "./useVaultMutations"; import * as vaultApi from "../lib/vaultApi"; import { queryKeys } from "../lib/queryClient"; import type { Transaction } from "../lib/transactionApi"; +import type { PortfolioHolding } from "../lib/portfolioApi"; +import type { VaultSummary } from "../lib/vaultApi"; vi.mock("../lib/vaultApi", () => ({ submitDeposit: vi.fn(), @@ -18,6 +21,9 @@ vi.mock("../lib/vaultApi", () => ({ const WALLET = "GABC123"; const TX_KEY = queryKeys.transactions.list(WALLET); +const BALANCE_KEY = queryKeys.balance.usdc(WALLET); +const HOLDINGS_KEY = queryKeys.portfolio.holdings(WALLET); +const SUMMARY_KEY = queryKeys.vault.summary(); function makeTransaction(overrides: Partial = {}): Transaction { return { @@ -32,8 +38,63 @@ function makeTransaction(overrides: Partial = {}): Transaction { }; } +function makeHolding(overrides: Partial = {}): PortfolioHolding { + return { + id: "h1", + asset: "USDC", + vaultName: "YieldVault", + symbol: "yvUSDC", + shares: 100, + apy: 8, + valueUsd: 200, + unrealizedGainUsd: 5, + issuer: "issuer", + status: "active", + ...overrides, + }; +} + +function makeSummary(overrides: Partial = {}): VaultSummary { + return { + tvl: 1000, + depositCap: 10000, + apy: 8, + participantCount: 10, + monthlyGrowthPct: 1, + strategyStabilityPct: 95, + assetLabel: "USDC", + exchangeRate: 1, + networkFeeEstimate: "0.01", + updatedAt: "2025-01-01T00:00:00.000Z", + contractPaused: false, + strategy: { + id: "rwa-1", + name: "RWA", + issuer: "issuer", + network: "testnet", + rpcUrl: "https://example.test", + status: "active", + description: "test", + }, + ...overrides, + }; +} + +function seedCaches(queryClient: QueryClient) { + const holdings = [makeHolding()]; + const summary = makeSummary(); + const txs = [makeTransaction()]; + queryClient.setQueryData(BALANCE_KEY, 500); + queryClient.setQueryData(HOLDINGS_KEY, holdings); + queryClient.setQueryData(SUMMARY_KEY, summary); + queryClient.setQueryData(TX_KEY, txs); + return { holdings, summary, txs }; +} + function makeQueryClient() { - return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: 0 } } }); + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: 0 } }, + }); } function wrapper(queryClient: QueryClient) { @@ -42,8 +103,6 @@ function wrapper(queryClient: QueryClient) { ); } -// ─── Deposit ────────────────────────────────────────────────────────────────── - describe("useDepositMutation – optimistic updates", () => { let queryClient: QueryClient; @@ -64,7 +123,6 @@ describe("useDepositMutation – optimistic updates", () => { result.current.mutate({ walletAddress: WALLET, amount: 100 }); }); - // Optimistic row should appear before the mutation resolves await waitFor(() => { const txs = queryClient.getQueryData(TX_KEY); expect(txs?.[0].status).toBe("pending"); @@ -73,6 +131,35 @@ describe("useDepositMutation – optimistic updates", () => { }); }); + it("optimistically decreases wallet balance and increases holdings/TVL", async () => { + let resolveDeposit!: () => void; + vi.mocked(vaultApi.submitDeposit).mockImplementation( + () => + new Promise((resolve) => { + resolveDeposit = () => resolve(undefined); + }), + ); + seedCaches(queryClient); + + const { result } = renderHook(() => useDepositMutation(), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate({ walletAddress: WALLET, amount: 100 }); + }); + + await waitFor(() => { + expect(queryClient.getQueryData(BALANCE_KEY)).toBe(400); + expect(queryClient.getQueryData(HOLDINGS_KEY)?.[0].valueUsd).toBe(300); + expect(queryClient.getQueryData(SUMMARY_KEY)?.tvl).toBe(1100); + }); + + await act(async () => { + resolveDeposit(); + }); + }); + it("pending row is prepended — existing rows remain below it", async () => { vi.mocked(vaultApi.submitDeposit).mockResolvedValue(undefined); const existing = [makeTransaction({ id: "e1" }), makeTransaction({ id: "e2" })]; @@ -94,10 +181,9 @@ describe("useDepositMutation – optimistic updates", () => { }); }); - it("rolls back to snapshot on error", async () => { + it("rolls back balance, holdings, TVL, and transactions on error", async () => { vi.mocked(vaultApi.submitDeposit).mockRejectedValue(new Error("network error")); - const existing = [makeTransaction()]; - queryClient.setQueryData(TX_KEY, existing); + const seeded = seedCaches(queryClient); const { result } = renderHook(() => useDepositMutation(), { wrapper: wrapper(queryClient), @@ -111,12 +197,16 @@ describe("useDepositMutation – optimistic updates", () => { } }); - const txs = queryClient.getQueryData(TX_KEY); - expect(txs).toEqual(existing); - expect(txs?.some((t) => t.id.startsWith("optimistic-"))).toBe(false); + expect(queryClient.getQueryData(BALANCE_KEY)).toBe(500); + expect(queryClient.getQueryData(HOLDINGS_KEY)).toEqual(seeded.holdings); + expect(queryClient.getQueryData(SUMMARY_KEY)).toEqual(seeded.summary); + expect(queryClient.getQueryData(TX_KEY)).toEqual(seeded.txs); + expect( + queryClient.getQueryData(TX_KEY)?.some((t) => t.id.startsWith("optimistic-")), + ).toBe(false); }); - it("invalidates transaction query on success (reconcile)", async () => { + it("invalidates related queries on settled success (reconcile)", async () => { vi.mocked(vaultApi.submitDeposit).mockResolvedValue(undefined); const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); @@ -131,6 +221,37 @@ describe("useDepositMutation – optimistic updates", () => { expect(invalidateSpy).toHaveBeenCalledWith( expect.objectContaining({ queryKey: TX_KEY }), ); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: BALANCE_KEY }), + ); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: HOLDINGS_KEY }), + ); + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: SUMMARY_KEY }), + ); + }); + + it("invalidates related queries after error rollback (reconcile)", async () => { + vi.mocked(vaultApi.submitDeposit).mockRejectedValue(new Error("rpc fail")); + seedCaches(queryClient); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + + const { result } = renderHook(() => useDepositMutation(), { + wrapper: wrapper(queryClient), + }); + + await act(async () => { + try { + await result.current.mutateAsync({ walletAddress: WALLET, amount: 50 }); + } catch { + // expected + } + }); + + expect(invalidateSpy).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: BALANCE_KEY }), + ); }); it("pending row has a timestamp at or after mutation start", async () => { @@ -154,8 +275,6 @@ describe("useDepositMutation – optimistic updates", () => { }); }); -// ─── Withdrawal ─────────────────────────────────────────────────────────────── - describe("useWithdrawMutation – optimistic updates", () => { let queryClient: QueryClient; @@ -184,10 +303,38 @@ describe("useWithdrawMutation – optimistic updates", () => { }); }); - it("rolls back to snapshot on error", async () => { + it("optimistically credits wallet balance and decreases holdings/TVL", async () => { + let resolveWithdraw!: () => void; + vi.mocked(vaultApi.submitWithdrawal).mockImplementation( + () => + new Promise((resolve) => { + resolveWithdraw = () => resolve(undefined); + }), + ); + seedCaches(queryClient); + + const { result } = renderHook(() => useWithdrawMutation(), { + wrapper: wrapper(queryClient), + }); + + act(() => { + result.current.mutate({ walletAddress: WALLET, amount: 100 }); + }); + + await waitFor(() => { + expect(queryClient.getQueryData(BALANCE_KEY)).toBe(600); + expect(queryClient.getQueryData(HOLDINGS_KEY)?.[0].valueUsd).toBe(100); + expect(queryClient.getQueryData(SUMMARY_KEY)?.tvl).toBe(900); + }); + + await act(async () => { + resolveWithdraw(); + }); + }); + + it("rolls back balance, holdings, TVL, and transactions on error", async () => { vi.mocked(vaultApi.submitWithdrawal).mockRejectedValue(new Error("rpc error")); - const existing = [makeTransaction({ type: "withdrawal", id: "w1" })]; - queryClient.setQueryData(TX_KEY, existing); + const seeded = seedCaches(queryClient); const { result } = renderHook(() => useWithdrawMutation(), { wrapper: wrapper(queryClient), @@ -201,11 +348,13 @@ describe("useWithdrawMutation – optimistic updates", () => { } }); - const txs = queryClient.getQueryData(TX_KEY); - expect(txs).toEqual(existing); + expect(queryClient.getQueryData(BALANCE_KEY)).toBe(500); + expect(queryClient.getQueryData(HOLDINGS_KEY)).toEqual(seeded.holdings); + expect(queryClient.getQueryData(SUMMARY_KEY)).toEqual(seeded.summary); + expect(queryClient.getQueryData(TX_KEY)).toEqual(seeded.txs); }); - it("invalidates transaction query on success", async () => { + it("invalidates transaction query on settled success", async () => { vi.mocked(vaultApi.submitWithdrawal).mockResolvedValue(undefined); const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); @@ -224,7 +373,6 @@ describe("useWithdrawMutation – optimistic updates", () => { it("no duplicate rows after success invalidation clears optimistic entry", async () => { vi.mocked(vaultApi.submitWithdrawal).mockResolvedValue(undefined); - // Simulate server returning real data (no optimistic row) const serverData = [makeTransaction({ id: "server-1", type: "withdrawal" })]; queryClient.setQueryData(TX_KEY, []); @@ -236,7 +384,6 @@ describe("useWithdrawMutation – optimistic updates", () => { await result.current.mutateAsync({ walletAddress: WALLET, amount: 20 }); }); - // Simulate the refetch returning server data (invalidation triggers this in real app) queryClient.setQueryData(TX_KEY, serverData); const txs = queryClient.getQueryData(TX_KEY)!; diff --git a/frontend/src/hooks/useVaultMutations.ts b/frontend/src/hooks/useVaultMutations.ts index 1307b859..7ea03763 100644 --- a/frontend/src/hooks/useVaultMutations.ts +++ b/frontend/src/hooks/useVaultMutations.ts @@ -1,9 +1,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { queryKeys } from "../lib/queryClient"; -import type { PortfolioHolding } from "../lib/portfolioApi"; -import type { VaultSummary } from "../lib/vaultApi"; import { submitDeposit, submitWithdrawal } from "../lib/vaultApi"; -import type { Transaction } from "../lib/transactionApi"; +import { + applyOptimisticVaultPatch, + cancelVaultOptimisticQueries, + captureVaultOptimisticSnapshot, + invalidateVaultOptimisticQueries, + rollbackVaultOptimisticSnapshot, + type VaultOptimisticSnapshot, +} from "../lib/optimisticVaultCache"; interface MutationParams { walletAddress: string; @@ -12,49 +16,15 @@ interface MutationParams { idempotencyKey?: string; } -interface OptimisticSnapshot { - balance?: number; - holdings?: PortfolioHolding[]; - summary?: VaultSummary; - transactions?: Transaction[]; -} - -function buildPendingTransaction( - action: "deposit" | "withdrawal", - amount: number, -): Transaction { - return { - id: `optimistic-${action}-${Date.now()}`, - type: action, - status: "pending", - amount: amount.toFixed(2), - asset: "USDC", - timestamp: new Date().toISOString(), - transactionHash: "pending-" + Date.now(), - }; -} - -function updateHoldings( - current: PortfolioHolding[] | undefined, - deltaUsd: number, -): PortfolioHolding[] | undefined { - if (!current?.length) { - return current; - } - - return current.map((holding, index) => - index === 0 - ? { - ...holding, - valueUsd: Math.max(holding.valueUsd + deltaUsd, 0), - status: "pending", - } - : holding, - ); -} - /** - * Deposit mutation with optimistic UI cache updates. + * Deposit mutation with production-hardened optimistic UI cache updates. + * + * Flow: + * 1. Cancel in-flight reads for related keys + * 2. Snapshot cache (including "never cached" vs "cached") + * 3. Apply optimistic wallet/holdings/TVL/tx patches + * 4. On failure: restore snapshot exactly, then invalidate to reconcile + * 5. On success: invalidate so server truth replaces optimistic rows */ export function useDepositMutation() { const queryClient = useQueryClient(); @@ -72,83 +42,26 @@ export function useDepositMutation() { ); return { walletAddress, amount, referralCode, idempotencyKey }; }, - onMutate: async ({ walletAddress, amount }) => { - const balanceKey = queryKeys.balance.usdc(walletAddress); - const holdingsKey = queryKeys.portfolio.holdings(walletAddress); - const summaryKey = queryKeys.vault.summary(); - const txKey = queryKeys.transactions.list(walletAddress); - - await Promise.all([ - queryClient.cancelQueries({ queryKey: balanceKey }), - queryClient.cancelQueries({ queryKey: holdingsKey }), - queryClient.cancelQueries({ queryKey: summaryKey }), - queryClient.cancelQueries({ queryKey: txKey }), - ]); - - const snapshot: OptimisticSnapshot = { - balance: queryClient.getQueryData(balanceKey), - holdings: queryClient.getQueryData(holdingsKey), - summary: queryClient.getQueryData(summaryKey), - transactions: queryClient.getQueryData(txKey), - }; - - queryClient.setQueryData(balanceKey, (current = 0) => - Math.max(current - amount, 0), - ); - queryClient.setQueryData( - holdingsKey, - (current) => updateHoldings(current, amount), - ); - queryClient.setQueryData(summaryKey, (current) => - current - ? { - ...current, - tvl: current.tvl + amount, - updatedAt: new Date().toISOString(), - } - : current, - ); - queryClient.setQueryData(txKey, (current) => [ - buildPendingTransaction("deposit", amount), - ...(current ?? []), - ]); - + onMutate: async ({ walletAddress, amount }): Promise => { + await cancelVaultOptimisticQueries(queryClient, walletAddress); + const snapshot = captureVaultOptimisticSnapshot(queryClient, walletAddress); + applyOptimisticVaultPatch(queryClient, walletAddress, "deposit", amount); return snapshot; }, onError: (_error, variables, snapshot) => { - queryClient.setQueryData( - queryKeys.balance.usdc(variables.walletAddress), - snapshot?.balance, - ); - queryClient.setQueryData( - queryKeys.portfolio.holdings(variables.walletAddress), - snapshot?.holdings, - ); - queryClient.setQueryData(queryKeys.vault.summary(), snapshot?.summary); - queryClient.setQueryData( - queryKeys.transactions.list(variables.walletAddress), - snapshot?.transactions, - ); + rollbackVaultOptimisticSnapshot(queryClient, variables.walletAddress, snapshot); }, - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ - queryKey: queryKeys.balance.usdc(variables.walletAddress), - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.portfolio.holdings(variables.walletAddress), - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.vault.summary(), - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.transactions.list(variables.walletAddress), - }); + onSettled: (_data, _error, variables) => { + invalidateVaultOptimisticQueries(queryClient, variables.walletAddress); }, }); } /** - * Withdrawal mutation with optimistic UI cache updates. + * Withdrawal mutation with production-hardened optimistic UI cache updates. + * + * Wallet USDC increases and vault holdings/TVL decrease immediately; any + * failure restores the pre-mutation snapshot and reconciles via invalidation. */ export function useWithdrawMutation() { const queryClient = useQueryClient(); @@ -165,77 +78,17 @@ export function useWithdrawMutation() { ); return { walletAddress, amount, idempotencyKey }; }, - onMutate: async ({ walletAddress, amount }) => { - const balanceKey = queryKeys.balance.usdc(walletAddress); - const holdingsKey = queryKeys.portfolio.holdings(walletAddress); - const summaryKey = queryKeys.vault.summary(); - const txKey = queryKeys.transactions.list(walletAddress); - - await Promise.all([ - queryClient.cancelQueries({ queryKey: balanceKey }), - queryClient.cancelQueries({ queryKey: holdingsKey }), - queryClient.cancelQueries({ queryKey: summaryKey }), - queryClient.cancelQueries({ queryKey: txKey }), - ]); - - const snapshot: OptimisticSnapshot = { - balance: queryClient.getQueryData(balanceKey), - holdings: queryClient.getQueryData(holdingsKey), - summary: queryClient.getQueryData(summaryKey), - transactions: queryClient.getQueryData(txKey), - }; - - queryClient.setQueryData(balanceKey, (current = 0) => - Math.max(current - amount, 0), - ); - queryClient.setQueryData( - holdingsKey, - (current) => updateHoldings(current, -amount), - ); - queryClient.setQueryData(summaryKey, (current) => - current - ? { - ...current, - tvl: Math.max(current.tvl - amount, 0), - updatedAt: new Date().toISOString(), - } - : current, - ); - queryClient.setQueryData(txKey, (current) => [ - buildPendingTransaction("withdrawal", amount), - ...(current ?? []), - ]); - + onMutate: async ({ walletAddress, amount }): Promise => { + await cancelVaultOptimisticQueries(queryClient, walletAddress); + const snapshot = captureVaultOptimisticSnapshot(queryClient, walletAddress); + applyOptimisticVaultPatch(queryClient, walletAddress, "withdrawal", amount); return snapshot; }, onError: (_error, variables, snapshot) => { - queryClient.setQueryData( - queryKeys.balance.usdc(variables.walletAddress), - snapshot?.balance, - ); - queryClient.setQueryData( - queryKeys.portfolio.holdings(variables.walletAddress), - snapshot?.holdings, - ); - queryClient.setQueryData(queryKeys.vault.summary(), snapshot?.summary); - queryClient.setQueryData( - queryKeys.transactions.list(variables.walletAddress), - snapshot?.transactions, - ); + rollbackVaultOptimisticSnapshot(queryClient, variables.walletAddress, snapshot); }, - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ - queryKey: queryKeys.balance.usdc(variables.walletAddress), - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.portfolio.holdings(variables.walletAddress), - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.vault.summary(), - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.transactions.list(variables.walletAddress), - }); + onSettled: (_data, _error, variables) => { + invalidateVaultOptimisticQueries(queryClient, variables.walletAddress); }, }); } diff --git a/frontend/src/hooks/useWalletHeartbeat.ts b/frontend/src/hooks/useWalletHeartbeat.ts index 3516d07f..a4fa5788 100644 --- a/frontend/src/hooks/useWalletHeartbeat.ts +++ b/frontend/src/hooks/useWalletHeartbeat.ts @@ -76,6 +76,7 @@ export function useWalletHeartbeat( useEffect(() => { if (!walletAddress) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear local heartbeat when wallet disconnects // Reset heartbeat when the session address is cleared. // eslint-disable-next-line react-hooks/set-state-in-effect -- reset on wallet disconnect setHeartbeat(INITIAL); diff --git a/frontend/src/lib/optimisticVaultCache.test.ts b/frontend/src/lib/optimisticVaultCache.test.ts new file mode 100644 index 00000000..82c27674 --- /dev/null +++ b/frontend/src/lib/optimisticVaultCache.test.ts @@ -0,0 +1,157 @@ +/** + * Unit tests for optimistic vault cache helpers (snapshot / apply / rollback). + */ +import { QueryClient } from "@tanstack/react-query"; +import { describe, it, expect, beforeEach } from "vitest"; +import { + applyOptimisticVaultPatch, + buildPendingTransaction, + captureVaultOptimisticSnapshot, + getVaultCacheKeys, + rollbackVaultOptimisticSnapshot, + updateHoldings, +} from "./optimisticVaultCache"; +import type { PortfolioHolding } from "./portfolioApi"; +import type { VaultSummary } from "./vaultApi"; +import type { Transaction } from "./transactionApi"; + +const WALLET = "GABC123"; + +function makeHolding(overrides: Partial = {}): PortfolioHolding { + return { + id: "h1", + asset: "USDC", + vaultName: "YieldVault", + symbol: "yvUSDC", + shares: 100, + apy: 8, + valueUsd: 200, + unrealizedGainUsd: 5, + issuer: "issuer", + status: "active", + ...overrides, + }; +} + +function makeSummary(overrides: Partial = {}): VaultSummary { + return { + tvl: 1000, + depositCap: 10000, + apy: 8, + participantCount: 10, + monthlyGrowthPct: 1, + strategyStabilityPct: 95, + assetLabel: "USDC", + exchangeRate: 1, + networkFeeEstimate: "0.01", + updatedAt: "2025-01-01T00:00:00.000Z", + contractPaused: false, + strategy: { + id: "rwa-1", + name: "RWA", + issuer: "issuer", + network: "testnet", + rpcUrl: "https://example.test", + status: "active", + description: "test", + }, + ...overrides, + }; +} + +function makeTx(overrides: Partial = {}): Transaction { + return { + id: "existing-1", + type: "deposit", + status: "completed", + amount: "50.00", + asset: "USDC", + timestamp: "2025-01-01T00:00:00.000Z", + transactionHash: "hash-1", + ...overrides, + }; +} + +describe("optimisticVaultCache helpers", () => { + let queryClient: QueryClient; + let keys: ReturnType; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: 0 } }, + }); + keys = getVaultCacheKeys(WALLET); + }); + + it("buildPendingTransaction marks rows as pending with stable ids", () => { + const tx = buildPendingTransaction("deposit", 42.5, 1_700_000_000_000); + expect(tx.id).toBe("optimistic-deposit-1700000000000"); + expect(tx.status).toBe("pending"); + expect(tx.type).toBe("deposit"); + expect(tx.amount).toBe("42.50"); + }); + + it("updateHoldings adjusts first holding and marks it pending", () => { + const updated = updateHoldings([makeHolding(), makeHolding({ id: "h2" })], 50); + expect(updated?.[0].valueUsd).toBe(250); + expect(updated?.[0].status).toBe("pending"); + expect(updated?.[1].valueUsd).toBe(200); + expect(updated?.[1].status).toBe("active"); + }); + + it("applyOptimisticVaultPatch decreases wallet balance on deposit", () => { + queryClient.setQueryData(keys.balanceKey, 500); + queryClient.setQueryData(keys.holdingsKey, [makeHolding()]); + queryClient.setQueryData(keys.summaryKey, makeSummary()); + queryClient.setQueryData(keys.txKey, [makeTx()]); + + applyOptimisticVaultPatch(queryClient, WALLET, "deposit", 100, 1_700_000_000_000); + + expect(queryClient.getQueryData(keys.balanceKey)).toBe(400); + expect(queryClient.getQueryData(keys.holdingsKey)?.[0].valueUsd).toBe(300); + expect(queryClient.getQueryData(keys.summaryKey)?.tvl).toBe(1100); + expect(queryClient.getQueryData(keys.txKey)?.[0].id).toMatch( + /^optimistic-deposit-/, + ); + }); + + it("applyOptimisticVaultPatch increases wallet balance on withdrawal", () => { + queryClient.setQueryData(keys.balanceKey, 500); + queryClient.setQueryData(keys.holdingsKey, [makeHolding({ valueUsd: 300 })]); + queryClient.setQueryData(keys.summaryKey, makeSummary({ tvl: 1000 })); + + applyOptimisticVaultPatch(queryClient, WALLET, "withdrawal", 100, 1_700_000_000_000); + + expect(queryClient.getQueryData(keys.balanceKey)).toBe(600); + expect(queryClient.getQueryData(keys.holdingsKey)?.[0].valueUsd).toBe(200); + expect(queryClient.getQueryData(keys.summaryKey)?.tvl).toBe(900); + }); + + it("rollback restores all cached keys to the pre-mutation snapshot", () => { + queryClient.setQueryData(keys.balanceKey, 500); + queryClient.setQueryData(keys.holdingsKey, [makeHolding()]); + queryClient.setQueryData(keys.summaryKey, makeSummary()); + queryClient.setQueryData(keys.txKey, [makeTx()]); + + const snapshot = captureVaultOptimisticSnapshot(queryClient, WALLET); + applyOptimisticVaultPatch(queryClient, WALLET, "deposit", 100); + rollbackVaultOptimisticSnapshot(queryClient, WALLET, snapshot); + + expect(queryClient.getQueryData(keys.balanceKey)).toBe(500); + expect(queryClient.getQueryData(keys.holdingsKey)).toEqual([makeHolding()]); + expect(queryClient.getQueryData(keys.summaryKey)).toEqual(makeSummary()); + expect(queryClient.getQueryData(keys.txKey)).toEqual([makeTx()]); + }); + + it("rollback removes keys that did not exist before the optimistic patch", () => { + const snapshot = captureVaultOptimisticSnapshot(queryClient, WALLET); + expect(snapshot.balance.exists).toBe(false); + + applyOptimisticVaultPatch(queryClient, WALLET, "deposit", 50); + expect(queryClient.getQueryData(keys.balanceKey)).toBe(0); + + rollbackVaultOptimisticSnapshot(queryClient, WALLET, snapshot); + expect(queryClient.getQueryData(keys.balanceKey)).toBeUndefined(); + expect(queryClient.getQueryState(keys.balanceKey)).toBeUndefined(); + }); +}); diff --git a/frontend/src/lib/optimisticVaultCache.ts b/frontend/src/lib/optimisticVaultCache.ts new file mode 100644 index 00000000..b51402d6 --- /dev/null +++ b/frontend/src/lib/optimisticVaultCache.ts @@ -0,0 +1,198 @@ +import type { QueryClient, QueryKey } from "@tanstack/react-query"; +import { queryKeys } from "./queryClient"; +import type { PortfolioHolding } from "./portfolioApi"; +import type { VaultSummary } from "./vaultApi"; +import type { Transaction } from "./transactionApi"; + +export type VaultMutationAction = "deposit" | "withdrawal"; + +/** + * Captures whether a query key had cached data so rollback can distinguish + * "never cached" from "cached as undefined/empty". + */ +export interface SnapshotEntry { + exists: boolean; + data?: T; +} + +export interface VaultOptimisticSnapshot { + balance: SnapshotEntry; + holdings: SnapshotEntry; + summary: SnapshotEntry; + transactions: SnapshotEntry; +} + +export interface VaultCacheKeys { + balanceKey: QueryKey; + holdingsKey: QueryKey; + summaryKey: QueryKey; + txKey: QueryKey; +} + +export function getVaultCacheKeys(walletAddress: string): VaultCacheKeys { + return { + balanceKey: queryKeys.balance.usdc(walletAddress), + holdingsKey: queryKeys.portfolio.holdings(walletAddress), + summaryKey: queryKeys.vault.summary(), + txKey: queryKeys.transactions.list(walletAddress), + }; +} + +function captureEntry( + queryClient: QueryClient, + queryKey: QueryKey, +): SnapshotEntry { + const state = queryClient.getQueryState(queryKey); + if (!state) { + return { exists: false }; + } + + return { + exists: true, + data: queryClient.getQueryData(queryKey), + }; +} + +export function captureVaultOptimisticSnapshot( + queryClient: QueryClient, + walletAddress: string, +): VaultOptimisticSnapshot { + const keys = getVaultCacheKeys(walletAddress); + return { + balance: captureEntry(queryClient, keys.balanceKey), + holdings: captureEntry(queryClient, keys.holdingsKey), + summary: captureEntry(queryClient, keys.summaryKey), + transactions: captureEntry(queryClient, keys.txKey), + }; +} + +export function restoreSnapshotEntry( + queryClient: QueryClient, + queryKey: QueryKey, + entry: SnapshotEntry | undefined, +): void { + if (!entry) { + return; + } + + if (!entry.exists) { + queryClient.removeQueries({ queryKey, exact: true }); + return; + } + + queryClient.setQueryData(queryKey, entry.data); +} + +export function rollbackVaultOptimisticSnapshot( + queryClient: QueryClient, + walletAddress: string, + snapshot: VaultOptimisticSnapshot | undefined, +): void { + if (!snapshot) { + return; + } + + const keys = getVaultCacheKeys(walletAddress); + restoreSnapshotEntry(queryClient, keys.balanceKey, snapshot.balance); + restoreSnapshotEntry(queryClient, keys.holdingsKey, snapshot.holdings); + restoreSnapshotEntry(queryClient, keys.summaryKey, snapshot.summary); + restoreSnapshotEntry(queryClient, keys.txKey, snapshot.transactions); +} + +export async function cancelVaultOptimisticQueries( + queryClient: QueryClient, + walletAddress: string, +): Promise { + const keys = getVaultCacheKeys(walletAddress); + await Promise.all([ + queryClient.cancelQueries({ queryKey: keys.balanceKey }), + queryClient.cancelQueries({ queryKey: keys.holdingsKey }), + queryClient.cancelQueries({ queryKey: keys.summaryKey }), + queryClient.cancelQueries({ queryKey: keys.txKey }), + ]); +} + +export function invalidateVaultOptimisticQueries( + queryClient: QueryClient, + walletAddress: string, +): void { + const keys = getVaultCacheKeys(walletAddress); + void queryClient.invalidateQueries({ queryKey: keys.balanceKey }); + void queryClient.invalidateQueries({ queryKey: keys.holdingsKey }); + void queryClient.invalidateQueries({ queryKey: keys.summaryKey }); + void queryClient.invalidateQueries({ queryKey: keys.txKey }); +} + +export function buildPendingTransaction( + action: VaultMutationAction, + amount: number, + now: number = Date.now(), +): Transaction { + return { + id: `optimistic-${action}-${now}`, + type: action, + status: "pending", + amount: amount.toFixed(2), + asset: "USDC", + timestamp: new Date(now).toISOString(), + transactionHash: `pending-${now}`, + }; +} + +export function updateHoldings( + current: PortfolioHolding[] | undefined, + deltaUsd: number, +): PortfolioHolding[] | undefined { + if (!current?.length) { + return current; + } + + return current.map((holding, index) => + index === 0 + ? { + ...holding, + valueUsd: Math.max(holding.valueUsd + deltaUsd, 0), + status: "pending", + } + : holding, + ); +} + +/** + * Apply optimistic cache patches for a deposit or withdrawal. + * + * Deposit: wallet USDC decreases, vault holdings/TVL increase. + * Withdrawal: wallet USDC increases, vault holdings/TVL decrease. + */ +export function applyOptimisticVaultPatch( + queryClient: QueryClient, + walletAddress: string, + action: VaultMutationAction, + amount: number, + now: number = Date.now(), +): void { + const keys = getVaultCacheKeys(walletAddress); + const walletDelta = action === "deposit" ? -amount : amount; + const vaultDelta = action === "deposit" ? amount : -amount; + + queryClient.setQueryData(keys.balanceKey, (current = 0) => + Math.max(current + walletDelta, 0), + ); + queryClient.setQueryData( + keys.holdingsKey, + (current) => updateHoldings(current, vaultDelta), + ); + queryClient.setQueryData(keys.summaryKey, (current) => + current + ? { + ...current, + tvl: Math.max(current.tvl + vaultDelta, 0), + updatedAt: new Date(now).toISOString(), + } + : current, + ); + queryClient.setQueryData(keys.txKey, (current) => [ + buildPendingTransaction(action, amount, now), + ...(current ?? []), + ]); +} diff --git a/frontend/src/pages/TransactionHistory.test.tsx b/frontend/src/pages/TransactionHistory.test.tsx index 43be90d5..73811e58 100644 --- a/frontend/src/pages/TransactionHistory.test.tsx +++ b/frontend/src/pages/TransactionHistory.test.tsx @@ -55,6 +55,7 @@ function makeTransaction(overrides: Partial = {}): Transaction { amount: "100.00", asset: "USDC", timestamp: "2025-01-15T10:30:00Z", + transactionHash: "fixture-transaction-default", // Deliberately not 40 chars — the pre-commit AWS secret regex flags /[A-Za-z0-9/+=]{40}/. transactionHash: "tx-hash-abcdef1234567890abcdef1234567890ab", ...overrides, @@ -626,6 +627,7 @@ 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 @@ -704,12 +706,14 @@ describe("TransactionHistory — status filter", () => { id: "2", status: "pending", asset: "EURC", + transactionHash: "fixture-transaction-pending", transactionHash: "tx-hash-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", }), makeTransaction({ id: "3", status: "failed", asset: "XLM", + transactionHash: "fixture-transaction-failed", transactionHash: "tx-hash-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", }), ]); diff --git a/frontend/src/tests/VaultDashboardWizard.test.tsx b/frontend/src/tests/VaultDashboardWizard.test.tsx index e54a6582..73c82d14 100644 --- a/frontend/src/tests/VaultDashboardWizard.test.tsx +++ b/frontend/src/tests/VaultDashboardWizard.test.tsx @@ -1,10 +1,20 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider, type UseQueryResult } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router-dom"; import { describe, it, expect, vi, beforeEach } from "vitest"; import VaultDashboard from "../components/VaultDashboard"; +import { PreferencesProvider } from "../context/PreferencesContext"; +import { ToastProvider } from "../context/ToastContext"; import { VaultProvider } from "../context/VaultContext"; import * as vaultDataHooks from "../hooks/useVaultData"; -import type { UseQueryResult } from "@tanstack/react-query"; +import * as vaultApi from "../lib/vaultApi"; import type { VaultSummary } from "../lib/vaultApi"; + +vi.mock("../hooks/useVaultData", () => ({ + useVaultSummary: vi.fn(), + useVaultHistory: vi.fn(), +})); + import { ToastProvider } from "../context/ToastContext"; import { PreferencesProvider } from "../context/PreferencesContext"; import { MemoryRouter } from "react-router-dom"; @@ -31,6 +41,8 @@ vi.mock("../hooks/useVaultData", () => ({ })); vi.mock("../hooks/useVaultMutations", () => ({ + useDepositMutation: () => ({ mutateAsync: vi.fn().mockResolvedValue({}), isPending: false }), + useWithdrawMutation: () => ({ mutateAsync: vi.fn().mockResolvedValue({}), isPending: false }), useDepositMutation: () => ({ mutateAsync: mockDepositMutateAsync, isPending: false, @@ -42,15 +54,14 @@ vi.mock("../hooks/useVaultMutations", () => ({ })); vi.mock("../hooks/useTransactionConfirmation", () => ({ - useTransactionConfirmation: () => ({ - requestConfirmation: vi.fn().mockResolvedValue(true), - modal: null, - isOpen: false, - }), + useTransactionConfirmation: () => ({ requestConfirmation: vi.fn().mockResolvedValue(true), modal: null, isOpen: false }), })); vi.mock("../hooks/useTokenAllowance", () => ({ useTokenAllowance: vi.fn(() => ({ + allowance: Number.MAX_SAFE_INTEGER, + approvalStatus: "confirmed", + needsApproval: () => false, allowance: 1_000_000, approvalStatus: "confirmed", needsApproval: vi.fn().mockReturnValue(false), @@ -58,8 +69,14 @@ vi.mock("../hooks/useTokenAllowance", () => ({ resetApproval: vi.fn(), })), })); - vi.mock("../hooks/useFeeEstimate", () => ({ + useFeeEstimate: () => ({ feeXlm: 0.1, feeUsd: 0.01, isEstimating: false, isHighFee: false }), +})); + +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", useFeeEstimate: () => ({ feeXlm: 0.1, feeUsd: 0.01, @@ -81,22 +98,13 @@ const mockSummary: VaultSummary = { networkFeeEstimate: "~0.00001 XLM", updatedAt: new Date().toISOString(), 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.", - }, + strategy: { id: "stellar-benji", name: "Franklin BENJI Connector", issuer: "Franklin Templeton", network: "Stellar", rpcUrl: "https://soroban-testnet.stellar.org", status: "active", description: "Connector strategy." }, }; -const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, -}); +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} @@ -113,6 +121,13 @@ const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( describe("VaultDashboard Wizard", () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(mockSummary), { status: 200, headers: { "content-type": "application/json" } }))); + vi.mocked(vaultDataHooks.useVaultSummary).mockReturnValue({ data: mockSummary, isLoading: false, error: null, refetch: vi.fn() } as unknown as UseQueryResult); + vi.mocked(vaultDataHooks.useVaultHistory).mockReturnValue({ data: [], isLoading: false, error: null, refetch: vi.fn() } as unknown as UseQueryResult<{ date: string; value: number }[], Error>); + }); + + it("navigates through the deposit wizard steps", async () => { + render(); localStorage.clear(); mockDepositMutateAsync.mockResolvedValue({}); vi.stubGlobal( @@ -152,20 +167,17 @@ describe("VaultDashboard Wizard", () => { expect(await screen.findByText("Amount to deposit")).toBeInTheDocument(); const input = screen.getByLabelText("Deposit amount"); fireEvent.change(input, { target: { value: "10" } }); - fireEvent.click(screen.getByText("Review Transaction")); - - await waitFor(() => { - expect(screen.getByText("Confirm Transaction")).toBeInTheDocument(); - }); + await waitFor(() => expect(screen.getByText("Confirm Transaction")).toBeInTheDocument()); expect(screen.getByText("10.00 USDC")).toBeInTheDocument(); - fireEvent.click(screen.getByText("Back")); - expect(screen.getByText("Amount to deposit")).toBeInTheDocument(); expect(screen.getByDisplayValue("10")).toBeInTheDocument(); - fireEvent.click(screen.getByText("Review Transaction")); + fireEvent.click(screen.getByRole("button", { name: /Confirm deposit/i })); + fireEvent.click(screen.getByText("Confirm deposit")); + await waitFor(() => expect(screen.getByText("Transaction Successful")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Done")); fireEvent.click(screen.getByRole("button", { name: /Confirm deposit/i })); diff --git a/scripts/vitest.config.ts b/scripts/vitest.config.ts index 58248e91..f157f416 100644 --- a/scripts/vitest.config.ts +++ b/scripts/vitest.config.ts @@ -2,9 +2,10 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + environment: 'node', environment: 'jsdom', include: ['scripts/**/*.test.ts'], globals: true, - setupFiles: ['./tests/setup.ts'], + setupFiles: ['tests/setup.ts'], }, });