diff --git a/package-lock.json b/package-lock.json index 467bb71..b5c2d4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1575,6 +1575,76 @@ } } }, + "node_modules/@creit.tech/stellar-wallets-kit/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@creit.tech/stellar-wallets-kit/node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "peer": true, + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/@creit.tech/stellar-wallets-kit/node_modules/bignumber.js": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", + "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", + "license": "MIT", + "peer": true + }, + "node_modules/@creit.tech/stellar-wallets-kit/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/@creit.tech/stellar-wallets-kit/node_modules/eventsource": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.0.tgz", + "integrity": "sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@creit.tech/stellar-wallets-kit/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@creit.tech/xbull-wallet-connect": { "version": "0.4.0", "dependencies": { @@ -8601,6 +8671,10 @@ }, "node_modules/jayson/node_modules/commander": { "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT", + "peer": true "license": "MIT" }, "node_modules/jayson/node_modules/ws": { diff --git a/src/components/NetworkBanner.tsx b/src/components/NetworkBanner.tsx index d3bff44..b167d5e 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 e88bde0..4bc5825 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") { @@ -154,6 +160,7 @@ export async function checkRpcHealth( if (timedOut) { const error = new Error( `RPC endpoint did not respond within ${Math.round(timeoutMs / 1000)}s.`, + { cause: err }, ); (error as Error & { cause?: unknown }).cause = err; throw error; @@ -162,6 +169,7 @@ export async function checkRpcHealth( if (err instanceof TypeError) { const error = new Error( "Could not reach the RPC endpoint. It may be offline or may not allow requests from this origin (CORS).", + { cause: err }, ); (error as Error & { cause?: unknown }).cause = err; throw error; diff --git a/src/lib/__tests__/adapter.test.ts b/src/lib/__tests__/adapter.test.ts index 05f1f77..147bcd2 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/__tests__/mock-client.test.ts b/src/lib/__tests__/mock-client.test.ts index 36e229b..e07a569 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', () => { @@ -123,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/adapter.ts b/src/lib/adapter.ts index a7a6c37..61d1042 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 @@ -73,19 +160,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); } } @@ -94,9 +182,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); } } @@ -105,9 +195,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); } } @@ -115,7 +208,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', }; } diff --git a/src/lib/deterministic-mock.ts b/src/lib/deterministic-mock.ts index 79d0cda..7130939 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 722c327..16a83c0 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, + deterministicMock, + DeterministicMockClock, + DeterministicMockData, +} 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 = { @@ -685,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; + }, + }); } diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts new file mode 100644 index 0000000..180cfab --- /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