From 8de03ec6b8b968ef7c9f64a65418315344d34749 Mon Sep 17 00:00:00 2001 From: "Jenola.base.eth" Date: Sat, 17 Oct 2026 10:44:47 +0000 Subject: [PATCH 1/4] feat(rpc): implement fetchWithRetry for robust RPC calls and error handling --- src/components/NetworkBanner.tsx | 3 +- src/components/NetworkSwitcher.tsx | 12 ++- src/lib/__tests__/mock-client.test.ts | 88 +++++++++++++++++- src/lib/rpc.ts | 126 ++++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 src/lib/rpc.ts diff --git a/src/components/NetworkBanner.tsx b/src/components/NetworkBanner.tsx index 4eaadb40..2e572d66 100644 --- a/src/components/NetworkBanner.tsx +++ b/src/components/NetworkBanner.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react import type { NavSection } from "@/components/Sidebar"; import { useSorokit } from "@/context/useSorokit"; +import { fetchWithRetry } from "@/lib/rpc"; import { cn } from "@/lib/utils"; export interface BannerConfig { @@ -155,7 +156,7 @@ function useRpcLatency( controller = new AbortController(); const started = performance.now(); try { - const res = await fetch(rpcUrl, { + const res = await fetchWithRetry(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth" }), diff --git a/src/components/NetworkSwitcher.tsx b/src/components/NetworkSwitcher.tsx index 5cc70d5d..f1a03e7e 100644 --- a/src/components/NetworkSwitcher.tsx +++ b/src/components/NetworkSwitcher.tsx @@ -14,6 +14,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { useSorokit } from "@/context/useSorokit"; import { useOutsideClick } from "@/hooks/useOutsideClick"; import type { NetworkInfo, NetworkName } from "@/lib/client"; +import { fetchWithRetry, formatStellarError } from "@/lib/rpc"; import { cn } from "@/lib/utils"; export interface NetworkPreset { @@ -129,22 +130,27 @@ export async function checkRpcHealth( signal?.addEventListener("abort", onAbort, { once: true }); try { - const res = await fetch(rpcUrl.trim(), { + const res = await fetchWithRetry(rpcUrl.trim(), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth" }), signal: controller.signal, }); if (!res.ok) { - throw new Error(`RPC endpoint responded with HTTP ${res.status}.`); + const body: unknown = await res.json().catch(() => null); + const detail = formatStellarError(body); + throw new Error( + `RPC endpoint responded with HTTP ${res.status}${detail ? `: ${detail}` : ""}.`, + ); } const body = (await res.json()) as { result?: { status?: string }; error?: { message?: string }; }; if (body.error) { + const detail = formatStellarError(body); throw new Error( - `RPC endpoint returned an error: ${body.error.message ?? "unknown error"}.`, + `RPC endpoint returned an error: ${detail ?? body.error.message ?? "unknown error"}.`, ); } if (body.result?.status && body.result.status !== "healthy") { diff --git a/src/lib/__tests__/mock-client.test.ts b/src/lib/__tests__/mock-client.test.ts index 36e229b5..8d88c87b 100644 --- a/src/lib/__tests__/mock-client.test.ts +++ b/src/lib/__tests__/mock-client.test.ts @@ -1,8 +1,94 @@ -import { describe, expect,it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { SorokitClient } from '../client'; import { deterministicMock,DeterministicMockData } from '../deterministic-mock'; import { createMockClient,MOCK_ADDRESS } from '../mock-client'; +import { fetchWithRetry, formatStellarError } from '../rpc'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('RPC retry behavior', () => { + it('retries network dropouts with exponential backoff and jitter', async () => { + const wait = vi.fn(async (_milliseconds: number, _signal?: AbortSignal) => + Promise.resolve(), + ); + const fetchMock = vi.fn() + .mockRejectedValueOnce(new TypeError('Network request failed')) + .mockRejectedValueOnce(new TypeError('Network request failed')) + .mockResolvedValueOnce({ status: 200 } as Response); + vi.stubGlobal('fetch', fetchMock); + + const result = await fetchWithRetry('https://rpc.example', undefined, { + baseDelayMs: 100, + random: () => 0.25, + wait, + }); + + expect(result.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(wait.mock.calls.map(([milliseconds]) => milliseconds)).toEqual([ + 75, + 150, + ]); + }); + + it.each([429, 502, 503, 504])('retries HTTP %i responses', async (status) => { + const wait = vi.fn(async (_milliseconds: number, _signal?: AbortSignal) => + Promise.resolve(), + ); + const fetchMock = vi.fn() + .mockResolvedValueOnce({ status } as Response) + .mockResolvedValueOnce({ status: 200 } as Response); + vi.stubGlobal('fetch', fetchMock); + + const result = await fetchWithRetry('https://rpc.example', undefined, { + baseDelayMs: 100, + random: () => 0.5, + wait, + }); + + expect(result.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenCalledWith(100, undefined); + }); + + it('stops after three retries when the endpoint remains unavailable', async () => { + const wait = vi.fn(async (_milliseconds: number, _signal?: AbortSignal) => + Promise.resolve(), + ); + const fetchMock = vi.fn().mockResolvedValue({ status: 503 } as Response); + vi.stubGlobal('fetch', fetchMock); + + const result = await fetchWithRetry('https://rpc.example', undefined, { + baseDelayMs: 100, + random: () => 0.5, + wait, + }); + + expect(result.status).toBe(503); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(wait.mock.calls.map(([milliseconds]) => milliseconds)).toEqual([ + 100, + 200, + 400, + ]); + }); + + it('exposes Horizon operation result codes alongside the detail', () => { + expect( + formatStellarError({ + detail: 'Transaction failed', + extras: { + result_codes: { + operations: ['op_underfunded', 'op_bad_auth'], + }, + }, + }), + ).toBe( + 'Transaction failed (operation codes: op_underfunded, op_bad_auth)', + ); + }); +}); describe('Mock Client - Issue #30 Fixes', () => { describe('Fix 1: Valid MOCK_ADDRESS', () => { diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts new file mode 100644 index 00000000..180cfabc --- /dev/null +++ b/src/lib/rpc.ts @@ -0,0 +1,126 @@ +const RETRYABLE_HTTP_STATUSES = new Set([429, 502, 503, 504]); +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_BASE_DELAY_MS = 250; + +export interface FetchRetryOptions { + maxRetries?: number; + baseDelayMs?: number; + signal?: AbortSignal; + random?: () => number; + wait?: (milliseconds: number, signal?: AbortSignal) => Promise; +} + +function isAbortError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + (error as { name?: string }).name === "AbortError" + ); +} + +function abortError(): Error { + if (typeof DOMException !== "undefined") { + return new DOMException("The operation was aborted.", "AbortError"); + } + const error = new Error("The operation was aborted."); + error.name = "AbortError"; + return error; +} + +function waitForRetry( + milliseconds: number, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(abortError()); + return; + } + + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + reject(abortError()); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** Fetch with bounded retries for transient RPC responses and network drops. */ +export async function fetchWithRetry( + input: RequestInfo | URL, + init?: RequestInit, + options: FetchRetryOptions = {}, +): Promise { + const maxRetries = Math.max( + 0, + Math.floor(options.maxRetries ?? DEFAULT_MAX_RETRIES), + ); + const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS; + const signal = init?.signal ?? options.signal; + + for (let attempt = 0; ; attempt += 1) { + let response: Response; + try { + response = await fetch(input, { ...init, signal }); + } catch (error) { + if (isAbortError(error) || !(error instanceof TypeError) || attempt >= maxRetries) { + throw error; + } + + const jitter = 0.5 + (options.random ?? Math.random)(); + await (options.wait ?? waitForRetry)( + baseDelayMs * 2 ** attempt * jitter, + signal, + ); + continue; + } + + if (!RETRYABLE_HTTP_STATUSES.has(response.status) || attempt >= maxRetries) { + return response; + } + + const jitter = 0.5 + (options.random ?? Math.random)(); + await (options.wait ?? waitForRetry)( + baseDelayMs * 2 ** attempt * jitter, + signal, + ); + } +} + +type JsonObject = Record; + +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Extract Horizon detail and Stellar operation result codes from an error body. */ +export function formatStellarError(body: unknown): string | null { + if (!isJsonObject(body)) return null; + + const rpcError = isJsonObject(body.error) ? body.error : null; + const detail = + (typeof body.detail === "string" && body.detail) || + (rpcError && typeof rpcError.message === "string" && rpcError.message) || + null; + const extras = isJsonObject(body.extras) + ? body.extras + : rpcError && isJsonObject(rpcError.extras) + ? rpcError.extras + : null; + const resultCodes = extras && isJsonObject(extras.result_codes) + ? extras.result_codes + : null; + const operations = resultCodes?.operations; + const operationCodes = Array.isArray(operations) + ? operations.filter((code): code is string => typeof code === "string") + : []; + + if (operationCodes.length === 0) return detail; + const codes = `operation codes: ${operationCodes.join(", ")}`; + return detail ? `${detail} (${codes})` : codes; +} \ No newline at end of file From a274da87d144d192854dde0d8fac9462c02f7f68 Mon Sep 17 00:00:00 2001 From: "Jenola.base.eth" Date: Sat, 17 Oct 2026 10:52:12 +0000 Subject: [PATCH 2/4] feat(adapter): enhance wallet connection with timeout and exponential backoff, add error guidance for Ledger --- src/lib/__tests__/adapter.test.ts | 85 ++++++++++++++++++++++ src/lib/adapter.ts | 117 +++++++++++++++++++++++++++--- 2 files changed, 191 insertions(+), 11 deletions(-) diff --git a/src/lib/__tests__/adapter.test.ts b/src/lib/__tests__/adapter.test.ts index e87573d7..0319c0f0 100644 --- a/src/lib/__tests__/adapter.test.ts +++ b/src/lib/__tests__/adapter.test.ts @@ -9,6 +9,12 @@ describe('ClientAdapter', () => { adapter = createClientAdapter(); }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + describe('Connection', () => { it('should return error when no wallet is available', async () => { const result = await adapter.connect(); @@ -21,6 +27,85 @@ describe('ClientAdapter', () => { const result = await adapter.connect(); expect(result.data).not.toBe('GBAMQXTQ7IQKPZXJKZJQZJQ...'); }); + + it('times out an unresponsive hardware wallet after 15 seconds', async () => { + vi.useFakeTimers(); + vi.stubGlobal('window', { + freighter: { + requestAccess: vi.fn(() => new Promise(() => {})), + getPublicKey: vi.fn(), + }, + }); + + try { + const resultPromise = adapter.connect(); + await vi.advanceTimersByTimeAsync(15_000); + const result = await resultPromise; + + expect(result.status).toBe('error'); + expect(result.error).toContain('timed out after 15 seconds'); + expect(result.error).toContain('open the Stellar app'); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); + + it('backs off exponentially between failed extension connection attempts', async () => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const requestAccess = vi.fn() + .mockRejectedValueOnce(new Error('Temporary wallet transport failure')) + .mockRejectedValueOnce(new Error('Temporary wallet transport failure')) + .mockResolvedValueOnce({}); + vi.stubGlobal('window', { + freighter: { + requestAccess, + getPublicKey: vi.fn().mockResolvedValue('GTESTADDRESS'), + }, + }); + + try { + const resultPromise = adapter.connect(); + await vi.advanceTimersByTimeAsync(0); + expect(requestAccess).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(249); + expect(requestAccess).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(requestAccess).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(499); + expect(requestAccess).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + + const result = await resultPromise; + expect(result.status).toBe('success'); + expect(requestAccess).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + } + }); + + it('translates Ledger app-not-open error codes into actionable guidance', async () => { + const requestAccess = vi.fn().mockRejectedValue(new Error('Device returned 0x6804')); + vi.stubGlobal('window', { + freighter: { + requestAccess, + getPublicKey: vi.fn(), + }, + }); + + const result = await adapter.connect(); + + expect(result.status).toBe('error'); + expect(result.error).toContain('Open the Stellar app on your Ledger'); + expect(result.error).toContain('unlock the device'); + expect(requestAccess).toHaveBeenCalledTimes(1); + vi.unstubAllGlobals(); + }); }); describe('Contract Invocation', () => { diff --git a/src/lib/adapter.ts b/src/lib/adapter.ts index b7525e0b..84b6c9fb 100644 --- a/src/lib/adapter.ts +++ b/src/lib/adapter.ts @@ -28,6 +28,93 @@ export interface AdapterResponse { import type { InvokeParams } from "./client"; +const WALLET_CONNECT_TIMEOUT_MS = 15_000; +const MAX_WALLET_CONNECT_ATTEMPTS = 3; +const WALLET_CONNECT_BACKOFF_MS = 250; + +const LEDGER_ERROR_GUIDANCE: Record = { + "6804": "Open the Stellar app on your Ledger, unlock the device, and try again.", + "6985": "The request was rejected on your Ledger. Approve the request on the device and try again.", + "6a80": "Ledger rejected the request data. Reconnect the device, open the Stellar app, and try again.", + "6d00": "The Stellar app is not open. Open it on your Ledger and try again.", + "6e00": "The wrong app is open. Open the Stellar app on your Ledger and try again.", +}; + +function getLedgerErrorCode(error: unknown): string | null { + if (typeof error === "object" && error !== null) { + const code = (error as { code?: unknown; statusCode?: unknown }).code ?? + (error as { statusCode?: unknown }).statusCode; + if (typeof code === "number") return code.toString(16).toLowerCase(); + if (typeof code === "string") { + const match = code.match(/(?:0x)?([0-9a-f]{4})/i); + if (match) return match[1].toLowerCase(); + } + } + + const message = error instanceof Error ? error.message : String(error); + return message.match(/0x([0-9a-f]{4})/i)?.[1].toLowerCase() ?? null; +} + +function formatWalletConnectionError(error: unknown): string { + const code = getLedgerErrorCode(error); + const guidance = code ? LEDGER_ERROR_GUIDANCE[code] : null; + if (guidance) return `Ledger error 0x${code}: ${guidance}`; + return error instanceof Error ? error.message : String(error); +} + +function isDeviceActionRequired(error: unknown): boolean { + const code = getLedgerErrorCode(error); + return code !== null && code in LEDGER_ERROR_GUIDANCE; +} + +async function withWalletConnectTimeout( + operation: () => Promise, +): Promise { + let timeoutId: ReturnType; + try { + return await Promise.race([ + Promise.resolve().then(operation), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + const error = new Error( + `Wallet connection timed out after ${WALLET_CONNECT_TIMEOUT_MS / 1000} seconds. Unlock your wallet and open the Stellar app if using Ledger.`, + ); + error.name = "TimeoutError"; + reject(error); + }, WALLET_CONNECT_TIMEOUT_MS); + }), + ]); + } finally { + clearTimeout(timeoutId!); + } +} + +async function connectWithBackoff( + operation: () => Promise, +): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + return await withWalletConnectTimeout(operation); + } catch (error) { + if ( + (error instanceof Error && error.name === "TimeoutError") || + isDeviceActionRequired(error) || + attempt >= MAX_WALLET_CONNECT_ATTEMPTS - 1 + ) { + throw error; + } + + const jitter = 0.5 + Math.random(); + await new Promise((resolve) => { + setTimeout( + resolve, + WALLET_CONNECT_BACKOFF_MS * 2 ** attempt * jitter, + ); + }); + } + } +} + /** * Universal client adapter for Stellar wallet connections * Supports Freighter, xBull, Albedo, and testnet mocking @@ -70,19 +157,20 @@ export class ClientAdapter { // Try to connect to installed wallet let connectedAddress: string | null = null; + let connectionError: string | null = null; const win = window as unknown as Record; // Check Freighter const freighter = win.freighter as FreighterWallet | undefined; if (freighter) { try { - const result = await freighter.requestAccess(); - if (result.error) { - throw new Error(result.error); - } - const pk = await freighter.getPublicKey(); - connectedAddress = pk; + connectedAddress = await connectWithBackoff(async () => { + const result = await freighter.requestAccess(); + if (result.error) throw new Error(result.error); + return freighter.getPublicKey(); + }); } catch (e) { + connectionError = formatWalletConnectionError(e); console.debug('Freighter not available:', e); } } @@ -91,9 +179,11 @@ export class ClientAdapter { const xBull = win.xBull as XBullWallet | undefined; if (!connectedAddress && xBull) { try { - const pk = await xBull.requestPublicKey(); - connectedAddress = pk; + connectedAddress = await connectWithBackoff(() => + xBull.requestPublicKey(), + ); } catch (e) { + connectionError = formatWalletConnectionError(e); console.debug('xBull not available:', e); } } @@ -102,9 +192,12 @@ export class ClientAdapter { const albedo = win.albedo as AlbedoWallet | undefined; if (!connectedAddress && albedo) { try { - const result = await albedo.publicKey(); - connectedAddress = result.publicKey; + connectedAddress = await connectWithBackoff(async () => { + const result = await albedo.publicKey(); + return result.publicKey; + }); } catch (e) { + connectionError = formatWalletConnectionError(e); console.debug('Albedo not available:', e); } } @@ -112,7 +205,9 @@ export class ClientAdapter { if (!connectedAddress) { return { data: null, - error: 'No Stellar wallet found. Install Freighter, xBull, or Albedo.', + error: + connectionError ?? + 'No Stellar wallet found. Install Freighter, xBull, or Albedo.', status: 'error', }; } From 4fe78234632c97d8dbb4945f12a6c8d39ecda95b Mon Sep 17 00:00:00 2001 From: "Jenola.base.eth" Date: Sat, 17 Oct 2026 11:01:47 +0000 Subject: [PATCH 3/4] feat(mock-client): enhance mock client with deterministic clock and time manipulation features --- src/lib/__tests__/mock-client.test.ts | 45 ++++++++ src/lib/deterministic-mock.ts | 47 +++++++- src/lib/mock-client.ts | 154 ++++++++++++++++++++++---- 3 files changed, 220 insertions(+), 26 deletions(-) diff --git a/src/lib/__tests__/mock-client.test.ts b/src/lib/__tests__/mock-client.test.ts index 8d88c87b..e07a5692 100644 --- a/src/lib/__tests__/mock-client.test.ts +++ b/src/lib/__tests__/mock-client.test.ts @@ -209,6 +209,51 @@ describe('Mock Client - Issue #30 Fixes', () => { expect(snapshot1).toEqual(snapshot2); }); + + it('produces identical responses from separately created clients with the same seed', async () => { + const client1 = createMockClient({ seed: 8128 }); + const client2 = createMockClient({ seed: 8128 }); + + const [response1, history1, response2, history2] = await Promise.all([ + client1.transaction.submit(), + client1.transaction.getHistory(MOCK_ADDRESS), + client2.transaction.submit(), + client2.transaction.getHistory(MOCK_ADDRESS), + ]); + + expect(response1).toEqual(response2); + expect(history1).toEqual(history2); + }); + + it('advances mock ledger timestamps without using real time', async () => { + const client = createMockClient(); + const before = await client.transaction.getHistory(MOCK_ADDRESS, 1, 1); + const initialTimestamp = Date.parse(before.data![0].createdAt); + + expect(client.advanceTime(60_000)).toBe(1700000060000); + + const after = await client.transaction.getHistory(MOCK_ADDRESS, 1, 1); + expect(Date.parse(after.data![0].createdAt)).toBe(initialTimestamp + 60_000); + }); + }); + + describe('Mock operation failure injection', () => { + it('plays through sequential errors before allowing the operation to succeed', async () => { + const client = createMockClient(); + const firstError = new Error('temporary failure 1'); + const secondError = new Error('temporary failure 2'); + client.setFailureSequence( + [firstError, secondError, 'success'], + 'transaction.submit', + ); + + await expect(client.transaction.submit()).rejects.toBe(firstError); + await expect(client.transaction.submit()).rejects.toBe(secondError); + const result = await client.transaction.submit(); + + expect(result.error).toBeNull(); + expect(result.data).not.toBeNull(); + }); }); describe('Fix 4: Pagination support in getHistory (Issue #588)', () => { diff --git a/src/lib/deterministic-mock.ts b/src/lib/deterministic-mock.ts index 79d0cda8..71309398 100644 --- a/src/lib/deterministic-mock.ts +++ b/src/lib/deterministic-mock.ts @@ -9,15 +9,32 @@ * them directly when you need a continuous sequence. */ +export const DEFAULT_MOCK_TIMESTAMP = 1700000000000; + export class DeterministicMockData { private initialSeed: number; private seed: number; + private readonly clock: DeterministicMockClock; - constructor(seedValue: number = 12345) { + constructor( + seedValue: number = 12345, + clock: DeterministicMockClock = new DeterministicMockClock(), + ) { this.initialSeed = seedValue; this.seed = seedValue; + this.clock = clock; } + /** Current deterministic time in milliseconds since the Unix epoch. */ + now(): number { + return this.clock.now(); + } + + /** Advance this data generator's clock without waiting in real time. */ + advanceTime(milliseconds: number): number { + return this.clock.advanceTime(milliseconds); + } + /** Reset the seed back to its initial value. */ reset(): void { this.seed = this.initialSeed; @@ -67,7 +84,7 @@ export class DeterministicMockData { history.push({ hash: this.generateTransactionHash(), ledger: 12345 - i, - createdAt: new Date(1700000000000 - i * 1000).toISOString(), + createdAt: new Date(this.clock.now() - i * 1000).toISOString(), successful: true, operationCount: 1, feePaid: '100', @@ -87,7 +104,7 @@ export class DeterministicMockData { contractId: this.generateHex(56), type: 'contract', ledger: 12345 - i, - createdAt: new Date(1700000000000 - i * 500).toISOString(), + createdAt: new Date(this.clock.now() - i * 500).toISOString(), topics: [this.generateHex(32), this.generateHex(32)], value: this.generateHex(64), }); @@ -96,5 +113,29 @@ export class DeterministicMockData { } } +/** Manual clock shared by deterministic mock data generators and clients. */ +export class DeterministicMockClock { + private currentTime: number; + + constructor(initialTime: number = DEFAULT_MOCK_TIMESTAMP) { + if (!Number.isFinite(initialTime)) { + throw new RangeError("Mock clock time must be a finite number."); + } + this.currentTime = initialTime; + } + + now(): number { + return this.currentTime; + } + + advanceTime(milliseconds: number): number { + if (!Number.isFinite(milliseconds) || milliseconds < 0) { + throw new RangeError("Mock clock can only advance by a finite non-negative duration."); + } + this.currentTime += milliseconds; + return this.currentTime; + } +} + /** Singleton with a fixed seed for consistent test data across imports. */ export const deterministicMock = new DeterministicMockData(12345); diff --git a/src/lib/mock-client.ts b/src/lib/mock-client.ts index 722c327f..deaaf2ba 100644 --- a/src/lib/mock-client.ts +++ b/src/lib/mock-client.ts @@ -19,7 +19,12 @@ import type { TxResult, TxStatus, } from "./client"; -import { deterministicMock } from "./deterministic-mock"; +import { + DEFAULT_MOCK_TIMESTAMP, + DeterministicMockClock, + DeterministicMockData, + deterministicMock, +} from "./deterministic-mock"; // Valid Stellar testnet address (56 chars: G + 55 uppercase alphanumeric) export const MOCK_ADDRESS = @@ -29,6 +34,59 @@ export const MOCK_ADDRESS = export const MOCK_HISTORY = deterministicMock.generateMockHistory(25); export const MOCK_EVENTS = deterministicMock.generateMockEvents(3); +export type MockFailureStep = Error | "success"; + +export interface MockClientOptions { + seed?: number; + initialTime?: number; +} + +export type MockSorokitClient = SorokitClient & { + /** Inject outcomes for all operations, or a named operation such as `account.getAccount`. */ + setFailureSequence: ( + sequence: MockFailureStep[], + operation?: string, + ) => void; + /** Advance this client's mock timestamp without real timers. */ + advanceTime: (milliseconds: number) => number; +}; + +function interceptMockOperations>( + group: T, + groupName: string, + failureSequences: Map, +): T { + return new Proxy(group, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== "function") return value; + + return (...args: unknown[]) => { + const operation = `${groupName}.${String(property)}`; + const sequenceKey = failureSequences.has(operation) + ? operation + : "*"; + const sequence = failureSequences.get(sequenceKey); + const outcome = sequence?.shift(); + if (sequence?.length === 0) failureSequences.delete(sequenceKey); + if (outcome instanceof Error) return Promise.reject(outcome); + return Reflect.apply(value, target, args); + }; + }, + }); +} + +function rebaseTimestamp( + value: T, + currentTime: number, +): T { + const offset = Date.parse(value.createdAt) - DEFAULT_MOCK_TIMESTAMP; + return { + ...value, + createdAt: new Date(currentTime + offset).toISOString(), + }; +} + export const NETWORKS = { testnet: { passphrase: "Test SDF Network ; September 2015", @@ -247,17 +305,38 @@ const MOCK_ALLOWANCES: AllowanceEntry[] = [ * - Proper pagination support: getHistory slices deterministic transaction records by * page and limit, providing distinct pages and accurate total count for TransactionHistory. */ -export function createMockClient(): SorokitClient; +export function createMockClient(options?: MockClientOptions): MockSorokitClient; export function createMockClient( networkName: string, -): SorokitClient | { data: null; error: string }; + options?: MockClientOptions, +): MockSorokitClient | { data: null; error: string }; export function createMockClient( - networkName?: string, -): SorokitClient | { data: null; error: string } { + networkNameOrOptions?: string | MockClientOptions, + options: MockClientOptions = {}, +): MockSorokitClient | { data: null; error: string } { + const networkName = + typeof networkNameOrOptions === "string" + ? networkNameOrOptions + : undefined; + const clientOptions = + typeof networkNameOrOptions === "object" && networkNameOrOptions !== null + ? networkNameOrOptions + : options; let activeNetwork = networkName && networkName in NETWORKS ? networkName : "testnet"; const connectedAddress = MOCK_ADDRESS; const batchProgressState = new Map(); + const clock = new DeterministicMockClock(clientOptions.initialTime); + const mockData = new DeterministicMockData(clientOptions.seed, clock); + const clientHistory = new DeterministicMockData( + clientOptions.seed, + clock, + ).generateMockHistory(25); + const clientEvents = new DeterministicMockData( + clientOptions.seed, + clock, + ).generateMockEvents(3); + const failureSequences = new Map(); if (networkName && !(networkName in NETWORKS)) { const validNetworks = Object.keys(NETWORKS).join(", "); @@ -267,7 +346,7 @@ export function createMockClient( }; } - return { + const client = { wallet: { connect: async () => ({ data: { address: connectedAddress }, @@ -293,7 +372,7 @@ export function createMockClient( } return { data: { - hash: deterministicMock.generateTransactionHash(), + hash: mockData.generateTransactionHash(), ledger: 12345, successful: true, }, @@ -304,7 +383,7 @@ export function createMockClient( transaction: { submit: async () => ({ data: { - hash: deterministicMock.generateTransactionHash(), + hash: mockData.generateTransactionHash(), ledger: 12345, successful: true, }, @@ -319,11 +398,13 @@ export function createMockClient( ) => { const safePage = Math.max(1, page || 1); const pageSize = - limit !== undefined && limit > 0 ? limit : MOCK_HISTORY.length; - const total = MOCK_HISTORY.length; + limit !== undefined && limit > 0 ? limit : clientHistory.length; + const total = clientHistory.length; const start = (safePage - 1) * pageSize; const end = start + pageSize; - const history = MOCK_HISTORY.slice(start, end); + const history = clientHistory.slice(start, end).map((entry) => + rebaseTimestamp(entry, mockData.now()), + ); return { data: history, error: null, total }; }, estimateFee: async () => ({ @@ -437,7 +518,7 @@ export function createMockClient( data: { success: true, result: { status: "ok", output: "mock-invoke-output" }, - txHash: deterministicMock.generateTransactionHash(), + txHash: mockData.generateTransactionHash(), }, error: null, status: "success" as const, @@ -452,10 +533,10 @@ export function createMockClient( _limit?: number, fromLedger?: number, ) => ({ - data: - fromLedger !== undefined - ? MOCK_EVENTS.filter((e) => e.ledger >= fromLedger) - : MOCK_EVENTS, + data: (fromLedger !== undefined + ? clientEvents.filter((e) => e.ledger >= fromLedger) + : clientEvents + ).map((event) => rebaseTimestamp(event, mockData.now())), error: null, }), }, @@ -490,7 +571,7 @@ export function createMockClient( getNfts: async (_address: string) => ({ data: [], error: null }), sendNft: async () => ({ data: { - hash: deterministicMock.generateTransactionHash(), + hash: mockData.generateTransactionHash(), ledger: 12345, successful: true, }, @@ -498,7 +579,7 @@ export function createMockClient( }), listNftForSale: async () => ({ data: { - hash: deterministicMock.generateTransactionHash(), + hash: mockData.generateTransactionHash(), ledger: 12345, successful: true, }, @@ -574,7 +655,7 @@ export function createMockClient( amount: string; }) => ({ data: { - hash: deterministicMock.generateTransactionHash(), + hash: mockData.generateTransactionHash(), ledger: 12345, successful: true, }, @@ -587,7 +668,7 @@ export function createMockClient( spender: string; }) => ({ data: { - hash: deterministicMock.generateTransactionHash(), + hash: mockData.generateTransactionHash(), ledger: 12345, successful: true, }, @@ -625,12 +706,12 @@ export function createMockClient( batchId: "", }; } - const batchId = `batch-mock-${deterministicMock.generateHex(8)}`; + const batchId = `batch-mock-${mockData.generateHex(8)}`; const entries: BatchEntryResult[] = params.entries.map((e) => ({ address: e.address, amount: e.amount, status: "confirmed" as const, - txHash: deterministicMock.generateTransactionHash(), + txHash: mockData.generateTransactionHash(), retryCount: 0, })); const batchResult: BatchResult = { @@ -641,7 +722,34 @@ export function createMockClient( entries, }; return { - data: batchResult, + setFailureSequence: ( + sequence: MockFailureStep[], + operation = "*", + ) => { + failureSequences.set(operation, [...sequence]); + }, + advanceTime: (milliseconds: number) => + mockData.advanceTime(milliseconds), + }; + + return new Proxy(client as MockSorokitClient, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if ( + typeof property === "string" && + ["wallet", "account", "transaction", "soroban", "network", "nft", "operation", "allowance", "batch"].includes(property) && + typeof value === "object" && + value !== null + ) { + return interceptMockOperations( + value as Record, + property, + failureSequences, + ); + } + return value; + }, + }); error: null, batchId, }; From 66c516f0fce069ef23f33ae387f98a5c1685413a Mon Sep 17 00:00:00 2001 From: "Jenola.base.eth" Date: Sat, 17 Oct 2026 11:28:02 +0000 Subject: [PATCH 4/4] feat(mock-client): refactor mock client to improve failure sequence handling and time manipulation feat(NetworkSwitcher): enhance RPC health check error handling with cause information --- package-lock.json | 101 ----------------------------- src/components/NetworkSwitcher.tsx | 2 + src/lib/mock-client.ts | 70 +++++++++++--------- 3 files changed, 42 insertions(+), 131 deletions(-) diff --git a/package-lock.json b/package-lock.json index 32154134..01b0c11d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1636,21 +1636,6 @@ "node": ">=20" } }, - "node_modules/@coinbase/cdp-sdk/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/@coinbase/wallet-sdk": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/@coinbase/wallet-sdk/-/wallet-sdk-4.3.6.tgz", @@ -1960,21 +1945,6 @@ "node": ">= 6" } }, - "node_modules/@creit.tech/stellar-wallets-kit/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/@creit.tech/xbull-wallet-connect": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@creit.tech/xbull-wallet-connect/-/xbull-wallet-connect-0.4.0.tgz", @@ -8447,21 +8417,6 @@ "ws": "^7.5.1" } }, - "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", @@ -11860,19 +11815,6 @@ } } }, - "node_modules/html-encoding-sniffer/node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -12369,21 +12311,6 @@ "license": "MIT", "peer": true }, - "node_modules/jayson/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/jayson/node_modules/ws": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", @@ -12499,20 +12426,6 @@ } } }, - "node_modules/jsdom/node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/jsdom/node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -16929,20 +16842,6 @@ } } }, - "node_modules/whatwg-url/node_modules/@noble/hashes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", - "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/src/components/NetworkSwitcher.tsx b/src/components/NetworkSwitcher.tsx index f1a03e7e..c9739670 100644 --- a/src/components/NetworkSwitcher.tsx +++ b/src/components/NetworkSwitcher.tsx @@ -160,12 +160,14 @@ export async function checkRpcHealth( if (timedOut) { throw new Error( `RPC endpoint did not respond within ${Math.round(timeoutMs / 1000)}s.`, + { cause: err }, ); } if (isAbortError(err)) throw err; if (err instanceof TypeError) { throw new Error( "Could not reach the RPC endpoint. It may be offline or may not allow requests from this origin (CORS).", + { cause: err }, ); } throw err instanceof Error ? err : new Error("RPC health check failed."); diff --git a/src/lib/mock-client.ts b/src/lib/mock-client.ts index deaaf2ba..16a83c02 100644 --- a/src/lib/mock-client.ts +++ b/src/lib/mock-client.ts @@ -21,9 +21,9 @@ import type { } from "./client"; import { DEFAULT_MOCK_TIMESTAMP, + deterministicMock, DeterministicMockClock, DeterministicMockData, - deterministicMock, } from "./deterministic-mock"; // Valid Stellar testnet address (56 chars: G + 55 uppercase alphanumeric) @@ -722,34 +722,7 @@ export function createMockClient( entries, }; return { - setFailureSequence: ( - sequence: MockFailureStep[], - operation = "*", - ) => { - failureSequences.set(operation, [...sequence]); - }, - advanceTime: (milliseconds: number) => - mockData.advanceTime(milliseconds), - }; - - return new Proxy(client as MockSorokitClient, { - get(target, property, receiver) { - const value = Reflect.get(target, property, receiver); - if ( - typeof property === "string" && - ["wallet", "account", "transaction", "soroban", "network", "nft", "operation", "allowance", "batch"].includes(property) && - typeof value === "object" && - value !== null - ) { - return interceptMockOperations( - value as Record, - property, - failureSequences, - ); - } - return value; - }, - }); + data: batchResult, error: null, batchId, }; @@ -793,5 +766,42 @@ export function createMockClient( error: null, }), }, - } as SorokitClient; + setFailureSequence: ( + sequence: MockFailureStep[], + operation = "*", + ) => { + failureSequences.set(operation, [...sequence]); + }, + advanceTime: (milliseconds: number) => + mockData.advanceTime(milliseconds), + }; + + return new Proxy(client as MockSorokitClient, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if ( + typeof property === "string" && + [ + "wallet", + "account", + "transaction", + "soroban", + "network", + "nft", + "operation", + "allowance", + "batch", + ].includes(property) && + typeof value === "object" && + value !== null + ) { + return interceptMockOperations( + value as Record, + property, + failureSequences, + ); + } + return value; + }, + }); }