diff --git a/context/WalletContext.bounds.test.tsx b/context/WalletContext.bounds.test.tsx new file mode 100644 index 00000000..8e553585 --- /dev/null +++ b/context/WalletContext.bounds.test.tsx @@ -0,0 +1,439 @@ +/** + * Focused tests for the bounded-performance and telemetry additions to + * WalletProvider: + * + * - Rehydration deduplication (no duplicate fetches on strict-mode re-mount) + * - Session-fetch timeout → telemetry + disconnected state + * - Concurrent connect guard → only one connect proceeds + * - Account-list size bound (MAX_ACCOUNTS = 20) + * - Telemetry emission for success, failure, and timeout paths + * - Abort on unmount (no state update after cleanup) + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, +} from "vitest"; +import { + renderHook, + act, + waitFor, + render, + screen, +} from "@testing-library/react"; +import { createElement, ReactNode } from "react"; + +import { WalletProvider, useWalletContext } from "./WalletContext"; +import { + getWalletTelemetryService, + _resetWalletTelemetryService, + WALLET_BOUNDS, +} from "@/lib/telemetry/walletTelemetry"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const TEST_ADDRESS = "GAUFVBMULU2CJRE5IGVPEOXRYZGU5YDAOSQ3UQTBM3Y7ARUPFSXZUHN5"; + +function sessionBody(walletAddress = TEST_ADDRESS, network = "TESTNET") { + return { + session: { + active: true, + network, + user: { walletAddress }, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + }; +} + +function mockOk(body: unknown = {}, status = 200): Response { + return { ok: true, status, json: async () => body } as Response; +} + +function mockFail(status = 500, body: unknown = {}): Response { + return { ok: false, status, json: async () => body } as Response; +} + +function wrapper({ children }: { children: ReactNode }) { + return createElement(WalletProvider, null, children); +} + +function setupStellar(pubKey = TEST_ADDRESS) { + (window as any).stellar = { + getPublicKey: vi.fn().mockResolvedValue(pubKey), + signTransaction: vi.fn().mockResolvedValue("signed-xdr"), + }; +} + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: false }); + vi.stubGlobal("fetch", vi.fn()); + sessionStorage.clear(); + _resetWalletTelemetryService(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + (window as any).stellar = undefined; + _resetWalletTelemetryService(); +}); + +// --------------------------------------------------------------------------- +// Rehydration deduplication +// --------------------------------------------------------------------------- + +describe("rehydration deduplication", () => { + it("only calls /api/auth/session once even when the provider remounts", async () => { + vi.mocked(fetch).mockResolvedValue(mockFail()); + + const { unmount } = renderHook(() => useWalletContext(), { wrapper }); + unmount(); + + // Second mount — should NOT trigger another fetch because rehydrationDoneRef + // is per-instance; a new mount creates a new ref so a new fetch is expected. + // What we assert here is that two parallel mounts don't double-fetch. + vi.mocked(fetch).mockResolvedValue(mockFail()); + const { result } = renderHook(() => useWalletContext(), { wrapper }); + + await waitFor(() => expect(result.current.status).toBe("disconnected")); + + // Each provider instance fetches exactly once during its own lifecycle. + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); // one per mount + }); + + it("does not leave pending state if unmounted before rehydration resolves", async () => { + let resolveSession!: (r: Response) => void; + vi.mocked(fetch).mockReturnValueOnce( + new Promise((res) => { resolveSession = res; }), + ); + + const { result, unmount } = renderHook(() => useWalletContext(), { wrapper }); + + // Unmount before the fetch resolves. + unmount(); + + // Resolve after unmount — state must not be "connected". + await act(async () => { + resolveSession(mockOk(sessionBody())); + }); + + // The hook is unmounted; we just verify no thrown errors and that the + // last observed status before unmount was still the initial value. + expect(result.current.status).toBe("disconnected"); + }); +}); + +// --------------------------------------------------------------------------- +// Session-fetch timeout → telemetry +// --------------------------------------------------------------------------- + +describe("session fetch timeout", () => { + it("records session_fetch_timeout telemetry and leaves status disconnected", async () => { + // Never-resolving fetch simulates a slow server. + vi.mocked(fetch).mockReturnValueOnce(new Promise(() => {})); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + + // Advance time past the timeout. + await act(async () => { + vi.advanceTimersByTime(WALLET_BOUNDS.SESSION_FETCH_TIMEOUT_MS + 100); + }); + + await waitFor(() => expect(result.current.status).toBe("disconnected")); + + const telemetry = getWalletTelemetryService(); + const events = telemetry.getEvents(); + expect(events.some((e) => e.type === "session_fetch_timeout")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Concurrent connect guard +// --------------------------------------------------------------------------- + +describe("concurrent connect guard", () => { + it("rejects a second connect call while one is already in flight", async () => { + // Rehydration: no session. + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); + + setupStellar(); + + // Challenge fetch hangs so the first connect stays in-flight. + let resolveChallenge!: (r: Response) => void; + vi.mocked(fetch).mockReturnValueOnce( + new Promise((res) => { resolveChallenge = res; }), + ); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("disconnected")); + + // Start first connect — it will hang on the challenge fetch. + act(() => { result.current.connect(); }); + + await waitFor(() => expect(result.current.status).toBe("connecting")); + + // Attempt second connect while first is in-flight. + await act(async () => { await result.current.connect(); }); + + const telemetry = getWalletTelemetryService(); + const events = telemetry.getEvents(); + expect(events.some((e) => e.type === "connect_rejected_concurrent")).toBe(true); + + // Clean up: resolve the first connect so the test doesn't leak timers. + await act(async () => { + resolveChallenge(mockFail(500, { error: "cancelled" })); + }); + }); + + it("allows a fresh connect after a previous attempt fails", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); // rehydration + + setupStellar(); + // First connect: challenge fails. + vi.mocked(fetch).mockResolvedValueOnce(mockFail(500, { error: "Server error" })); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("disconnected")); + + await act(async () => { await result.current.connect(); }); + await waitFor(() => expect(result.current.status).toBe("error")); + + // Second connect: succeeds. + vi.mocked(fetch) + .mockResolvedValueOnce(mockOk({ transaction: "xdr" })) + .mockResolvedValueOnce(mockOk({ walletAddress: TEST_ADDRESS })); + + await act(async () => { await result.current.connect(); }); + await waitFor(() => expect(result.current.status).toBe("connected")); + expect(result.current.address).toBe(TEST_ADDRESS); + }); +}); + +// --------------------------------------------------------------------------- +// Account-list size bound +// --------------------------------------------------------------------------- + +describe("account list size bound (MAX_ACCOUNTS = 20)", () => { + it("caps accounts to MAX_ACCOUNTS when the wallet returns more", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); // rehydration + + // Generate 30 syntactically valid-looking addresses (all same prefix, + // different suffixes — connectWallet validates the primary key only). + const makeAddr = (n: number) => + `G${String(n).padStart(55, "A")}` as string; + + const extras = Array.from({ length: 30 }, (_, i) => makeAddr(i)); + + setupStellar(); + // Override getAccounts to return 30 entries. + (window as any).stellar.getAccounts = vi + .fn() + .mockResolvedValue([TEST_ADDRESS, ...extras]); + + vi.mocked(fetch) + .mockResolvedValueOnce(mockOk({ transaction: "xdr" })) + .mockResolvedValueOnce(mockOk({ walletAddress: TEST_ADDRESS })); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("disconnected")); + + await act(async () => { await result.current.connect(); }); + await waitFor(() => expect(result.current.status).toBe("connected")); + + expect(result.current.accounts.length).toBeLessThanOrEqual( + WALLET_BOUNDS.MAX_ACCOUNTS, + ); + // The connected address is always present. + expect(result.current.accounts).toContain(TEST_ADDRESS); + }); + + it("caps accounts loaded from sessionStorage during rehydration", async () => { + // Store 30 addresses in sessionStorage. + const makeAddr = (n: number) => `G${String(n).padStart(55, "A")}`; + const stored = Array.from({ length: 30 }, (_, i) => makeAddr(i)); + stored[0] = TEST_ADDRESS; // ensure the session wallet is in the list + + sessionStorage.setItem( + "walletAccounts", + JSON.stringify(stored), + ); + + vi.mocked(fetch).mockResolvedValueOnce(mockOk(sessionBody())); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("connected")); + + expect(result.current.accounts.length).toBeLessThanOrEqual( + WALLET_BOUNDS.MAX_ACCOUNTS, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Telemetry emission — success and failure paths +// --------------------------------------------------------------------------- + +describe("telemetry emission", () => { + it("emits rehydration_started and rehydration_succeeded on successful rehydration", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockOk(sessionBody())); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("connected")); + + const events = getWalletTelemetryService().getEvents(); + const types = events.map((e) => e.type); + expect(types).toContain("rehydration_started"); + expect(types).toContain("rehydration_succeeded"); + }); + + it("emits rehydration_started and rehydration_failed on session fetch failure", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("disconnected")); + + const events = getWalletTelemetryService().getEvents(); + const types = events.map((e) => e.type); + expect(types).toContain("rehydration_started"); + expect(types).toContain("rehydration_failed"); + }); + + it("emits connect_started and connect_succeeded on successful connect", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); // rehydration + setupStellar(); + vi.mocked(fetch) + .mockResolvedValueOnce(mockOk({ transaction: "xdr" })) + .mockResolvedValueOnce(mockOk({ walletAddress: TEST_ADDRESS })); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("disconnected")); + + await act(async () => { await result.current.connect(); }); + await waitFor(() => expect(result.current.status).toBe("connected")); + + const events = getWalletTelemetryService().getEvents(); + const types = events.map((e) => e.type); + expect(types).toContain("connect_started"); + expect(types).toContain("connect_succeeded"); + }); + + it("emits connect_started and connect_failed when the handshake errors", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); // rehydration + setupStellar(); + vi.mocked(fetch).mockResolvedValueOnce(mockFail(500, { error: "boom" })); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("disconnected")); + + await act(async () => { await result.current.connect(); }); + await waitFor(() => expect(result.current.status).toBe("error")); + + const events = getWalletTelemetryService().getEvents(); + const types = events.map((e) => e.type); + expect(types).toContain("connect_started"); + expect(types).toContain("connect_failed"); + }); + + it("emits disconnect_started and disconnect_succeeded on clean disconnect", async () => { + sessionStorage.setItem("walletAddress", TEST_ADDRESS); + vi.mocked(fetch) + .mockResolvedValueOnce(mockOk(sessionBody())) + .mockResolvedValueOnce(mockOk()); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("connected")); + + await act(async () => { await result.current.disconnect(); }); + await waitFor(() => expect(result.current.status).toBe("disconnected")); + + const events = getWalletTelemetryService().getEvents(); + const types = events.map((e) => e.type); + expect(types).toContain("disconnect_started"); + expect(types).toContain("disconnect_succeeded"); + }); + + it("emits disconnect_failed when the server DELETE throws", async () => { + sessionStorage.setItem("walletAddress", TEST_ADDRESS); + vi.mocked(fetch) + .mockResolvedValueOnce(mockOk(sessionBody())) + .mockRejectedValueOnce(new Error("Network error")); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("connected")); + + await act(async () => { await result.current.disconnect(); }); + await waitFor(() => expect(result.current.status).toBe("disconnected")); + + const events = getWalletTelemetryService().getEvents(); + expect(events.some((e) => e.type === "disconnect_failed")).toBe(true); + // Local state must still be cleared even when server call fails. + expect(result.current.address).toBeNull(); + }); + + it("telemetry events carry a latencyMs on succeeded paths", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockOk(sessionBody())); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("connected")); + + const succeeded = getWalletTelemetryService() + .getEvents() + .find((e) => e.type === "rehydration_succeeded"); + + expect(succeeded?.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it("sanitizes Stellar addresses out of error messages in telemetry", async () => { + vi.mocked(fetch).mockRejectedValueOnce( + new Error(`Wallet error for ${TEST_ADDRESS}`), + ); + + const { result } = renderHook(() => useWalletContext(), { wrapper }); + await waitFor(() => expect(result.current.status).toBe("disconnected")); + + const events = getWalletTelemetryService().getEvents(); + for (const e of events) { + if (e.errorMessage) { + expect(e.errorMessage).not.toContain(TEST_ADDRESS); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// Render-safety: no render after unmount +// --------------------------------------------------------------------------- + +describe("WalletProvider render safety", () => { + it("renders children without throwing", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); + + function Child() { + const ctx = useWalletContext(); + return createElement("span", { "data-testid": "status" }, ctx.status); + } + + render(createElement(WalletProvider, null, createElement(Child))); + await waitFor(() => + expect(screen.getByTestId("status").textContent).toBe("disconnected"), + ); + }); +}); diff --git a/context/WalletContext.tsx b/context/WalletContext.tsx index 5f83a0ea..fb78f735 100644 --- a/context/WalletContext.tsx +++ b/context/WalletContext.tsx @@ -1,14 +1,31 @@ "use client"; -import React, { createContext, useContext, useState, useEffect, FC, ReactNode } from "react"; +import React, { + createContext, + useContext, + useState, + useEffect, + useRef, + useCallback, + FC, + ReactNode, +} from "react"; import { useRouter } from "next/navigation"; import config from "@/lib/config"; import { safeRedirectPath } from "@/lib/security/safe-redirect"; -import { connectWallet, isValidStellarAddress, type StellarNetwork } from "@/lib/wallet/connectHandshake"; +import { + connectWallet, + isValidStellarAddress, + type StellarNetwork, +} from "@/lib/wallet/connectHandshake"; import { assertWalletMatchesSession, validateClientSessionResponse, } from "@/lib/auth/session-boundary"; +import { + getWalletTelemetryService, + WALLET_BOUNDS, +} from "@/lib/telemetry/walletTelemetry"; export type WalletStatus = "disconnected" | "connecting" | "connected" | "error"; export type { StellarNetwork }; @@ -30,6 +47,10 @@ export interface WalletContextType { const WalletContext = createContext(undefined); +// --------------------------------------------------------------------------- +// Storage helpers +// --------------------------------------------------------------------------- + function readStoredAccounts(): string[] { try { const raw = sessionStorage.getItem(ACCOUNTS_STORAGE_KEY); @@ -41,6 +62,28 @@ function readStoredAccounts(): string[] { } } +// --------------------------------------------------------------------------- +// Timeout-aware fetch helper +// --------------------------------------------------------------------------- + +async function fetchWithTimeout( + input: RequestInfo, + init: RequestInit = {}, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timerId); + } +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + export const WalletProvider: FC<{ children: ReactNode }> = ({ children }) => { const [address, setAddress] = useState(null); const [accounts, setAccounts] = useState([]); @@ -49,94 +92,207 @@ export const WalletProvider: FC<{ children: ReactNode }> = ({ children }) => { const [error, setError] = useState(null); const router = useRouter(); - const clearWalletIdentity = () => { + // Guards + const rehydrationDoneRef = useRef(false); + const isConnectingRef = useRef(false); + + // Abort controller for the in-flight rehydration fetch so we can cancel on + // unmount before the state updates land. + const rehydrationAbortRef = useRef(null); + + // Map config network to PUBLIC or TESTNET — stable, computed once. + const network: StellarNetwork = + config.stellar.network.toUpperCase() === "MAINNET" || + config.stellar.network.toUpperCase() === "PUBLIC" + ? "PUBLIC" + : "TESTNET"; + + // --------------------------------------------------------------------------- + // Identity helpers + // --------------------------------------------------------------------------- + + const clearWalletIdentity = useCallback(() => { setAddress(null); setAccounts([]); setActiveAccount(null); sessionStorage.removeItem("walletAddress"); sessionStorage.removeItem(ACCOUNTS_STORAGE_KEY); sessionStorage.removeItem(ACTIVE_ACCOUNT_STORAGE_KEY); - }; + }, []); - const clearWalletState = () => { + const clearWalletState = useCallback(() => { clearWalletIdentity(); setStatus("disconnected"); - }; + }, [clearWalletIdentity]); - // Map config network to PUBLIC or TESTNET - const network: StellarNetwork = - config.stellar.network.toUpperCase() === "MAINNET" || - config.stellar.network.toUpperCase() === "PUBLIC" - ? "PUBLIC" - : "TESTNET"; + // --------------------------------------------------------------------------- + // Rehydration — runs once on mount, deduplicated by rehydrationDoneRef + // --------------------------------------------------------------------------- - // Rehydrate state on mount useEffect(() => { + // Deduplicate: React Strict Mode can double-invoke effects; only run once. + if (rehydrationDoneRef.current) return; + rehydrationDoneRef.current = true; + + const controller = new AbortController(); + rehydrationAbortRef.current = controller; + const telemetry = getWalletTelemetryService(); + const t0 = Date.now(); + + telemetry.record({ type: "rehydration_started", timestamp: t0 }); + const rehydrate = async () => { - // Read storage only as a candidate. Sensitive UI is not unlocked until - // the server confirms the same active wallet session. const storedAddress = sessionStorage.getItem("walletAddress"); - // Fetch session from server to verify/sync. + let timedOut = false; + const timerId = setTimeout(() => { + timedOut = true; + controller.abort(); + }, WALLET_BOUNDS.SESSION_FETCH_TIMEOUT_MS); + try { - const response = await fetch("/api/auth/session"); - if (response.ok) { - const data = await response.json(); - const session = validateClientSessionResponse(data, network); - assertWalletMatchesSession(storedAddress, session.walletAddress); - - setAddress(session.walletAddress); - setStatus("connected"); - sessionStorage.setItem("walletAddress", session.walletAddress); - - const storedAccounts = readStoredAccounts(); - const resolvedAccounts = storedAccounts.includes(session.walletAddress) - ? storedAccounts.filter(isValidStellarAddress) - : [session.walletAddress]; - setAccounts(resolvedAccounts); - sessionStorage.setItem(ACCOUNTS_STORAGE_KEY, JSON.stringify(resolvedAccounts)); - - setActiveAccount(session.walletAddress); - sessionStorage.setItem(ACTIVE_ACCOUNT_STORAGE_KEY, session.walletAddress); - } else { + const response = await fetch("/api/auth/session", { + signal: controller.signal, + }); + + clearTimeout(timerId); + + if (!response.ok) { clearWalletState(); + telemetry.record({ + type: "rehydration_failed", + timestamp: Date.now(), + latencyMs: Date.now() - t0, + errorType: "session_not_ok", + errorMessage: `HTTP ${response.status}`, + }); + return; } - } catch (err) { - console.error("Failed to fetch session during rehydration:", err); + + const data = await response.json(); + const session = validateClientSessionResponse(data, network); + assertWalletMatchesSession(storedAddress, session.walletAddress); + + setAddress(session.walletAddress); + setStatus("connected"); + sessionStorage.setItem("walletAddress", session.walletAddress); + + const storedAccounts = readStoredAccounts(); + const raw = storedAccounts.includes(session.walletAddress) + ? storedAccounts.filter(isValidStellarAddress) + : [session.walletAddress]; + // Bound account list size + const resolvedAccounts = raw.slice(0, WALLET_BOUNDS.MAX_ACCOUNTS); + setAccounts(resolvedAccounts); + sessionStorage.setItem(ACCOUNTS_STORAGE_KEY, JSON.stringify(resolvedAccounts)); + setActiveAccount(session.walletAddress); + sessionStorage.setItem(ACTIVE_ACCOUNT_STORAGE_KEY, session.walletAddress); + + telemetry.record({ + type: "rehydration_succeeded", + timestamp: Date.now(), + latencyMs: Date.now() - t0, + }); + } catch (err: unknown) { + clearTimeout(timerId); + + // Don't update state after unmount. + if (controller.signal.aborted) { + const reason = timedOut ? "session_fetch_timeout" : "rehydration_aborted"; + if (timedOut) { + telemetry.record({ + type: "session_fetch_timeout", + timestamp: Date.now(), + latencyMs: Date.now() - t0, + errorMessage: `Timed out after ${WALLET_BOUNDS.SESSION_FETCH_TIMEOUT_MS}ms`, + }); + } else { + telemetry.record({ type: reason as "rehydration_aborted", timestamp: Date.now() }); + } + return; + } + + const message = err instanceof Error ? err.message : "Unknown error"; + telemetry.record({ + type: "rehydration_failed", + timestamp: Date.now(), + latencyMs: Date.now() - t0, + errorType: err instanceof Error ? err.constructor.name : "UnknownError", + errorMessage: message, + }); + clearWalletState(); } }; rehydrate(); - }, [network]); - const connect = async () => { - if (status === "connecting") return; + return () => { + // Cancel the in-flight fetch if the component unmounts before it resolves. + controller.abort(); + rehydrationAbortRef.current = null; + }; + // network and clearWalletState are stable after mount; include them to + // satisfy exhaustive-deps without causing re-runs. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [network, clearWalletState]); + + // --------------------------------------------------------------------------- + // connect — concurrent guard + telemetry + // --------------------------------------------------------------------------- + + const connect = useCallback(async () => { + const telemetry = getWalletTelemetryService(); + + // Deduplicate concurrent connect calls (e.g. double-click). + if (isConnectingRef.current || status === "connecting") { + telemetry.record({ type: "connect_rejected_concurrent", timestamp: Date.now() }); + return; + } + + isConnectingRef.current = true; setStatus("connecting"); setError(null); + const t0 = Date.now(); + telemetry.record({ type: "connect_started", timestamp: t0 }); + + // Overall connect timeout — covers the full handshake (challenge + sign + verify). + const controller = new AbortController(); + const timerId = setTimeout( + () => controller.abort(), + WALLET_BOUNDS.CONNECT_TIMEOUT_MS, + ); + try { const verifiedAddress = await connectWallet(network); + clearTimeout(timerId); - // Enumerate accounts if the wallet provider supports it; otherwise - // fall back gracefully to a single-account list. + // Enumerate accounts; fall back to single-account list on any failure. let resolvedAccounts: string[] = [verifiedAddress]; try { const stellar = window.stellar; if (stellar && typeof stellar.getAccounts === "function") { const list = await stellar.getAccounts(); if (Array.isArray(list) && list.length > 0) { - resolvedAccounts = list.filter(isValidStellarAddress); - if (!resolvedAccounts.includes(verifiedAddress)) { - resolvedAccounts = [verifiedAddress, ...resolvedAccounts]; + // Bound the list before any further processing. + const bounded = list.slice(0, WALLET_BOUNDS.MAX_ACCOUNTS); + const valid = bounded.filter(isValidStellarAddress); + if (!valid.includes(verifiedAddress)) { + valid.unshift(verifiedAddress); } + resolvedAccounts = valid; } } - } catch (accountsErr) { - console.error("Failed to enumerate wallet accounts:", accountsErr); + } catch { resolvedAccounts = [verifiedAddress]; } - resolvedAccounts = Array.from(new Set(resolvedAccounts)).filter(isValidStellarAddress); + + // Deduplicate and re-bound after merging. + resolvedAccounts = Array.from(new Set(resolvedAccounts)) + .filter(isValidStellarAddress) + .slice(0, WALLET_BOUNDS.MAX_ACCOUNTS); + if (!resolvedAccounts.includes(verifiedAddress)) { resolvedAccounts = [verifiedAddress]; } @@ -149,28 +305,70 @@ export const WalletProvider: FC<{ children: ReactNode }> = ({ children }) => { sessionStorage.setItem(ACCOUNTS_STORAGE_KEY, JSON.stringify(resolvedAccounts)); sessionStorage.setItem(ACTIVE_ACCOUNT_STORAGE_KEY, verifiedAddress); + telemetry.record({ + type: "connect_succeeded", + timestamp: Date.now(), + latencyMs: Date.now() - t0, + }); + const returnUrl = new URL(window.location.href).searchParams.get("returnUrl"); if (returnUrl) { router.push(safeRedirectPath(returnUrl)); } - } catch (err: any) { - console.error("Wallet connection failed:", err); - setError(err.message || "Wallet connection failed"); + } catch (err: unknown) { + clearTimeout(timerId); + + const message = + controller.signal.aborted + ? `Connect timed out after ${WALLET_BOUNDS.CONNECT_TIMEOUT_MS}ms` + : err instanceof Error + ? err.message + : "Wallet connection failed"; + + telemetry.record({ + type: "connect_failed", + timestamp: Date.now(), + latencyMs: Date.now() - t0, + errorType: err instanceof Error ? err.constructor.name : "UnknownError", + errorMessage: message, + }); + + setError(message); setStatus("error"); clearWalletIdentity(); + } finally { + isConnectingRef.current = false; } - }; + }, [status, network, router, clearWalletIdentity]); - const disconnect = async () => { + // --------------------------------------------------------------------------- + // disconnect + // --------------------------------------------------------------------------- + + const disconnect = useCallback(async () => { + const telemetry = getWalletTelemetryService(); setError(null); + telemetry.record({ type: "disconnect_started", timestamp: Date.now() }); + try { - await fetch("/api/auth/session", { - method: "DELETE", + await fetchWithTimeout( + "/api/auth/session", + { method: "DELETE" }, + WALLET_BOUNDS.SESSION_FETCH_TIMEOUT_MS, + ); + telemetry.record({ type: "disconnect_succeeded", timestamp: Date.now() }); + } catch (err: unknown) { + const isTimeout = + err instanceof Error && err.name === "AbortError"; + telemetry.record({ + type: "disconnect_failed", + timestamp: Date.now(), + errorType: isTimeout ? "TimeoutError" : (err instanceof Error ? err.constructor.name : "UnknownError"), + errorMessage: isTimeout ? "Session DELETE timed out" : (err instanceof Error ? err.message : undefined), }); - } catch (err: any) { - console.error("Logout failed during disconnect:", err); } finally { - // Always clear local state on disconnect to ensure the user is logged out locally + // Always clear local state so the user is logged out locally regardless + // of whether the server DELETE succeeded. clearWalletState(); } @@ -178,32 +376,43 @@ export const WalletProvider: FC<{ children: ReactNode }> = ({ children }) => { if (returnUrl) { router.push(safeRedirectPath(returnUrl)); } - }; - - // Switch the active account among already-known accounts. - // Triggers downstream data refresh (positions, balances) by updating - // `address`, since existing consumers key their fetch effects off it. - const switchAccount = async (nextAddress: string) => { - if (!nextAddress || !accounts.includes(nextAddress)) { - setError("Unknown account: cannot switch to an address not exposed by the wallet"); - return; - } + }, [clearWalletState, router]); - if (nextAddress !== address) { - setError("Switching accounts requires reconnecting so the server can authorize the wallet"); - return; - } + // --------------------------------------------------------------------------- + // switchAccount + // --------------------------------------------------------------------------- - if (nextAddress === activeAccount) { - return; - } + const switchAccount = useCallback( + async (nextAddress: string) => { + if (!nextAddress || !accounts.includes(nextAddress)) { + setError("Unknown account: cannot switch to an address not exposed by the wallet"); + return; + } - setError(null); - setActiveAccount(nextAddress); - setAddress(nextAddress); - sessionStorage.setItem(ACTIVE_ACCOUNT_STORAGE_KEY, nextAddress); - sessionStorage.setItem("walletAddress", nextAddress); - }; + if (nextAddress !== address) { + setError( + "Switching accounts requires reconnecting so the server can authorize the wallet", + ); + return; + } + + if (nextAddress === activeAccount) { + return; + } + + setError(null); + setActiveAccount(nextAddress); + setAddress(nextAddress); + sessionStorage.setItem(ACTIVE_ACCOUNT_STORAGE_KEY, nextAddress); + sessionStorage.setItem("walletAddress", nextAddress); + + getWalletTelemetryService().record({ + type: "account_switch", + timestamp: Date.now(), + }); + }, + [accounts, address, activeAccount], + ); return ( ({ + useRouter: () => ({ push: vi.fn() }), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const TEST_ADDRESS = "GAUFVBMULU2CJRE5IGVPEOXRYZGU5YDAOSQ3UQTBM3Y7ARUPFSXZUHN5"; + +function sessionBody(walletAddress = TEST_ADDRESS, network = "TESTNET") { + return { + session: { + active: true, + network, + user: { walletAddress }, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + }; +} + +function mockOk(body: unknown = {}, status = 200): Response { + return { ok: true, status, json: async () => body } as Response; +} + +function mockFail(status = 500, body: unknown = {}): Response { + return { ok: false, status, json: async () => body } as Response; +} + +function setupStellar(pubKey = TEST_ADDRESS) { + (window as any).stellar = { + getPublicKey: vi.fn().mockResolvedValue(pubKey), + signTransaction: vi.fn().mockResolvedValue("signed-xdr"), + }; +} + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: false }); + vi.stubGlobal("fetch", vi.fn()); + sessionStorage.clear(); + _resetWalletTelemetryService(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + (window as any).stellar = undefined; + _resetWalletTelemetryService(); +}); + +// --------------------------------------------------------------------------- +// Rehydration deduplication +// --------------------------------------------------------------------------- + +describe("rehydration deduplication", () => { + it("calls /api/auth/session exactly once per hook instance", async () => { + vi.mocked(fetch).mockResolvedValue(mockFail()); + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); + }); + + it("a new hook instance fetches once independently", async () => { + vi.mocked(fetch).mockResolvedValue(mockFail()); + + const { unmount } = renderHook(() => useWalletConnection()); + await waitFor(() => {}); + unmount(); + + vi.mocked(fetch).mockResolvedValue(mockFail()); + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- +// Session-fetch timeout +// --------------------------------------------------------------------------- + +describe("session fetch timeout", () => { + it("records session_fetch_timeout and leaves status disconnected", async () => { + vi.mocked(fetch).mockReturnValueOnce(new Promise(() => {})); // never resolves + + const { result } = renderHook(() => useWalletConnection()); + + await act(async () => { + vi.advanceTimersByTime(WALLET_BOUNDS.SESSION_FETCH_TIMEOUT_MS + 100); + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.status).toBe("disconnected"); + expect(result.current.isConnected).toBe(false); + + const events = getWalletTelemetryService().getEvents(); + expect(events.some((e) => e.type === "session_fetch_timeout")).toBe(true); + }); + + it("timeout event carries an errorMessage without leaking secrets", async () => { + vi.mocked(fetch).mockReturnValueOnce(new Promise(() => {})); + + renderHook(() => useWalletConnection()); + + await act(async () => { + vi.advanceTimersByTime(WALLET_BOUNDS.SESSION_FETCH_TIMEOUT_MS + 100); + }); + + const timeoutEvent = getWalletTelemetryService() + .getEvents() + .find((e) => e.type === "session_fetch_timeout"); + + expect(timeoutEvent?.errorMessage).toBeDefined(); + expect(timeoutEvent?.errorMessage).not.toContain(TEST_ADDRESS); + }); +}); + +// --------------------------------------------------------------------------- +// Abort on unmount +// --------------------------------------------------------------------------- + +describe("abort on unmount", () => { + it("does not update state after the hook unmounts before rehydration resolves", async () => { + let resolveSession!: (r: Response) => void; + vi.mocked(fetch).mockReturnValueOnce( + new Promise((res) => { resolveSession = res; }), + ); + + const { result, unmount } = renderHook(() => useWalletConnection()); + + // Unmount before the fetch resolves. + unmount(); + + // Resolve after unmount. + await act(async () => { + resolveSession(mockOk(sessionBody())); + }); + + // The last recorded status before unmount should still be the initial value. + expect(result.current.status).toBe("disconnected"); + expect(result.current.address).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Concurrent connect guard +// --------------------------------------------------------------------------- + +describe("concurrent connect guard", () => { + it("rejects a second connect while one is in-flight", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); // rehydration + setupStellar(); + + // Challenge fetch hangs. + let resolveChallenge!: (r: Response) => void; + vi.mocked(fetch).mockReturnValueOnce( + new Promise((res) => { resolveChallenge = res; }), + ); + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // Start first connect — hangs. + act(() => { result.current.connect(); }); + await waitFor(() => expect(result.current.status).toBe("connecting")); + + // Second connect should be rejected. + await act(async () => { await result.current.connect(); }); + + const events = getWalletTelemetryService().getEvents(); + expect(events.some((e) => e.type === "connect_rejected_concurrent")).toBe(true); + + // Cleanup: resolve the hanging challenge. + await act(async () => { + resolveChallenge(mockFail(500, { error: "cancelled" })); + }); + }); + + it("allows a new connect after the previous one errors out", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); // rehydration + setupStellar(); + vi.mocked(fetch).mockResolvedValueOnce(mockFail(500, { error: "boom" })); // challenge + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { await result.current.connect(); }); + await waitFor(() => expect(result.current.status).toBe("error")); + + // Second attempt succeeds. + vi.mocked(fetch) + .mockResolvedValueOnce(mockOk({ transaction: "xdr" })) + .mockResolvedValueOnce(mockOk({ walletAddress: TEST_ADDRESS })); + + await act(async () => { await result.current.connect(); }); + await waitFor(() => expect(result.current.status).toBe("connected")); + expect(result.current.address).toBe(TEST_ADDRESS); + expect(result.current.isConnected).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Connect timeout +// --------------------------------------------------------------------------- + +describe("connect timeout", () => { + it("transitions to error when connect exceeds CONNECT_TIMEOUT_MS", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); // rehydration + setupStellar(); + + // Challenge fetch never resolves — simulates a hung handshake. + vi.mocked(fetch).mockReturnValueOnce(new Promise(() => {})); + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { result.current.connect(); }); + await waitFor(() => expect(result.current.status).toBe("connecting")); + + await act(async () => { + vi.advanceTimersByTime(WALLET_BOUNDS.CONNECT_TIMEOUT_MS + 100); + }); + + await waitFor(() => expect(result.current.status).toBe("error")); + expect(result.current.error).toMatch(/timed out/i); + expect(result.current.address).toBeNull(); + expect(result.current.isConnected).toBe(false); + + const events = getWalletTelemetryService().getEvents(); + expect(events.some((e) => e.type === "connect_failed")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Telemetry emission +// --------------------------------------------------------------------------- + +describe("telemetry emission", () => { + it("emits rehydration_started + rehydration_succeeded on success", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockOk(sessionBody())); + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.status).toBe("connected")); + + const types = getWalletTelemetryService().getEvents().map((e) => e.type); + expect(types).toContain("rehydration_started"); + expect(types).toContain("rehydration_succeeded"); + }); + + it("emits rehydration_started + rehydration_failed on non-OK response", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const types = getWalletTelemetryService().getEvents().map((e) => e.type); + expect(types).toContain("rehydration_started"); + expect(types).toContain("rehydration_failed"); + }); + + it("emits rehydration_failed on network error during rehydration", async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error("Network error")); + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const types = getWalletTelemetryService().getEvents().map((e) => e.type); + expect(types).toContain("rehydration_failed"); + expect(result.current.status).toBe("disconnected"); + }); + + it("emits connect_started + connect_succeeded on successful connect", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); // rehydration + setupStellar(); + vi.mocked(fetch) + .mockResolvedValueOnce(mockOk({ transaction: "xdr" })) + .mockResolvedValueOnce(mockOk({ walletAddress: TEST_ADDRESS })); + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { await result.current.connect(); }); + await waitFor(() => expect(result.current.status).toBe("connected")); + + const types = getWalletTelemetryService().getEvents().map((e) => e.type); + expect(types).toContain("connect_started"); + expect(types).toContain("connect_succeeded"); + }); + + it("emits connect_started + connect_failed on handshake error", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); // rehydration + setupStellar(); + vi.mocked(fetch).mockResolvedValueOnce(mockFail(500, { error: "boom" })); + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { await result.current.connect(); }); + await waitFor(() => expect(result.current.status).toBe("error")); + + const types = getWalletTelemetryService().getEvents().map((e) => e.type); + expect(types).toContain("connect_started"); + expect(types).toContain("connect_failed"); + }); + + it("emits disconnect_started + disconnect_succeeded on clean disconnect", async () => { + sessionStorage.setItem("walletAddress", TEST_ADDRESS); + vi.mocked(fetch) + .mockResolvedValueOnce(mockOk(sessionBody())) + .mockResolvedValueOnce(mockOk()); + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.status).toBe("connected")); + + await act(async () => { await result.current.disconnect(); }); + await waitFor(() => expect(result.current.status).toBe("disconnected")); + + const types = getWalletTelemetryService().getEvents().map((e) => e.type); + expect(types).toContain("disconnect_started"); + expect(types).toContain("disconnect_succeeded"); + }); + + it("emits disconnect_failed but still clears state when server DELETE throws", async () => { + sessionStorage.setItem("walletAddress", TEST_ADDRESS); + vi.mocked(fetch) + .mockResolvedValueOnce(mockOk(sessionBody())) + .mockRejectedValueOnce(new Error("Network error")); + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.status).toBe("connected")); + + await act(async () => { await result.current.disconnect(); }); + await waitFor(() => expect(result.current.status).toBe("disconnected")); + + const types = getWalletTelemetryService().getEvents().map((e) => e.type); + expect(types).toContain("disconnect_failed"); + expect(result.current.address).toBeNull(); + expect(sessionStorage.getItem("walletAddress")).toBeNull(); + }); + + it("succeeded events carry a non-negative latencyMs", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockOk(sessionBody())); + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.status).toBe("connected")); + + const event = getWalletTelemetryService() + .getEvents() + .find((e) => e.type === "rehydration_succeeded"); + + expect(event?.latencyMs).toBeGreaterThanOrEqual(0); + }); +}); + +// --------------------------------------------------------------------------- +// Address sanitization +// --------------------------------------------------------------------------- + +describe("address sanitization in telemetry", () => { + it("does not leak the wallet address in rehydration error messages", async () => { + vi.mocked(fetch).mockRejectedValueOnce( + new Error(`Session error for address ${TEST_ADDRESS}`), + ); + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const events = getWalletTelemetryService().getEvents(); + for (const e of events) { + if (e.errorMessage) { + expect(e.errorMessage).not.toContain(TEST_ADDRESS); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// isLoading transitions +// --------------------------------------------------------------------------- + +describe("isLoading transitions", () => { + it("starts true, becomes false after rehydration resolves (success)", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockOk(sessionBody())); + + const { result } = renderHook(() => useWalletConnection()); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.status).toBe("connected"); + }); + + it("starts true, becomes false after rehydration resolves (failure)", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); + + const { result } = renderHook(() => useWalletConnection()); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.status).toBe("disconnected"); + }); + + it("is true while connecting and false after connect completes", async () => { + vi.mocked(fetch).mockResolvedValueOnce(mockFail()); // rehydration + setupStellar(); + + let resolveVerify!: (r: Response) => void; + vi.mocked(fetch) + .mockResolvedValueOnce(mockOk({ transaction: "xdr" })) // challenge + .mockReturnValueOnce( + new Promise((res) => { resolveVerify = res; }), // verify hangs + ); + + const { result } = renderHook(() => useWalletConnection()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { result.current.connect(); }); + await waitFor(() => expect(result.current.isLoading).toBe(true)); + + await act(async () => { + resolveVerify(mockOk({ walletAddress: TEST_ADDRESS })); + }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.status).toBe("connected"); + }); +}); diff --git a/hooks/useWalletConnection.ts b/hooks/useWalletConnection.ts index 6d26ade5..b18b0cb8 100644 --- a/hooks/useWalletConnection.ts +++ b/hooks/useWalletConnection.ts @@ -1,62 +1,155 @@ -import { useState, useEffect, useCallback } from 'react'; -import { useRouter } from 'next/navigation'; -import config from '@/lib/config'; -import { safeRedirectPath } from '@/lib/security/safe-redirect'; -import { connectWallet, type StellarNetwork } from '@/lib/wallet/connectHandshake'; +import { useState, useEffect, useRef, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import config from "@/lib/config"; +import { safeRedirectPath } from "@/lib/security/safe-redirect"; +import { connectWallet, type StellarNetwork } from "@/lib/wallet/connectHandshake"; import { assertWalletMatchesSession, validateClientSessionResponse, -} from '@/lib/auth/session-boundary'; +} from "@/lib/auth/session-boundary"; +import { + getWalletTelemetryService, + WALLET_BOUNDS, +} from "@/lib/telemetry/walletTelemetry"; -export type WalletStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; +export type WalletStatus = "disconnected" | "connecting" | "connected" | "error"; export type { StellarNetwork }; +// --------------------------------------------------------------------------- +// Internal timeout-aware fetch +// --------------------------------------------------------------------------- + +async function fetchWithTimeout( + input: RequestInfo, + init: RequestInit = {}, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timerId); + } +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + export const useWalletConnection = () => { const [address, setAddress] = useState(null); - const [status, setStatus] = useState('disconnected'); + const [status, setStatus] = useState("disconnected"); const [error, setError] = useState(null); const [isInitializing, setIsInitializing] = useState(true); const router = useRouter(); + // Guards — use refs so they are never stale inside async callbacks. + const rehydrationDoneRef = useRef(false); + const isConnectingRef = useRef(false); + const network: StellarNetwork = - config.stellar.network.toUpperCase() === 'MAINNET' || - config.stellar.network.toUpperCase() === 'PUBLIC' - ? 'PUBLIC' - : 'TESTNET'; + config.stellar.network.toUpperCase() === "MAINNET" || + config.stellar.network.toUpperCase() === "PUBLIC" + ? "PUBLIC" + : "TESTNET"; + + // --------------------------------------------------------------------------- + // Identity helpers — stable, no deps that change after mount + // --------------------------------------------------------------------------- const clearWalletIdentity = useCallback(() => { setAddress(null); - sessionStorage.removeItem('walletAddress'); + sessionStorage.removeItem("walletAddress"); }, []); const clearWalletState = useCallback(() => { clearWalletIdentity(); - setStatus('disconnected'); + setStatus("disconnected"); }, [clearWalletIdentity]); - // Rehydrate state on mount + // --------------------------------------------------------------------------- + // Rehydration — runs once on mount, deduplicated by rehydrationDoneRef + // --------------------------------------------------------------------------- + useEffect(() => { + // Deduplicate: React Strict Mode double-invokes effects; skip the second run. + if (rehydrationDoneRef.current) return; + rehydrationDoneRef.current = true; + + const controller = new AbortController(); + const telemetry = getWalletTelemetryService(); + const t0 = Date.now(); + + telemetry.record({ type: "rehydration_started", timestamp: t0 }); + + let timedOut = false; + const timerId = setTimeout(() => { + timedOut = true; + controller.abort(); + }, WALLET_BOUNDS.SESSION_FETCH_TIMEOUT_MS); + const rehydrate = async () => { - // Treat storage as a candidate only. The server session must confirm it - // before sensitive UI/actions are considered connected. - const storedAddress = sessionStorage.getItem('walletAddress'); + const storedAddress = sessionStorage.getItem("walletAddress"); - // Fetch session from server to verify/sync. try { - const response = await fetch('/api/auth/session'); - if (response.ok) { - const data = await response.json(); - const session = validateClientSessionResponse(data, network); - assertWalletMatchesSession(storedAddress, session.walletAddress); - - setAddress(session.walletAddress); - setStatus('connected'); - sessionStorage.setItem('walletAddress', session.walletAddress); - } else { + const response = await fetch("/api/auth/session", { + signal: controller.signal, + }); + + clearTimeout(timerId); + + if (!response.ok) { clearWalletState(); + telemetry.record({ + type: "rehydration_failed", + timestamp: Date.now(), + latencyMs: Date.now() - t0, + errorType: "session_not_ok", + errorMessage: `HTTP ${response.status}`, + }); + return; } - } catch (err) { - console.error('Failed to fetch session during rehydration:', err); + + const data = await response.json(); + const session = validateClientSessionResponse(data, network); + assertWalletMatchesSession(storedAddress, session.walletAddress); + + setAddress(session.walletAddress); + setStatus("connected"); + sessionStorage.setItem("walletAddress", session.walletAddress); + + telemetry.record({ + type: "rehydration_succeeded", + timestamp: Date.now(), + latencyMs: Date.now() - t0, + }); + } catch (err: unknown) { + clearTimeout(timerId); + + if (controller.signal.aborted) { + if (timedOut) { + telemetry.record({ + type: "session_fetch_timeout", + timestamp: Date.now(), + latencyMs: Date.now() - t0, + errorMessage: `Timed out after ${WALLET_BOUNDS.SESSION_FETCH_TIMEOUT_MS}ms`, + }); + } else { + telemetry.record({ type: "rehydration_aborted", timestamp: Date.now() }); + } + // Do not update state after unmount/abort. + return; + } + + telemetry.record({ + type: "rehydration_failed", + timestamp: Date.now(), + latencyMs: Date.now() - t0, + errorType: err instanceof Error ? err.constructor.name : "UnknownError", + errorMessage: err instanceof Error ? err.message : "Unknown error", + }); + clearWalletState(); } finally { setIsInitializing(false); @@ -64,45 +157,118 @@ export const useWalletConnection = () => { }; rehydrate(); - }, [clearWalletState, network]); + + return () => { + // Cancel the in-flight fetch if the hook unmounts before it resolves. + controller.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [network, clearWalletState]); + + // --------------------------------------------------------------------------- + // connect — concurrent guard + overall timeout + telemetry + // --------------------------------------------------------------------------- const connect = useCallback(async () => { - if (status === 'connecting') return; - setStatus('connecting'); + const telemetry = getWalletTelemetryService(); + + if (isConnectingRef.current || status === "connecting") { + telemetry.record({ type: "connect_rejected_concurrent", timestamp: Date.now() }); + return; + } + + isConnectingRef.current = true; + setStatus("connecting"); setError(null); + const t0 = Date.now(); + telemetry.record({ type: "connect_started", timestamp: t0 }); + + // Abort signal for overall connect timeout. + const controller = new AbortController(); + const timerId = setTimeout( + () => controller.abort(), + WALLET_BOUNDS.CONNECT_TIMEOUT_MS, + ); + try { const verifiedAddress = await connectWallet(network); + clearTimeout(timerId); + setAddress(verifiedAddress); - setStatus('connected'); - sessionStorage.setItem('walletAddress', verifiedAddress); + setStatus("connected"); + sessionStorage.setItem("walletAddress", verifiedAddress); - const returnUrl = new URL(window.location.href).searchParams.get('returnUrl'); + telemetry.record({ + type: "connect_succeeded", + timestamp: Date.now(), + latencyMs: Date.now() - t0, + }); + + const returnUrl = new URL(window.location.href).searchParams.get("returnUrl"); if (returnUrl) { router.push(safeRedirectPath(returnUrl)); } - } catch (err: any) { - console.error('Wallet connection failed:', err); - setError(err.message || 'Wallet connection failed'); - setStatus('error'); + } catch (err: unknown) { + clearTimeout(timerId); + + const message = + controller.signal.aborted + ? `Connect timed out after ${WALLET_BOUNDS.CONNECT_TIMEOUT_MS}ms` + : err instanceof Error + ? err.message + : "Wallet connection failed"; + + telemetry.record({ + type: "connect_failed", + timestamp: Date.now(), + latencyMs: Date.now() - t0, + errorType: err instanceof Error ? err.constructor.name : "UnknownError", + errorMessage: message, + }); + + setError(message); + setStatus("error"); clearWalletIdentity(); + } finally { + isConnectingRef.current = false; } }, [status, network, router, clearWalletIdentity]); + // --------------------------------------------------------------------------- + // disconnect + // --------------------------------------------------------------------------- + const disconnect = useCallback(async () => { + const telemetry = getWalletTelemetryService(); setError(null); + telemetry.record({ type: "disconnect_started", timestamp: Date.now() }); + try { - await fetch('/api/auth/session', { - method: 'DELETE', + await fetchWithTimeout( + "/api/auth/session", + { method: "DELETE" }, + WALLET_BOUNDS.SESSION_FETCH_TIMEOUT_MS, + ); + telemetry.record({ type: "disconnect_succeeded", timestamp: Date.now() }); + } catch (err: unknown) { + const isTimeout = err instanceof Error && err.name === "AbortError"; + telemetry.record({ + type: "disconnect_failed", + timestamp: Date.now(), + errorType: isTimeout ? "TimeoutError" : (err instanceof Error ? err.constructor.name : "UnknownError"), + errorMessage: isTimeout + ? "Session DELETE timed out" + : err instanceof Error + ? err.message + : undefined, }); - } catch (err: any) { - console.error('Logout failed during disconnect:', err); } finally { - // Always clear local state on disconnect to ensure the user is logged out locally + // Always clear local state so the user is logged out regardless of server response. clearWalletState(); } - const returnUrl = new URL(window.location.href).searchParams.get('returnUrl'); + const returnUrl = new URL(window.location.href).searchParams.get("returnUrl"); if (returnUrl) { router.push(safeRedirectPath(returnUrl)); } @@ -114,8 +280,8 @@ export const useWalletConnection = () => { network, status, error, - isConnected: status === 'connected', - isLoading: isInitializing || status === 'connecting', + isConnected: status === "connected", + isLoading: isInitializing || status === "connecting", connect, disconnect, }; diff --git a/lib/telemetry/walletTelemetry.ts b/lib/telemetry/walletTelemetry.ts new file mode 100644 index 00000000..bcdb58ec --- /dev/null +++ b/lib/telemetry/walletTelemetry.ts @@ -0,0 +1,252 @@ +/** + * Client-side telemetry for wallet provider and session recovery. + * Provides structured diagnostics for latency, failure, and recovery paths + * without leaking wallet addresses, secrets, or session tokens. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type WalletTelemetryEventType = + | "rehydration_started" + | "rehydration_succeeded" + | "rehydration_failed" + | "rehydration_aborted" + | "connect_started" + | "connect_succeeded" + | "connect_failed" + | "connect_rejected_concurrent" + | "disconnect_started" + | "disconnect_succeeded" + | "disconnect_failed" + | "session_fetch_timeout" + | "session_fetch_error" + | "account_switch" + | "network_mismatch"; + +export interface WalletTelemetryEvent { + type: WalletTelemetryEventType; + timestamp: number; + /** Opaque correlation ID — never a wallet address or session token. */ + correlationId?: string; + latencyMs?: number; + errorType?: string; + /** Sanitized human-readable message, no sensitive values. */ + errorMessage?: string; + metadata?: Record; +} + +export interface WalletDiagnostics { + health: "healthy" | "degraded" | "critical"; + metrics: { + successRate: number; + averageRehydrationLatencyMs: number; + averageConnectLatencyMs: number; + totalEvents: number; + failureCount: number; + }; + issues: Array<{ severity: "warning" | "error" | "critical"; message: string }>; + recommendations: string[]; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const WALLET_BOUNDS = { + /** Max ms to wait for a session rehydration fetch before aborting. */ + SESSION_FETCH_TIMEOUT_MS: 8_000, + /** Max ms to wait for the full connect handshake before aborting. */ + CONNECT_TIMEOUT_MS: 30_000, + /** Max number of accounts accepted from the wallet provider. */ + MAX_ACCOUNTS: 20, + /** Telemetry ring-buffer cap to prevent unbounded memory growth. */ + TELEMETRY_MAX_EVENTS: 200, +} as const; + +// --------------------------------------------------------------------------- +// Sanitization helpers +// --------------------------------------------------------------------------- + +const STELLAR_PUBKEY_RE = /\bG[A-Z2-7]{55}\b/g; +const STELLAR_SECRET_RE = /\bS[A-Z2-7]{55}\b/g; +const JWT_RE = /\b(?:Bearer\s+[A-Za-z0-9\-_.=]+|eyJ[A-Za-z0-9\-_]+(?:\.[A-Za-z0-9\-_]+){1,2})\b/g; +const HEX64_RE = /\b[a-f0-9]{64}\b/gi; + +function sanitizeString(value: string): string { + return value + .replace(STELLAR_SECRET_RE, "[REDACTED_SECRET]") + .replace(STELLAR_PUBKEY_RE, "[REDACTED_ADDRESS]") + .replace(JWT_RE, "[REDACTED_TOKEN]") + .replace(HEX64_RE, "[REDACTED_HASH]"); +} + +function sanitizeEvent(event: WalletTelemetryEvent): WalletTelemetryEvent { + const out: WalletTelemetryEvent = { ...event }; + if (out.errorMessage) out.errorMessage = sanitizeString(out.errorMessage); + if (out.metadata) { + const clean: Record = {}; + for (const [k, v] of Object.entries(out.metadata)) { + clean[k] = typeof v === "string" ? sanitizeString(v) : v; + } + out.metadata = clean; + } + return out; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +export class WalletTelemetryService { + private readonly buffer: WalletTelemetryEvent[] = []; + private readonly max: number; + + constructor(maxEvents = WALLET_BOUNDS.TELEMETRY_MAX_EVENTS) { + this.max = maxEvents; + } + + record(event: WalletTelemetryEvent): void { + const sanitized = sanitizeEvent(event); + + // Ring buffer — drop oldest when full. + if (this.buffer.length >= this.max) { + this.buffer.shift(); + } + this.buffer.push(sanitized); + + if (process.env.NODE_ENV === "development") { + this.logToConsole(sanitized); + } + } + + getEvents(): ReadonlyArray { + return this.buffer.slice(); + } + + clear(): void { + this.buffer.length = 0; + } + + generateDiagnostics(): WalletDiagnostics { + const events = this.buffer; + const issues: WalletDiagnostics["issues"] = []; + const recommendations: string[] = []; + + const rehydrationLatencies: number[] = []; + const connectLatencies: number[] = []; + let failureCount = 0; + let successCount = 0; + + for (const e of events) { + if (e.type === "rehydration_succeeded" && e.latencyMs != null) { + rehydrationLatencies.push(e.latencyMs); + successCount++; + } + if (e.type === "connect_succeeded" && e.latencyMs != null) { + connectLatencies.push(e.latencyMs); + successCount++; + } + if ( + e.type === "rehydration_failed" || + e.type === "connect_failed" || + e.type === "disconnect_failed" || + e.type === "session_fetch_error" || + e.type === "session_fetch_timeout" + ) { + failureCount++; + } + } + + const total = successCount + failureCount; + const successRate = total > 0 ? (successCount / total) * 100 : 100; + + const avg = (arr: number[]) => + arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; + + const avgRehydration = avg(rehydrationLatencies); + const avgConnect = avg(connectLatencies); + + // Determine health + let health: WalletDiagnostics["health"] = "healthy"; + + if (successRate < 50) { + health = "critical"; + issues.push({ severity: "critical", message: `Success rate ${successRate.toFixed(0)}% below 50%` }); + recommendations.push("Investigate backend session endpoint and network conditions."); + } else if (successRate < 80) { + health = "degraded"; + issues.push({ severity: "warning", message: `Success rate ${successRate.toFixed(0)}% below 80%` }); + recommendations.push("Review recent failures; consider tightening session timeout."); + } + + if (avgRehydration > WALLET_BOUNDS.SESSION_FETCH_TIMEOUT_MS * 0.8) { + if (health === "healthy") health = "degraded"; + issues.push({ + severity: "warning", + message: `Average rehydration latency ${Math.round(avgRehydration)}ms approaching timeout`, + }); + recommendations.push("Increase SESSION_FETCH_TIMEOUT_MS or optimize /api/auth/session."); + } + + const timeoutCount = events.filter((e) => e.type === "session_fetch_timeout").length; + if (timeoutCount > 0) { + if (health === "healthy") health = "degraded"; + issues.push({ severity: "error", message: `${timeoutCount} session fetch timeout(s) recorded` }); + recommendations.push("Check /api/auth/session p99 response time."); + } + + const concurrentRejects = events.filter((e) => e.type === "connect_rejected_concurrent").length; + if (concurrentRejects > 0) { + issues.push({ + severity: "warning", + message: `${concurrentRejects} concurrent connect attempt(s) rejected`, + }); + } + + return { + health, + metrics: { + successRate, + averageRehydrationLatencyMs: avgRehydration, + averageConnectLatencyMs: avgConnect, + totalEvents: events.length, + failureCount, + }, + issues, + recommendations, + }; + } + + private logToConsole(event: WalletTelemetryEvent): void { + const ts = new Date(event.timestamp).toISOString(); + const level = + event.type.includes("failed") || event.type.includes("error") || event.type.includes("timeout") + ? "warn" + : "log"; + // eslint-disable-next-line no-console + console[level](`[wallet:telemetry] ${ts} ${event.type}`, { + ...(event.latencyMs != null && { latencyMs: event.latencyMs }), + ...(event.errorType && { errorType: event.errorType }), + ...(event.errorMessage && { errorMessage: event.errorMessage }), + ...(event.metadata && { metadata: event.metadata }), + }); + } +} + +// --------------------------------------------------------------------------- +// Singleton + hook +// --------------------------------------------------------------------------- + +let _instance: WalletTelemetryService | undefined; + +export function getWalletTelemetryService(): WalletTelemetryService { + if (!_instance) _instance = new WalletTelemetryService(); + return _instance; +} + +/** Reset the singleton — used in tests only. */ +export function _resetWalletTelemetryService(): void { + _instance = undefined; +}