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/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/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/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/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/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/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/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); + }); +}); 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/); + }); +}); 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"); + }); +});