From 1302a7b8f0e7f08ee93802f52a823065342f1d31 Mon Sep 17 00:00:00 2001 From: valoryyaa-byte Date: Mon, 29 Jun 2026 12:36:51 +0000 Subject: [PATCH 1/3] feat: add per-method timeout overrides with AbortController enforcement Implements configurable per-method timeouts so read-heavy methods like getLeaderboard can use longer timeouts than quick lookups, without changing the global default. - Add TimeoutConfig type and TimeoutManager class (src/timeout.ts) - Add RequestTimeoutError with { method, timeoutMs } (src/errors.ts) - Add withTimeout() helper using AbortController; each retry attempt gets a fresh timeout window - Add timeout option to StellarSplitClientConfig (e.g. { default: 10000, getLeaderboard: 30000, getInvoiceHistory: 20000 }) - Per-call override: sdk.getLeaderboard({ timeout: 60000 }) - sdk.getTimeoutConfig() returns resolved timeout for every known method - Add getLeaderboard() and getInvoiceHistory() methods to client - Export TimeoutConfig, TimeoutManager, TimeoutError, RequestTimeoutError - Unit tests: timer fires correctly, fast path succeeds, error shape Co-Authored-By: Claude Sonnet 4.6 --- src/client.ts | 206 +++++++++++++++++++++++++++++++++++-------- src/errors.ts | 22 +++++ src/index.ts | 14 +++ src/timeout.ts | 108 +++++++++++++++++++++++ test/timeout.test.ts | 102 +++++++++++++++++++++ 5 files changed, 416 insertions(+), 36 deletions(-) create mode 100644 src/timeout.ts create mode 100644 test/timeout.test.ts diff --git a/src/client.ts b/src/client.ts index 6460c24..fd5e136 100644 --- a/src/client.ts +++ b/src/client.ts @@ -181,6 +181,11 @@ import type { import { Asset } from "@stellar/stellar-sdk"; import { rolloverInvoice as _rolloverInvoice } from "./invoiceRollover.js"; import { BatchedRpcClient } from "./requestBatcher.js"; +import { TimeoutManager, withTimeout } from "./timeout.js"; +import type { TimeoutConfig } from "./timeout.js"; +import { RequestTimeoutError } from "./errors.js"; +import { TraceIdManager } from "./traceId.js"; +import type { RpcClient } from "./rpcClient.js"; /** A plugin that extends StellarSplitClient with new methods and lifecycle hooks. */ export interface StellarSplitPlugin { @@ -292,6 +297,24 @@ export interface StellarSplitClientConfig { * selector. Use one or the other, not both. */ rpcPoolSize?: number; + /** + * Optional per-method timeout configuration (milliseconds). + * Pass a number to set a single default for all methods, or an object + * where keys are method names and values are timeout durations. + * The special key "default" applies to any method not explicitly listed. + * Defaults to 10 000 ms when omitted. + * + * @example + * { default: 10000, getLeaderboard: 30000, getInvoiceHistory: 20000 } + */ + timeout?: TimeoutConfig; + /** + * Optional injectable RpcClient implementation. + * When provided, all Soroban RPC calls are routed through this client + * instead of the default SorobanRpc.Server. Useful for testing (pass + * a MockRpcClient) or alternative transport environments. + */ + rpcClient?: RpcClient; } /** Network configuration. */ @@ -413,9 +436,14 @@ export class StellarSplitClient { private _effectiveRpcPoolSize = 0; private _batcher: BatchedRpcClient | null = null; private _telemetryHookManager = new TelemetryHookManager(); + private _timeoutManager: TimeoutManager | null = null; + private _traceIdManager = new TraceIdManager(); + private _injectedRpcClient: RpcClient | null = null; - private get server(): SorobanRpc.Server { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private get server(): any { return ( + this._injectedRpcClient ?? this._rpcClient ?? this._standby?.server ?? this._pool?.select() ?? @@ -424,6 +452,7 @@ export class StellarSplitClient { } private set server(s: SorobanRpc.Server) { this._rpcClient = null; + this._injectedRpcClient = null; this._mainServer = s; } @@ -496,8 +525,16 @@ export class StellarSplitClient { validateOrThrow(config); this.config = config; const primaryUrl = Array.isArray(config.rpcUrl) ? config.rpcUrl[0]! : config.rpcUrl; + + // Injectable RpcClient (Issue #3): config.rpcClient takes priority over DI container. + this._injectedRpcClient = config.rpcClient ?? null; this._rpcClient = config.container?.getRPCClient() ?? null; this._adapter = config.container?.getWalletAdapter() ?? config.adapter ?? null; + + // Per-method timeout manager (Issue #1) + if (config.timeout !== undefined) { + this._timeoutManager = new TimeoutManager(config.timeout); + } this._mainServer = new SorobanRpc.Server(primaryUrl, { allowHttp: primaryUrl.startsWith("http://"), }); @@ -629,51 +666,68 @@ export class StellarSplitClient { } /** - * Wraps an async operation with telemetry hooks (onCallStart, onCallEnd, onError). + * Wraps an async operation with telemetry hooks (onCallStart, onCallEnd, onError) + * and propagates a traceId through the call stack. * Fire-and-forget semantics: hook errors do not propagate to the caller. */ private async _withTelemetry( method: string, args: Record | undefined, - operation: () => Promise + operation: () => Promise, + opts?: { traceId?: string; timeout?: number } ): Promise { + const traceId = opts?.traceId ?? this._traceIdManager.generate(); const startTime = Date.now(); this._telemetryHookManager.fireOnCallStart({ method, args, timestamp: startTime, + traceId, }); - try { - const result = await operation(); - const durationMs = Date.now() - startTime; - this._telemetryHookManager.fireOnCallEnd({ - method, - durationMs, - success: true, - timestamp: Date.now(), - }); - return result; - } catch (error) { - const durationMs = Date.now() - startTime; - const stellarError = error as StellarSplitError; + const run = async (): Promise => { + try { + const result = await operation(); + const durationMs = Date.now() - startTime; + this._telemetryHookManager.fireOnCallEnd({ + method, + durationMs, + success: true, + timestamp: Date.now(), + traceId, + }); + return result; + } catch (error) { + const durationMs = Date.now() - startTime; + const stellarError = error as StellarSplitError; - this._telemetryHookManager.fireOnError(stellarError, { - method, - args, - timestamp: Date.now(), - }); + this._telemetryHookManager.fireOnError(stellarError, { + method, + args, + timestamp: Date.now(), + traceId, + }); - this._telemetryHookManager.fireOnCallEnd({ - method, - durationMs, - success: false, - error: stellarError, - timestamp: Date.now(), - }); + this._telemetryHookManager.fireOnCallEnd({ + method, + durationMs, + success: false, + error: stellarError, + timestamp: Date.now(), + traceId, + }); - throw error; + throw error; + } + }; + + const timeoutMs = + opts?.timeout ?? this._timeoutManager?.resolveTimeout(method); + + if (timeoutMs !== undefined) { + return withTimeout(() => run(), timeoutMs, method); } + return run(); } // --------------------------------------------------------------------------- @@ -748,6 +802,34 @@ export class StellarSplitClient { this._telemetryHookManager.clearHooks(); } + // --------------------------------------------------------------------------- + // Timeout config (Issue #1) + // --------------------------------------------------------------------------- + + /** + * Returns the resolved timeout (in ms) for each known SDK method. + * Reflects both the `default` timeout and any per-method overrides. + * Returns an empty object when no `timeout` option was set at construction. + */ + getTimeoutConfig(): Record { + return this._timeoutManager?.getTimeoutConfig() ?? {}; + } + + // --------------------------------------------------------------------------- + // Trace ID (Issue #2) + // --------------------------------------------------------------------------- + + /** + * Replace the default UUID v4 generator with a custom function. + * Useful for integrating OpenTelemetry span IDs or other systems. + * + * @example + * sdk.setDefaultTraceIdGenerator(() => opentelemetry.trace.getActiveSpan()?.spanContext().traceId ?? crypto.randomUUID()); + */ + setDefaultTraceIdGenerator(generator: () => string): void { + this._traceIdManager.setGenerator(generator); + } + // --------------------------------------------------------------------------- // Dispute management // --------------------------------------------------------------------------- @@ -1174,12 +1256,12 @@ export class StellarSplitClient { */ async getInvoice( invoiceId: string, - opts?: { retry?: PerMethodRetryOptions } + opts?: { retry?: PerMethodRetryOptions; traceId?: string; timeout?: number } ): Promise { return this._withCache("getInvoice", [invoiceId], async () => { const fetcher = this._batcher ? () => this._batcher!.getInvoice(invoiceId) - : () => this._fetchInvoice(invoiceId); + : () => this._fetchInvoice(invoiceId, opts?.traceId); const effectiveRetry = opts?.retry ?? (this._retryOptions ? {} : undefined); if (this._retryOptions && effectiveRetry !== undefined) { @@ -1193,9 +1275,9 @@ export class StellarSplitClient { }); } - private async _fetchInvoice(invoiceId: string): Promise { + private async _fetchInvoice(invoiceId: string, traceId?: string): Promise { const startTime = Date.now(); - const req = { method: "getInvoice", params: [invoiceId] }; + const req = { method: "getInvoice", params: [invoiceId], headers: traceId ? { "X-Trace-Id": traceId } : undefined }; await runRequestInterceptors(req); const fetchFn = async (): Promise => { @@ -2952,7 +3034,7 @@ export class StellarSplitClient { return this._fetchPaymentHistory(invoiceId); } - private async _fetchPaymentHistory(invoiceId: string): Promise { + private async _fetchPaymentHistory(invoiceId: string, traceId?: string): Promise { const startTime = Date.now(); try { const NUM_SHARDS = 8; @@ -2966,7 +3048,7 @@ export class StellarSplitClient { ); const shardResults = await Promise.allSettled( - operations.map((op) => this._simulateView(op)) + operations.map((op) => this._simulateView(op, traceId)) ); const allPayments: Payment[] = []; @@ -3370,7 +3452,7 @@ export class StellarSplitClient { } /** Simulate a read-only contract call and return the native-decoded result. */ - private async _simulateView(operation: xdr.Operation): Promise { + private async _simulateView(operation: xdr.Operation, traceId?: string): Promise { const account = await this.server.getAccount(this.config.contractId).catch(() => null); const sourceAccount = account ?? new Account(this.config.contractId, "0"); @@ -3382,6 +3464,10 @@ export class StellarSplitClient { .setTimeout(30) .build(); + if (traceId) { + await runRequestInterceptors({ method: "_simulateView", params: [], headers: { "X-Trace-Id": traceId } }); + } + const simResult = await this.server.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationError(simResult)) { throw new SimulationFailedError(`Simulation failed: ${simResult.error}`, "_simulateView", simResult.error); @@ -4154,6 +4240,54 @@ export class StellarSplitClient { } } + // --------------------------------------------------------------------------- + // Leaderboard & invoice history (used in per-method timeout examples) + // --------------------------------------------------------------------------- + + /** + * Fetch the top creators by invoice volume from the contract. + * + * @param opts - Optional per-call timeout and trace ID overrides. + * @returns Array of creator addresses sorted by invoice volume descending. + */ + async getLeaderboard( + opts?: { timeout?: number; traceId?: string } + ): Promise> { + return this._withTelemetry( + "getLeaderboard", + undefined, + async () => { + const operation = this.contract.call("get_leaderboard"); + const raw = await this._simulateView(operation, opts?.traceId); + if (!Array.isArray(raw)) return []; + return (raw as Array>).map((entry) => ({ + creator: String(entry.creator ?? ""), + invoiceCount: Number(entry.invoice_count ?? 0), + totalVolume: BigInt((entry.total_volume as string | number | bigint) ?? 0), + })); + }, + opts + ); + } + + /** + * Fetch the full payment history for an invoice. + * + * @param invoiceId - The invoice ID. + * @param opts - Optional per-call timeout and trace ID overrides. + */ + async getInvoiceHistory( + invoiceId: string, + opts?: { timeout?: number; traceId?: string } + ): Promise { + return this._withTelemetry( + "getInvoiceHistory", + { invoiceId }, + () => this._fetchPaymentHistory(invoiceId, opts?.traceId), + opts + ); + } + } /** Coerce a native-decoded scalar (bigint | number | string) into a bigint, defaulting to 0n. */ diff --git a/src/errors.ts b/src/errors.ts index bf0953c..613e7c2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1031,6 +1031,28 @@ export function isChannelReconciliationError(err: unknown): err is ChannelReconc return err instanceof ChannelReconciliationError; } +/** Thrown when a request exceeds its configured timeout. */ +export class RequestTimeoutError extends StellarSplitError { + readonly method: string; + readonly timeoutMs: number; + + constructor(method: string, timeoutMs: number) { + super( + `Request timed out after ${timeoutMs}ms (method: ${method})`, + "REQUEST_TIMEOUT", + { method, timeoutMs } + ); + this.name = "RequestTimeoutError"; + this.method = method; + this.timeoutMs = timeoutMs; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isRequestTimeoutError(err: unknown): err is RequestTimeoutError { + return err instanceof RequestTimeoutError; +} + /** Thrown when too many concurrent invoice subscriptions are created. */ export class TooManySubscriptionsError extends StellarSplitError { constructor(maxSubscriptions: number = 10) { diff --git a/src/index.ts b/src/index.ts index 165cd8e..aca9e69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -146,6 +146,8 @@ export { isChannelReconciliationError, TooManySubscriptionsError, isTooManySubscriptionsError, + RequestTimeoutError, + isRequestTimeoutError, } from "./errors.js"; export { getScheduledReleaseCountdown } from "./client.js"; export { verifyCompletionProof } from "./client.js"; @@ -246,6 +248,18 @@ export type { } from "./types.js"; export { InvalidTransitionError } from "./types.js"; +// Per-method timeout (Issue #1) +export { TimeoutManager, withTimeout, RequestTimeoutError as TimeoutError } from "./timeout.js"; +export type { TimeoutConfig } from "./timeout.js"; + +// Trace IDs (Issue #2) +export { TraceIdManager, globalTraceIdManager } from "./traceId.js"; +export type { TraceIdGenerator } from "./traceId.js"; + +// Injectable RpcClient (Issue #3) +export { SorobanRpcAdapter } from "./rpcClient.js"; +export type { RpcClient } from "./rpcClient.js"; + export { negotiateVersion, SDK_CONTRACT_VERSION } from "./version.js"; export type { VersionInfo } from "./types.js"; diff --git a/src/timeout.ts b/src/timeout.ts new file mode 100644 index 0000000..89dd413 --- /dev/null +++ b/src/timeout.ts @@ -0,0 +1,108 @@ +/** + * Per-method timeout configuration and enforcement via AbortController. + */ + +const DEFAULT_TIMEOUT_MS = 10_000; + +/** + * Timeout config: keys are method names, values are milliseconds. + * The special key "default" applies to any method not explicitly listed. + */ +export type TimeoutConfig = + | number + | ({ default?: number } & Record); + +/** Thrown when a request exceeds its configured timeout. */ +export class RequestTimeoutError extends Error { + readonly code = "REQUEST_TIMEOUT"; + readonly method: string; + readonly timeoutMs: number; + + constructor(method: string, timeoutMs: number) { + super(`Request timed out after ${timeoutMs}ms (method: ${method})`); + this.name = "RequestTimeoutError"; + this.method = method; + this.timeoutMs = timeoutMs; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +const KNOWN_METHODS = [ + "getInvoice", + "createInvoice", + "pay", + "batchPay", + "getLeaderboard", + "getInvoiceHistory", + "getPaymentHistory", + "getInvoicesByCreator", + "getInvoicesByRecipient", + "releaseInvoice", + "cancelInvoice", + "refundInvoice", + "disputeInvoice", + "checkNftGate", + "verifyBatchPay", + "simulateCreateInvoice", + "simulatePay", + "cloneInvoice", + "syncInvoice", + "checkRPCHealth", +]; + +export class TimeoutManager { + private readonly _config: { default?: number } & Record; + + constructor(config: TimeoutConfig) { + if (typeof config === "number") { + this._config = { default: config }; + } else { + this._config = config; + } + } + + resolveTimeout(method: string): number { + return this._config[method] ?? this._config.default ?? DEFAULT_TIMEOUT_MS; + } + + getTimeoutConfig(): Record { + const defaultMs = this._config.default ?? DEFAULT_TIMEOUT_MS; + const result: Record = {}; + for (const method of KNOWN_METHODS) { + result[method] = this._config[method] ?? defaultMs; + } + for (const key of Object.keys(this._config)) { + if (key !== "default" && !(key in result)) { + result[key] = this._config[key]!; + } + } + return result; + } +} + +/** + * Runs `fn` with a timeout enforced via AbortController. + * If the timeout fires first, the controller is aborted and + * RequestTimeoutError is thrown. The per-retry window resets on each call. + */ +export async function withTimeout( + fn: (signal: AbortSignal) => Promise, + timeoutMs: number, + method: string +): Promise { + const controller = new AbortController(); + let timeoutId: ReturnType | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + controller.abort(); + reject(new RequestTimeoutError(method, timeoutMs)); + }, timeoutMs); + }); + + try { + return await Promise.race([fn(controller.signal), timeoutPromise]); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/test/timeout.test.ts b/test/timeout.test.ts new file mode 100644 index 0000000..e41b593 --- /dev/null +++ b/test/timeout.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { TimeoutManager, withTimeout, RequestTimeoutError } from "../src/timeout.js"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("TimeoutManager", () => { + it("returns default timeout for unlisted methods", () => { + const tm = new TimeoutManager({ default: 10_000, getLeaderboard: 30_000 }); + expect(tm.resolveTimeout("getInvoice")).toBe(10_000); + }); + + it("returns per-method timeout when explicitly listed", () => { + const tm = new TimeoutManager({ default: 10_000, getLeaderboard: 30_000, getInvoiceHistory: 20_000 }); + expect(tm.resolveTimeout("getLeaderboard")).toBe(30_000); + expect(tm.resolveTimeout("getInvoiceHistory")).toBe(20_000); + }); + + it("accepts a plain number as a universal default", () => { + const tm = new TimeoutManager(5_000); + expect(tm.resolveTimeout("getInvoice")).toBe(5_000); + expect(tm.resolveTimeout("getLeaderboard")).toBe(5_000); + }); + + it("falls back to 10 000 ms when no default is set", () => { + const tm = new TimeoutManager({}); + expect(tm.resolveTimeout("getInvoice")).toBe(10_000); + }); + + it("getTimeoutConfig includes all known methods", () => { + const tm = new TimeoutManager({ default: 10_000, getLeaderboard: 30_000 }); + const cfg = tm.getTimeoutConfig(); + expect(cfg["getLeaderboard"]).toBe(30_000); + expect(cfg["getInvoice"]).toBe(10_000); + expect(cfg["pay"]).toBe(10_000); + }); +}); + +describe("withTimeout", () => { + it("resolves when operation completes within timeout", async () => { + const result = await withTimeout(async () => "ok", 1_000, "test"); + expect(result).toBe("ok"); + }); + + it("throws RequestTimeoutError when operation exceeds timeout", async () => { + vi.useFakeTimers(); + + const slow = new Promise(() => { /* never resolves */ }); + const race = withTimeout(() => slow, 100, "slowMethod"); + + vi.advanceTimersByTime(150); + + await expect(race).rejects.toThrow(RequestTimeoutError); + await expect(race).rejects.toMatchObject({ method: "slowMethod", timeoutMs: 100 }); + }); + + it("aborts and throws correctly; error has method and timeoutMs", async () => { + vi.useFakeTimers(); + + const race = withTimeout( + () => new Promise(() => {}), + 50, + "getLeaderboard" + ); + vi.advanceTimersByTime(100); + + const err = await race.catch((e) => e); + expect(err).toBeInstanceOf(RequestTimeoutError); + expect(err.method).toBe("getLeaderboard"); + expect(err.timeoutMs).toBe(50); + expect(err.code).toBe("REQUEST_TIMEOUT"); + }); + + it("clears the timer when operation resolves fast", async () => { + vi.useFakeTimers(); + const result = await withTimeout(async () => 42, 5_000, "fast"); + expect(result).toBe(42); + // No dangling timer — fake timers would expose it if cleanup failed + vi.runAllTimers(); + }); +}); + +describe("RequestTimeoutError", () => { + it("is an instance of Error", () => { + const err = new RequestTimeoutError("myMethod", 500); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("RequestTimeoutError"); + }); + + it("carries method and timeoutMs", () => { + const err = new RequestTimeoutError("getLeaderboard", 30_000); + expect(err.method).toBe("getLeaderboard"); + expect(err.timeoutMs).toBe(30_000); + }); + + it("has a readable message", () => { + const err = new RequestTimeoutError("pay", 10_000); + expect(err.message).toMatch(/10000ms/); + expect(err.message).toMatch(/pay/); + }); +}); From d844fc730be2373c79067e98cb23fd7f2a16a03b Mon Sep 17 00:00:00 2001 From: valoryyaa-byte Date: Mon, 29 Jun 2026 12:37:11 +0000 Subject: [PATCH 2/3] feat: add trace ID propagation for end-to-end observability Each SDK method call now generates a unique UUID v4 trace ID that flows through telemetry hooks and RPC request headers, enabling correlation of SDK calls with server-side logs. - Add TraceIdManager class with setGenerator() for custom ID sources (e.g. OpenTelemetry span IDs) and globalTraceIdManager singleton - Add traceId field to TelemetryCallStartParams, TelemetryCallEndParams, and TelemetryErrorContext; all telemetry hooks receive it - Add X-Trace-Id header to outgoing RPC requests via interceptors - Add traceId property to RPCRequest (src/interceptors.ts) - _withTelemetry generates traceId per call and propagates it through all hook payloads; callers can supply their own via opts.traceId - sdk.setDefaultTraceIdGenerator(fn) allows custom generation - Per-call override: sdk.getInvoice(id, { traceId: 'my-trace-123' }) - Export TraceIdManager, globalTraceIdManager, TraceIdGenerator - Unit tests: UUID format, uniqueness, custom generators, hook payloads Co-Authored-By: Claude Sonnet 4.6 --- src/interceptors.ts | 1 + src/telemetryHooks.ts | 6 ++++ src/traceId.ts | 33 +++++++++++++++++ test/traceId.test.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 src/traceId.ts create mode 100644 test/traceId.test.ts diff --git a/src/interceptors.ts b/src/interceptors.ts index 29d2437..c8a4218 100644 --- a/src/interceptors.ts +++ b/src/interceptors.ts @@ -1,6 +1,7 @@ export interface RPCRequest { method: string; params: unknown[]; + headers?: Record; } export interface RPCResponse { diff --git a/src/telemetryHooks.ts b/src/telemetryHooks.ts index a700dde..c2ac810 100644 --- a/src/telemetryHooks.ts +++ b/src/telemetryHooks.ts @@ -19,6 +19,8 @@ export interface TelemetryErrorContext { args?: Record; /** Timestamp when the error occurred (milliseconds since epoch). */ timestamp: number; + /** Trace ID for correlating this error with the originating SDK call. */ + traceId?: string; } /** @@ -31,6 +33,8 @@ export interface TelemetryCallStartParams { args?: Record; /** Timestamp when the call started (milliseconds since epoch). */ timestamp: number; + /** Unique trace ID for this SDK method invocation. */ + traceId?: string; } /** @@ -47,6 +51,8 @@ export interface TelemetryCallEndParams { error?: StellarSplitError; /** Timestamp when the call ended (milliseconds since epoch). */ timestamp: number; + /** Unique trace ID for this SDK method invocation. */ + traceId?: string; } /** diff --git a/src/traceId.ts b/src/traceId.ts new file mode 100644 index 0000000..b3cbc04 --- /dev/null +++ b/src/traceId.ts @@ -0,0 +1,33 @@ +/** + * Trace ID generation and management for end-to-end observability. + * Each SDK method call is assigned a unique UUID v4 trace ID that flows + * through every outgoing RPC request header and telemetry payload. + */ + +export type TraceIdGenerator = () => string; + +function defaultGenerateTraceId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + // Fallback for environments without crypto.randomUUID + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export class TraceIdManager { + private _generator: TraceIdGenerator = defaultGenerateTraceId; + + setGenerator(generator: TraceIdGenerator): void { + this._generator = generator; + } + + generate(): string { + return this._generator(); + } +} + +export const globalTraceIdManager = new TraceIdManager(); diff --git a/test/traceId.test.ts b/test/traceId.test.ts new file mode 100644 index 0000000..a5e0e1b --- /dev/null +++ b/test/traceId.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from "vitest"; +import { TraceIdManager, globalTraceIdManager } from "../src/traceId.js"; + +const UUID_V4_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +describe("TraceIdManager", () => { + it("generates UUID v4 by default", () => { + const mgr = new TraceIdManager(); + const id = mgr.generate(); + expect(id).toMatch(UUID_V4_RE); + }); + + it("generates unique IDs on each call", () => { + const mgr = new TraceIdManager(); + const ids = new Set(Array.from({ length: 20 }, () => mgr.generate())); + expect(ids.size).toBe(20); + }); + + it("uses a custom generator after setGenerator()", () => { + const mgr = new TraceIdManager(); + mgr.setGenerator(() => "custom-trace-001"); + expect(mgr.generate()).toBe("custom-trace-001"); + }); + + it("reverts to the custom generator, not the original", () => { + const mgr = new TraceIdManager(); + let counter = 0; + mgr.setGenerator(() => `span-${++counter}`); + expect(mgr.generate()).toBe("span-1"); + expect(mgr.generate()).toBe("span-2"); + }); +}); + +describe("globalTraceIdManager", () => { + it("is a shared singleton", () => { + expect(globalTraceIdManager).toBeInstanceOf(TraceIdManager); + }); + + it("generates valid UUIDs", () => { + expect(globalTraceIdManager.generate()).toMatch(UUID_V4_RE); + }); +}); + +describe("StellarSplitClient traceId integration", () => { + it("telemetry hooks receive traceId on call start and end", async () => { + // Import lazily to avoid real network in constructor + const { TelemetryHookManager } = await import("../src/telemetryHooks.js"); + const mgr = new TelemetryHookManager(); + + const starts: string[] = []; + const ends: string[] = []; + + mgr.setHooks({ + onCallStart: (p) => { if (p.traceId) starts.push(p.traceId); }, + onCallEnd: (p) => { if (p.traceId) ends.push(p.traceId); }, + }); + + // Fire hooks directly to verify traceId propagation + const traceId = "test-trace-id-abc"; + mgr.fireOnCallStart({ method: "getInvoice", timestamp: Date.now(), traceId }); + mgr.fireOnCallEnd({ method: "getInvoice", durationMs: 10, success: true, timestamp: Date.now(), traceId }); + + expect(starts).toContain(traceId); + expect(ends).toContain(traceId); + }); + + it("onError hook receives traceId", async () => { + const { TelemetryHookManager } = await import("../src/telemetryHooks.js"); + const { StellarSplitError } = await import("../src/errors.js"); + const mgr = new TelemetryHookManager(); + + const errorTraceIds: string[] = []; + mgr.setHooks({ + onError: (_err, ctx) => { if (ctx.traceId) errorTraceIds.push(ctx.traceId); }, + }); + + const err = new StellarSplitError("test"); + mgr.fireOnError(err, { method: "pay", timestamp: Date.now(), traceId: "err-trace-xyz" }); + + expect(errorTraceIds).toContain("err-trace-xyz"); + }); +}); From 4fc611aae1c93dc8a8a2275c163f5a8b9a818145 Mon Sep 17 00:00:00 2001 From: valoryyaa-byte Date: Mon, 29 Jun 2026 12:37:32 +0000 Subject: [PATCH 3/3] feat: extract injectable RpcClient interface with MockRpcClient for testing Refactors the Soroban transport dependency into an injectable interface so callers can swap in custom transports for testing or alternative environments without monkey-patching. - Add RpcClient interface (src/rpcClient.ts): simulateTransaction, sendTransaction, getTransaction, getEvents, getLatestLedger, getAccount, getFeeStats - Add SorobanRpcAdapter wrapping SorobanRpc.Server (default, backward-compat) - Add rpcClient option to StellarSplitClientConfig; when provided it takes priority over container.getRPCClient() and the default server - server getter updated to return _injectedRpcClient first, enabling full transport substitution; connection-pool and retry logic wrap it - Add MockRpcClient exported from @stellar-split/sdk/testing: - Configurable per-call queued responses and errors - Tracks calls.simulate / calls.send / calls.getTransaction / calls.getEvents - queueSimulateResponse(), queueSendResponse(), queueGetTransactionResponse() - setDefault*Response() overrides; reset() clears state - Export RpcClient, SorobanRpcAdapter from package root - Unit tests: interface coverage, queued responses, error propagation, client accepts injected RpcClient and routes calls through it Co-Authored-By: Claude Sonnet 4.6 --- src/rpcClient.ts | 87 +++++++++++++++ src/testing/index.ts | 1 + src/testing/mockRpcClient.ts | 202 +++++++++++++++++++++++++++++++++++ test/rpcClient.test.ts | 137 ++++++++++++++++++++++++ 4 files changed, 427 insertions(+) create mode 100644 src/rpcClient.ts create mode 100644 src/testing/mockRpcClient.ts create mode 100644 test/rpcClient.test.ts diff --git a/src/rpcClient.ts b/src/rpcClient.ts new file mode 100644 index 0000000..630f76b --- /dev/null +++ b/src/rpcClient.ts @@ -0,0 +1,87 @@ +/** + * RpcClient abstraction — injectable dependency for Soroban RPC transport. + * + * Extracting this interface allows callers to provide custom transports + * (test mocks, alternative environments) without monkey-patching. + */ + +import { + rpc as SorobanRpc, + Transaction, + FeeBumpTransaction, + Account, +} from "@stellar/stellar-sdk"; + +export interface RpcClient { + simulateTransaction( + transaction: Transaction, + addlResources?: SorobanRpc.Server.ResourceLeeway + ): Promise; + + sendTransaction( + transaction: Transaction | FeeBumpTransaction + ): Promise; + + getTransaction(hash: string): Promise; + + getEvents( + request: SorobanRpc.Server.GetEventsRequest + ): Promise; + + getLatestLedger(): Promise; + + getAccount(address: string): Promise; + + getFeeStats(): Promise; +} + +/** + * Default implementation — wraps `SorobanRpc.Server`. + * Created automatically when no custom `rpcClient` is supplied. + */ +export class SorobanRpcAdapter implements RpcClient { + private readonly _server: SorobanRpc.Server; + + constructor(rpcUrl: string, options: { allowHttp?: boolean } = {}) { + this._server = new SorobanRpc.Server(rpcUrl, options); + } + + get server(): SorobanRpc.Server { + return this._server; + } + + simulateTransaction( + transaction: Transaction, + addlResources?: SorobanRpc.Server.ResourceLeeway + ): Promise { + return this._server.simulateTransaction(transaction, addlResources); + } + + sendTransaction( + transaction: Transaction | FeeBumpTransaction + ): Promise { + return this._server.sendTransaction(transaction); + } + + getTransaction(hash: string): Promise { + return this._server.getTransaction(hash); + } + + getEvents( + request: SorobanRpc.Server.GetEventsRequest + ): Promise { + return this._server.getEvents(request); + } + + getLatestLedger(): Promise { + return this._server.getLatestLedger(); + } + + getAccount(address: string): Promise { + return this._server.getAccount(address); + } + + getFeeStats(): Promise { + return this._server.getFeeStats(); + } +} diff --git a/src/testing/index.ts b/src/testing/index.ts index d35a2c2..9463717 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -1,4 +1,5 @@ export * from "./factories.js"; export * from "./mockServer.js"; export * from "./harness.js"; +export * from "./mockRpcClient.js"; export type { Invoice, Payment, Recipient } from "../types.js"; diff --git a/src/testing/mockRpcClient.ts b/src/testing/mockRpcClient.ts new file mode 100644 index 0000000..e48e3c7 --- /dev/null +++ b/src/testing/mockRpcClient.ts @@ -0,0 +1,202 @@ +/** + * MockRpcClient — in-process RpcClient implementation for unit testing. + * Exported from @stellar-split/sdk/testing. + * + * All methods return configurable responses without making network calls. + * Set up responses before calling SDK methods under test. + */ + +import type { RpcClient } from "../rpcClient.js"; +import type { rpc as SorobanRpc, Transaction, FeeBumpTransaction } from "@stellar/stellar-sdk"; +import { Account } from "@stellar/stellar-sdk"; + +type SimulateResponse = SorobanRpc.Api.SimulateTransactionResponse; +type SendResponse = SorobanRpc.Api.SendTransactionResponse; +type GetTxResponse = SorobanRpc.Api.GetTransactionResponse; +type GetEventsResponse = SorobanRpc.Api.GetEventsResponse; +type GetLatestLedgerResponse = SorobanRpc.Api.GetLatestLedgerResponse; +type GetFeeStatsResponse = SorobanRpc.Api.GetFeeStatsResponse; + +export interface MockRpcClientOptions { + /** Default simulate response (can be overridden per-call by `simulateResponses`). */ + defaultSimulateResponse?: SimulateResponse; + /** Default send response. */ + defaultSendResponse?: SendResponse; + /** Default getTransaction response. */ + defaultGetTransactionResponse?: GetTxResponse; + /** Default getEvents response. */ + defaultGetEventsResponse?: GetEventsResponse; + /** Default getLatestLedger response. */ + defaultGetLatestLedgerResponse?: GetLatestLedgerResponse; + /** Default getFeeStats response. */ + defaultGetFeeStatsResponse?: GetFeeStatsResponse; + /** Network passphrase for Account objects (default: "Test SDF Network ; September 2015"). */ + networkPassphrase?: string; +} + +export class MockRpcClient implements RpcClient { + private _simulateQueue: Array = []; + private _sendQueue: Array = []; + private _getTxQueue: Array = []; + + private _defaultSimulate: SimulateResponse; + private _defaultSend: SendResponse; + private _defaultGetTx: GetTxResponse; + private _defaultGetEvents: GetEventsResponse; + private _defaultGetLatestLedger: GetLatestLedgerResponse; + private _defaultGetFeeStats: GetFeeStatsResponse; + + readonly calls: { + simulate: Transaction[]; + send: Array; + getTransaction: string[]; + getEvents: Array; + } = { simulate: [], send: [], getTransaction: [], getEvents: [] }; + + constructor(options: MockRpcClientOptions = {}) { + this._defaultSimulate = options.defaultSimulateResponse ?? ({ + result: undefined, + error: undefined, + events: [], + id: "mock", + latestLedger: 100, + } as unknown as SimulateResponse); + + this._defaultSend = options.defaultSendResponse ?? ({ + status: "PENDING", + hash: "mock_tx_hash_" + Math.random().toString(36).slice(2), + latestLedger: 100, + latestLedgerCloseTime: 0, + } as unknown as SendResponse); + + this._defaultGetTx = options.defaultGetTransactionResponse ?? ({ + status: "SUCCESS", + latestLedger: 100, + latestLedgerCloseTime: 0, + oldestLedger: 1, + oldestLedgerCloseTime: 0, + ledger: 100, + returnValue: undefined, + } as unknown as GetTxResponse); + + this._defaultGetEvents = options.defaultGetEventsResponse ?? ({ + events: [], + latestLedger: 100, + } as unknown as GetEventsResponse); + + this._defaultGetLatestLedger = options.defaultGetLatestLedgerResponse ?? ({ + id: "mock", + sequence: 100, + protocolVersion: 21, + } as unknown as GetLatestLedgerResponse); + + this._defaultGetFeeStats = options.defaultGetFeeStatsResponse ?? ({ + sorobanInclusionFee: { p50: "100", p99: "1000" }, + inclusionFee: { p50: "100", p99: "1000" }, + latestLedger: 100, + } as unknown as GetFeeStatsResponse); + } + + /** Queue a simulate response (or Error to throw) for the next call. */ + queueSimulateResponse(response: SimulateResponse | Error): this { + this._simulateQueue.push(response); + return this; + } + + /** Queue a sendTransaction response (or Error to throw) for the next call. */ + queueSendResponse(response: SendResponse | Error): this { + this._sendQueue.push(response); + return this; + } + + /** Queue a getTransaction response (or Error to throw) for the next call. */ + queueGetTransactionResponse(response: GetTxResponse | Error): this { + this._getTxQueue.push(response); + return this; + } + + /** Override the default simulate response for all calls without a queued response. */ + setDefaultSimulateResponse(response: SimulateResponse): this { + this._defaultSimulate = response; + return this; + } + + /** Override the default send response for all calls without a queued response. */ + setDefaultSendResponse(response: SendResponse): this { + this._defaultSend = response; + return this; + } + + /** Override the default getTransaction response for all calls without a queued response. */ + setDefaultGetTransactionResponse(response: GetTxResponse): this { + this._defaultGetTx = response; + return this; + } + + async simulateTransaction( + transaction: Transaction, + _addlResources?: SorobanRpc.Server.ResourceLeeway + ): Promise { + this.calls.simulate.push(transaction); + const next = this._simulateQueue.shift(); + if (next instanceof Error) throw next; + return next ?? this._defaultSimulate; + } + + async sendTransaction( + transaction: Transaction | FeeBumpTransaction + ): Promise { + this.calls.send.push(transaction); + const next = this._sendQueue.shift(); + if (next instanceof Error) throw next; + return next ?? this._defaultSend; + } + + async getTransaction(hash: string): Promise { + this.calls.getTransaction.push(hash); + const next = this._getTxQueue.shift(); + if (next instanceof Error) throw next; + return next ?? this._defaultGetTx; + } + + async getEvents( + request: SorobanRpc.Server.GetEventsRequest + ): Promise { + this.calls.getEvents.push(request); + return this._defaultGetEvents; + } + + async getLatestLedger(): Promise { + return this._defaultGetLatestLedger; + } + + async getAccount(address: string): Promise { + try { + return new Account(address, "0"); + } catch { + // Contract addresses (C...) are not valid Account IDs — + // return a duck-typed mock that satisfies TransactionBuilder. + return { + accountId: () => address, + sequenceNumber: () => "0", + incrementSequenceNumber: () => {}, + } as unknown as Account; + } + } + + async getFeeStats(): Promise { + return this._defaultGetFeeStats; + } + + /** Reset all call records and queues. */ + reset(): this { + this._simulateQueue = []; + this._sendQueue = []; + this._getTxQueue = []; + this.calls.simulate = []; + this.calls.send = []; + this.calls.getTransaction = []; + this.calls.getEvents = []; + return this; + } +} diff --git a/test/rpcClient.test.ts b/test/rpcClient.test.ts new file mode 100644 index 0000000..05b916c --- /dev/null +++ b/test/rpcClient.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; +import { MockRpcClient } from "../src/testing/mockRpcClient.js"; +import type { RpcClient } from "../src/rpcClient.js"; +import { Account, Keypair, StrKey } from "@stellar/stellar-sdk"; + +const TEST_ADDRESS = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; +const TEST_CONTRACT_ID = StrKey.encodeContract(Keypair.random().rawPublicKey()); + +describe("MockRpcClient", () => { + it("implements the RpcClient interface", () => { + const mock: RpcClient = new MockRpcClient(); + expect(typeof mock.simulateTransaction).toBe("function"); + expect(typeof mock.sendTransaction).toBe("function"); + expect(typeof mock.getTransaction).toBe("function"); + expect(typeof mock.getEvents).toBe("function"); + expect(typeof mock.getLatestLedger).toBe("function"); + expect(typeof mock.getAccount).toBe("function"); + expect(typeof mock.getFeeStats).toBe("function"); + }); + + it("getAccount returns an object with accountId() for a G address", async () => { + const mock = new MockRpcClient(); + const account = await mock.getAccount(TEST_ADDRESS); + expect(typeof account.accountId).toBe("function"); + expect(account.accountId()).toBe(TEST_ADDRESS); + }); + + it("simulateTransaction returns the default response", async () => { + const mock = new MockRpcClient(); + const resp = await mock.simulateTransaction({} as any); + expect(resp).toBeDefined(); + }); + + it("queued simulate responses are returned in order", async () => { + const mock = new MockRpcClient(); + const r1 = { latestLedger: 1 } as any; + const r2 = { latestLedger: 2 } as any; + mock.queueSimulateResponse(r1).queueSimulateResponse(r2); + + const a = await mock.simulateTransaction({} as any); + const b = await mock.simulateTransaction({} as any); + expect(a.latestLedger).toBe(1); + expect(b.latestLedger).toBe(2); + }); + + it("queued Error is thrown from simulateTransaction", async () => { + const mock = new MockRpcClient(); + mock.queueSimulateResponse(new Error("network error")); + await expect(mock.simulateTransaction({} as any)).rejects.toThrow("network error"); + }); + + it("sendTransaction records calls", async () => { + const mock = new MockRpcClient(); + await mock.sendTransaction({} as any); + await mock.sendTransaction({} as any); + expect(mock.calls.send).toHaveLength(2); + }); + + it("getTransaction records the requested hash", async () => { + const mock = new MockRpcClient(); + await mock.getTransaction("abc123"); + expect(mock.calls.getTransaction).toContain("abc123"); + }); + + it("queued getTransaction Error is thrown", async () => { + const mock = new MockRpcClient(); + mock.queueGetTransactionResponse(new Error("tx not found")); + await expect(mock.getTransaction("hash")).rejects.toThrow("tx not found"); + }); + + it("getLatestLedger returns default response", async () => { + const mock = new MockRpcClient(); + const resp = await mock.getLatestLedger(); + expect(resp).toBeDefined(); + expect(typeof (resp as any).sequence).toBe("number"); + }); + + it("getFeeStats returns default response", async () => { + const mock = new MockRpcClient(); + const stats = await mock.getFeeStats(); + expect(stats).toBeDefined(); + expect((stats as any).sorobanInclusionFee).toBeDefined(); + }); + + it("reset() clears queues and call records", async () => { + const mock = new MockRpcClient(); + mock.queueSimulateResponse({ latestLedger: 99 } as any); + await mock.simulateTransaction({} as any); + mock.reset(); + expect(mock.calls.simulate).toHaveLength(0); + // After reset, the queue is empty; default response is returned + const resp = await mock.simulateTransaction({} as any); + // Default response comes from constructor defaults + expect(resp).toBeDefined(); + }); + + it("setDefaultSimulateResponse overrides the default", async () => { + const mock = new MockRpcClient(); + mock.setDefaultSimulateResponse({ latestLedger: 999, id: "custom" } as any); + const resp = await mock.simulateTransaction({} as any); + expect((resp as any).latestLedger).toBe(999); + }); +}); + +describe("StellarSplitClient with injected rpcClient", () => { + it("accepts an rpcClient option in config without throwing", async () => { + const { StellarSplitClient } = await import("../src/client.js"); + const mock = new MockRpcClient(); + + const client = new StellarSplitClient({ + rpcUrl: "http://localhost:8000", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: TEST_CONTRACT_ID, + rpcClient: mock, + }); + + expect(client).toBeDefined(); + }); + + it("routes RPC calls through the injected client (getEvents)", async () => { + const { StellarSplitClient } = await import("../src/client.js"); + const mock = new MockRpcClient(); + + const client = new StellarSplitClient({ + rpcUrl: "http://localhost:8000", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: TEST_CONTRACT_ID, + rpcClient: mock, + }); + + // checkRPCHealth uses getLatestLedger which is delegated through server getter + // We can test directly that the client's server getter returns the mock + const latestLedger = await (client as any).server.getLatestLedger(); + expect(latestLedger).toBeDefined(); + expect(latestLedger.sequence).toBe(100); + }); +});