diff --git a/frontend/src/app/hooks/useApi.optimisticRollback.test.tsx b/frontend/src/app/hooks/useApi.optimisticRollback.test.tsx
new file mode 100644
index 00000000..1b646f62
--- /dev/null
+++ b/frontend/src/app/hooks/useApi.optimisticRollback.test.tsx
@@ -0,0 +1,343 @@
+/**
+ * hooks/useApi.optimisticRollback.test.tsx
+ *
+ * Tests for #1226: optimistic-update snapshot, rollback on error, and
+ * onSettled invalidation in useRepayLoan, useDepositToPool, and
+ * useWithdrawFromPool.
+ */
+
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import {
+ useRepayLoan,
+ useDepositToPool,
+ useWithdrawFromPool,
+ queryKeys,
+ type LoanDetails,
+ type PoolStats,
+ type DepositorPortfolio,
+ type BorrowerLoan,
+} from "./useApi";
+
+function createTestHarness() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ mutations: { retry: false },
+ queries: { retry: false },
+ },
+ });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ return { queryClient, wrapper };
+}
+
+function mockFetchFailure() {
+ return jest.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ statusText: "Internal Server Error",
+ json: async () => ({ message: "boom" }),
+ });
+}
+
+function mockFetchSuccess(data: T) {
+ return jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => data,
+ });
+}
+
+const BORROWER = "GBORROWER123";
+const DEPOSITOR = "GDEPOSITOR456";
+const LOAN_ID = 1;
+
+const seedLoanDetail = (): LoanDetails => ({
+ loanId: LOAN_ID,
+ principal: 1000,
+ accruedInterest: 50,
+ totalRepaid: 200,
+ totalOwed: 850,
+ interestRate: 5,
+ status: "active",
+ events: [],
+});
+
+const seedBorrowerLoans = (): BorrowerLoan[] => [
+ {
+ id: LOAN_ID,
+ principal: 1000,
+ accruedInterest: 50,
+ totalOwed: 850,
+ totalRepaid: 200,
+ nextPaymentDeadline: "2026-07-01",
+ status: "active",
+ borrower: BORROWER,
+ },
+];
+
+const seedPoolStats = (): PoolStats => ({
+ totalDeposits: 10000,
+ totalOutstanding: 5000,
+ utilizationRate: 0.5,
+ apy: 8,
+ activeLoansCount: 3,
+});
+
+const seedDepositor = (): DepositorPortfolio => ({
+ address: DEPOSITOR,
+ depositAmount: 500,
+ sharePercent: 5,
+ estimatedYield: 40,
+ apy: 8,
+ firstDepositAt: "2026-01-01",
+});
+
+describe("useRepayLoan optimistic rollback", () => {
+ const originalFetch = global.fetch;
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ jest.restoreAllMocks();
+ });
+
+ function seedRepayCache(queryClient: QueryClient) {
+ const loanDetail = seedLoanDetail();
+ const borrowerLoans = seedBorrowerLoans();
+ const poolStats = seedPoolStats();
+
+ queryClient.setQueryData(queryKeys.loans.detail(String(LOAN_ID)), loanDetail);
+ queryClient.setQueryData(queryKeys.borrowerLoans.byAddress(BORROWER), borrowerLoans);
+ queryClient.setQueryData(queryKeys.pool.stats(), poolStats);
+
+ return { loanDetail, borrowerLoans, poolStats };
+ }
+
+ it("onMutate updates loan detail optimistically while pending", async () => {
+ global.fetch = mockFetchSuccess({ txHash: "abc" }) as unknown as typeof fetch;
+ const { queryClient, wrapper } = createTestHarness();
+ seedRepayCache(queryClient);
+
+ const { result } = renderHook(() => useRepayLoan(), { wrapper });
+
+ result.current.mutate({ loanId: LOAN_ID, amount: 100, borrowerAddress: BORROWER });
+
+ await waitFor(() => {
+ const cached = queryClient.getQueryData(queryKeys.loans.detail(String(LOAN_ID)));
+ expect(cached?.totalOwed).toBe(750);
+ expect(cached?.totalRepaid).toBe(300);
+ expect(cached?.status).toBe("active");
+ });
+ });
+
+ it("onMutate flips status to repaid when repayment covers totalOwed", async () => {
+ global.fetch = mockFetchSuccess({ txHash: "abc" }) as unknown as typeof fetch;
+ const { queryClient, wrapper } = createTestHarness();
+ seedRepayCache(queryClient);
+
+ const { result } = renderHook(() => useRepayLoan(), { wrapper });
+
+ result.current.mutate({ loanId: LOAN_ID, amount: 850, borrowerAddress: BORROWER });
+
+ await waitFor(() => {
+ const cached = queryClient.getQueryData(queryKeys.loans.detail(String(LOAN_ID)));
+ expect(cached?.totalOwed).toBe(0);
+ expect(cached?.totalRepaid).toBe(1050);
+ expect(cached?.status).toBe("repaid");
+ });
+ });
+
+ it("onError restores exact previous loan detail, borrower loans, and pool stats", async () => {
+ global.fetch = mockFetchFailure() as unknown as typeof fetch;
+ const { queryClient, wrapper } = createTestHarness();
+ const { loanDetail, borrowerLoans, poolStats } = seedRepayCache(queryClient);
+
+ const { result } = renderHook(() => useRepayLoan(), { wrapper });
+
+ result.current.mutate({ loanId: LOAN_ID, amount: 100, borrowerAddress: BORROWER });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+
+ expect(queryClient.getQueryData(queryKeys.loans.detail(String(LOAN_ID)))).toEqual(loanDetail);
+ expect(queryClient.getQueryData(queryKeys.borrowerLoans.byAddress(BORROWER))).toEqual(
+ borrowerLoans,
+ );
+ expect(queryClient.getQueryData(queryKeys.pool.stats())).toEqual(poolStats);
+ });
+
+ it("onSettled invalidates loans.detail, borrowerLoans.byAddress, and pool.stats", async () => {
+ global.fetch = mockFetchSuccess({ txHash: "abc" }) as unknown as typeof fetch;
+ const { queryClient, wrapper } = createTestHarness();
+ seedRepayCache(queryClient);
+ const invalidateSpy = jest.spyOn(queryClient, "invalidateQueries");
+
+ const { result } = renderHook(() => useRepayLoan(), { wrapper });
+
+ result.current.mutate({ loanId: LOAN_ID, amount: 100, borrowerAddress: BORROWER });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(invalidateSpy).toHaveBeenCalledWith({
+ queryKey: queryKeys.loans.detail(String(LOAN_ID)),
+ });
+ expect(invalidateSpy).toHaveBeenCalledWith({
+ queryKey: queryKeys.borrowerLoans.byAddress(BORROWER),
+ });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.pool.stats() });
+ });
+});
+
+describe("useWithdrawFromPool optimistic rollback", () => {
+ const originalFetch = global.fetch;
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ jest.restoreAllMocks();
+ });
+
+ function seedWithdrawCache(queryClient: QueryClient) {
+ const poolStats = seedPoolStats();
+ const depositor = seedDepositor();
+
+ queryClient.setQueryData(queryKeys.pool.stats(), poolStats);
+ queryClient.setQueryData(queryKeys.pool.depositor(DEPOSITOR), depositor);
+
+ return { poolStats, depositor };
+ }
+
+ it("onMutate clamps depositAmount and totalDeposits at 0 when withdrawal exceeds balance", async () => {
+ global.fetch = mockFetchSuccess({
+ unsignedTxXdr: "xdr",
+ networkPassphrase: "pass",
+ }) as unknown as typeof fetch;
+ const { queryClient, wrapper } = createTestHarness();
+ queryClient.setQueryData(queryKeys.pool.stats(), { ...seedPoolStats(), totalDeposits: 30 });
+ queryClient.setQueryData(queryKeys.pool.depositor(DEPOSITOR), {
+ ...seedDepositor(),
+ depositAmount: 30,
+ });
+
+ const { result } = renderHook(() => useWithdrawFromPool(), { wrapper });
+
+ result.current.mutate({
+ amount: 100,
+ depositorAddress: DEPOSITOR,
+ token: "USDC",
+ });
+
+ await waitFor(() => {
+ const cachedDepositor = queryClient.getQueryData(
+ queryKeys.pool.depositor(DEPOSITOR),
+ );
+ const cachedStats = queryClient.getQueryData(queryKeys.pool.stats());
+ expect(cachedDepositor?.depositAmount).toBe(0);
+ expect(cachedStats?.totalDeposits).toBe(0);
+ });
+ });
+
+ it("onError restores exact previous pool stats and depositor portfolio", async () => {
+ global.fetch = mockFetchFailure() as unknown as typeof fetch;
+ const { queryClient, wrapper } = createTestHarness();
+ const { poolStats, depositor } = seedWithdrawCache(queryClient);
+
+ const { result } = renderHook(() => useWithdrawFromPool(), { wrapper });
+
+ result.current.mutate({
+ amount: 50,
+ depositorAddress: DEPOSITOR,
+ token: "USDC",
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+
+ expect(queryClient.getQueryData(queryKeys.pool.stats())).toEqual(poolStats);
+ expect(queryClient.getQueryData(queryKeys.pool.depositor(DEPOSITOR))).toEqual(depositor);
+ });
+
+ it("onSettled invalidates pool.stats and pool.depositor", async () => {
+ global.fetch = mockFetchSuccess({
+ unsignedTxXdr: "xdr",
+ networkPassphrase: "pass",
+ }) as unknown as typeof fetch;
+ const { queryClient, wrapper } = createTestHarness();
+ seedWithdrawCache(queryClient);
+ const invalidateSpy = jest.spyOn(queryClient, "invalidateQueries");
+
+ const { result } = renderHook(() => useWithdrawFromPool(), { wrapper });
+
+ result.current.mutate({
+ amount: 50,
+ depositorAddress: DEPOSITOR,
+ token: "USDC",
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: queryKeys.pool.stats() });
+ expect(invalidateSpy).toHaveBeenCalledWith({
+ queryKey: queryKeys.pool.depositor(DEPOSITOR),
+ });
+ });
+});
+
+describe("useDepositToPool optimistic rollback", () => {
+ const originalFetch = global.fetch;
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ jest.restoreAllMocks();
+ });
+
+ it("onMutate increases pool stats and depositor depositAmount optimistically", async () => {
+ global.fetch = mockFetchSuccess({
+ unsignedTxXdr: "xdr",
+ networkPassphrase: "pass",
+ }) as unknown as typeof fetch;
+ const { queryClient, wrapper } = createTestHarness();
+ const poolStats = seedPoolStats();
+ const depositor = seedDepositor();
+ queryClient.setQueryData(queryKeys.pool.stats(), poolStats);
+ queryClient.setQueryData(queryKeys.pool.depositor(DEPOSITOR), depositor);
+
+ const { result } = renderHook(() => useDepositToPool(), { wrapper });
+
+ result.current.mutate({
+ amount: 200,
+ depositorAddress: DEPOSITOR,
+ token: "USDC",
+ });
+
+ await waitFor(() => {
+ const cachedStats = queryClient.getQueryData(queryKeys.pool.stats());
+ const cachedDepositor = queryClient.getQueryData(
+ queryKeys.pool.depositor(DEPOSITOR),
+ );
+ expect(cachedStats?.totalDeposits).toBe(10200);
+ expect(cachedDepositor?.depositAmount).toBe(700);
+ });
+ });
+
+ it("onError restores exact previous pool stats and depositor portfolio", async () => {
+ global.fetch = mockFetchFailure() as unknown as typeof fetch;
+ const { queryClient, wrapper } = createTestHarness();
+ const poolStats = seedPoolStats();
+ const depositor = seedDepositor();
+ queryClient.setQueryData(queryKeys.pool.stats(), poolStats);
+ queryClient.setQueryData(queryKeys.pool.depositor(DEPOSITOR), depositor);
+
+ const { result } = renderHook(() => useDepositToPool(), { wrapper });
+
+ result.current.mutate({
+ amount: 200,
+ depositorAddress: DEPOSITOR,
+ token: "USDC",
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+
+ expect(queryClient.getQueryData(queryKeys.pool.stats())).toEqual(poolStats);
+ expect(queryClient.getQueryData(queryKeys.pool.depositor(DEPOSITOR))).toEqual(depositor);
+ });
+});