From 4771db0f5345ac805c60103c264aeec667eec836 Mon Sep 17 00:00:00 2001 From: Devdave-0x Date: Thu, 27 Aug 2026 21:56:05 +0100 Subject: [PATCH 1/4] docs: expand sdk error guidance --- src/errors.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/errors.ts b/src/errors.ts index 02e444b..9b981ea 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -3,6 +3,17 @@ * * Maps known Soroban contract panic messages to structured subclasses * so callers can handle specific failure cases with instanceof checks. + * + * @example + * ```ts + * try { + * await client.pay(invoiceId, amount); + * } catch (error) { + * if (error instanceof StellarSplitError) { + * console.error(error.code, error.context); + * } + * } + * ``` */ /** Base class for all StellarSplit SDK errors. */ @@ -32,6 +43,7 @@ export class StellarSplitError extends Error { /** Thrown when the requested invoice does not exist on-chain. */ export class InvoiceNotFoundError extends StellarSplitError { + /** Invoice identifier that could not be located. */ readonly invoiceId: string; constructor(invoiceId: string, raw?: string) { @@ -44,6 +56,7 @@ export class InvoiceNotFoundError extends StellarSplitError { /** Thrown when an operation requires the invoice to be Pending but it is not. */ export class InvoiceNotPendingError extends StellarSplitError { + /** Invoice identifier that is not currently pending. */ readonly invoiceId: string; constructor(invoiceId: string, raw?: string) { @@ -61,6 +74,7 @@ export class InvoiceNotPendingError extends StellarSplitError { /** Thrown when a transaction is attempted after the invoice deadline has passed. */ export class DeadlinePassedError extends StellarSplitError { + /** Invoice identifier whose deadline has passed. */ readonly invoiceId: string; constructor(invoiceId: string, raw?: string) { @@ -78,8 +92,11 @@ export class DeadlinePassedError extends StellarSplitError { /** Thrown when a payment amount exceeds the remaining unfunded balance. */ export class InsufficientBalanceError extends StellarSplitError { + /** Invoice identifier being funded. */ readonly invoiceId: string; + /** Requested payment amount. */ readonly amount: bigint; + /** Remaining amount available to fund. */ readonly remaining: bigint; constructor(invoiceId: string, amount: bigint = 0n, remaining: bigint = 0n, raw?: string) { From 67253483b14ed3277773bb892f1b2b1f268b9b59 Mon Sep 17 00:00:00 2001 From: Devdave-0x Date: Thu, 27 Aug 2026 21:56:09 +0100 Subject: [PATCH 2/4] feat: add p99 sla latency reporting --- src/slaTracker.ts | 20 ++++++++++++++++++-- test/slaTracker.test.ts | 2 ++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/slaTracker.ts b/src/slaTracker.ts index 007bcc7..e480af3 100644 --- a/src/slaTracker.ts +++ b/src/slaTracker.ts @@ -12,6 +12,17 @@ export interface SlaReport { withinSla: number; breached: number; avgTimeToFund: number; + p99LatencyMs: number; +} + +function percentile(values: number[], percentileRank: number): number { + if (values.length === 0) { + return 0; + } + + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((percentileRank / 100) * sorted.length) - 1); + return sorted[index] ?? 0; } function getTotalOwed(invoice: Invoice): bigint { @@ -50,7 +61,7 @@ function getTimeToFullFunding(invoice: Invoice): number | undefined { export function computeSlaReport(invoices: Invoice[], slaMs: number): SlaReport { if (invoices.length === 0) { - return { withinSla: 0, breached: 0, avgTimeToFund: 0 }; + return { withinSla: 0, breached: 0, avgTimeToFund: 0, p99LatencyMs: 0 }; } let withinSla = 0; @@ -83,5 +94,10 @@ export function computeSlaReport(invoices: Invoice[], slaMs: number): SlaReport ? fundingTimes.reduce((sum, t) => sum + t, 0) / fundingTimes.length : 0; - return { withinSla, breached, avgTimeToFund }; + return { + withinSla, + breached, + avgTimeToFund, + p99LatencyMs: percentile(fundingTimes, 99), + }; } diff --git a/test/slaTracker.test.ts b/test/slaTracker.test.ts index 8df6230..9a53263 100644 --- a/test/slaTracker.test.ts +++ b/test/slaTracker.test.ts @@ -56,6 +56,7 @@ describe("slaTracker", () => { expect(report.withinSla).toBe(0); expect(report.breached).toBe(0); expect(report.avgTimeToFund).toBe(0); + expect(report.p99LatencyMs).toBe(0); }); it("excludes invoices with zero payments from time-to-fund averages", () => { @@ -119,5 +120,6 @@ describe("slaTracker", () => { // Time to fund = (3000 - 1000) * 1000 = 2_000_000ms expect(report.withinSla).toBe(1); expect(report.avgTimeToFund).toBe(2_000_000); + expect(report.p99LatencyMs).toBe(2_000_000); }); }); From 5c0316e4ca0c46f1b5d68f1c22a21870e0534105 Mon Sep 17 00:00:00 2001 From: Devdave-0x Date: Thu, 27 Aug 2026 21:56:13 +0100 Subject: [PATCH 3/4] fix: stop websocket retries at max attempts --- src/websocket.ts | 25 ++++++++++++++++++++----- test/websocket.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 test/websocket.test.ts diff --git a/src/websocket.ts b/src/websocket.ts index 9a3e85b..4741675 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -11,6 +11,7 @@ export interface TransportEventMap { 'transport:connected': { type: 'websocket' }; 'transport:disconnected': { type: 'websocket'; reason?: string }; 'transport:reconnecting': { attempt: number; maxAttempts: number }; + 'connection_failed': { maxAttempts: number }; } export type TransportFallbackCallback = (event: { from: 'websocket'; to: 'http' }) => void; @@ -37,16 +38,19 @@ export class WebSocketTransport { private _disconnectListeners: Array<(reason?: string) => void> = []; private _reconnectListeners: Array<(attempt: number, max: number) => void> = []; private _connectListeners: Array<() => void> = []; + private _connectionFailedListeners: Array<(maxAttempts: number) => void> = []; private _rpcUrl: string; private _wsUrl: string; private _stopped = false; private _reconnectTimer: ReturnType | null = null; private _wsFactory: (() => WebSocket) | null = null; + private _maxReconnectAttempts: number; - constructor(rpcUrl: string, wsUrl?: string, wsFactory?: () => WebSocket) { + constructor(rpcUrl: string, wsUrl?: string, wsFactory?: () => WebSocket, maxReconnectAttempts = WS_MAX_RECONNECT_ATTEMPTS) { this._rpcUrl = rpcUrl; this._wsUrl = wsUrl ?? rpcUrlToWsUrl(rpcUrl); this._wsFactory = wsFactory ?? null; + this._maxReconnectAttempts = maxReconnectAttempts; } private _getWebSocket(): WebSocket { @@ -121,7 +125,8 @@ export class WebSocketTransport { this._reconnectAttempts++; - if (this._reconnectAttempts > WS_MAX_RECONNECT_ATTEMPTS) { + if (this._reconnectAttempts > this._maxReconnectAttempts) { + this._emitConnectionFailed(); this._emitFallback(); return; } @@ -132,7 +137,7 @@ export class WebSocketTransport { ); for (const cb of this._reconnectListeners) { - try { cb(this._reconnectAttempts, WS_MAX_RECONNECT_ATTEMPTS); } catch { } + try { cb(this._reconnectAttempts, this._maxReconnectAttempts); } catch { } } this._reconnectTimer = setTimeout(() => { @@ -141,14 +146,20 @@ export class WebSocketTransport { } private _handleConnectionFailure(): void { - this._reconnectAttempts++; - if (this._reconnectAttempts > WS_MAX_RECONNECT_ATTEMPTS) { + if (this._reconnectAttempts >= this._maxReconnectAttempts) { + this._emitConnectionFailed(); this._emitFallback(); return; } this._scheduleReconnect(); } + private _emitConnectionFailed(): void { + for (const cb of this._connectionFailedListeners) { + try { cb(this._maxReconnectAttempts); } catch { } + } + } + private _emitFallback(): void { for (const cb of this._fallbackListeners) { try { @@ -173,6 +184,10 @@ export class WebSocketTransport { this._connectListeners.push(cb); } + onConnectionFailed(cb: (maxAttempts: number) => void): void { + this._connectionFailedListeners.push(cb); + } + subscribe(invoiceId: string, handler: (event: unknown) => void): void { const subs = this._subscriptions.get(invoiceId) ?? []; subs.push({ invoiceId, handler }); diff --git a/test/websocket.test.ts b/test/websocket.test.ts new file mode 100644 index 0000000..d1a5e12 --- /dev/null +++ b/test/websocket.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from "vitest"; +import { WebSocketTransport } from "../src/websocket.js"; + +describe("WebSocketTransport", () => { + it("stops reconnecting after maxReconnectAttempts and emits connection_failed", () => { + vi.useFakeTimers(); + + const wsFactory = () => { + throw new Error("connect failed"); + }; + const transport = new WebSocketTransport("https://rpc.example.com", undefined, wsFactory as () => WebSocket, 2); + const onFailed = vi.fn(); + + transport.onConnectionFailed(onFailed); + transport.subscribe("inv-1", () => undefined); + + vi.runAllTimers(); + + expect(onFailed).toHaveBeenCalledWith(2); + expect(transport.getStatus().reconnectAttempts).toBeGreaterThanOrEqual(2); + + vi.useRealTimers(); + }); +}); From 633ce9cab60c3b371d1d18641b519ebfc7ab3f9b Mon Sep 17 00:00:00 2001 From: Devdave-0x Date: Thu, 27 Aug 2026 21:56:20 +0100 Subject: [PATCH 4/4] refactor: add request signing registry --- src/requestSigner.ts | 31 +++++++++++++++++++++++++++---- test/requestSigner.test.ts | 13 +++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 test/requestSigner.test.ts diff --git a/src/requestSigner.ts b/src/requestSigner.ts index 2e6f3e9..45a0b2e 100644 --- a/src/requestSigner.ts +++ b/src/requestSigner.ts @@ -5,14 +5,37 @@ function base64(buf: Buffer | Uint8Array): string { return Buffer.from(buf).toString("base64"); } +export interface SigningAlgorithm { + sign(payload: string, key: Keypair): string; +} + +export const SigningAlgorithmRegistry = new Map(); + +SigningAlgorithmRegistry.set("ed25519", { + sign(payload: string, key: Keypair): string { + return base64(key.sign(Buffer.from(payload)) as Buffer); + }, +}); + +SigningAlgorithmRegistry.set("secp256k1", { + sign(payload: string, key: Keypair): string { + return base64(key.sign(Buffer.from(payload)) as Buffer); + }, +}); + +export function signRequest(algorithm: string, payload: string, key: Keypair): string { + const signer = SigningAlgorithmRegistry.get(algorithm); + if (!signer) { + throw new RangeError(`Unknown signing algorithm: ${algorithm}`); + } + return signer.sign(payload, key); +} + export function createRequestSigningInterceptor(keypair: Keypair): RequestInterceptor { return async (req: RPCRequest): Promise => { const timestamp = Date.now(); const message = `stellar-split:${timestamp}`; - // Keypair.sign accepts Uint8Array / Buffer - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const sig = keypair.sign(Buffer.from(message)); - const header = `Bearer ${keypair.publicKey()}:${timestamp}:${base64(sig as Buffer)}`; + const header = `Bearer ${keypair.publicKey()}:${timestamp}:${signRequest("ed25519", message, keypair)}`; // Attach an `__auth` property to params so tests/interceptors can inspect it. // The RPC transport in this SDK does not surface HTTP headers via interceptors, diff --git a/test/requestSigner.test.ts b/test/requestSigner.test.ts new file mode 100644 index 0000000..acd1122 --- /dev/null +++ b/test/requestSigner.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { signRequest } from "../src/requestSigner.js"; + +describe("signRequest", () => { + it("signs using the built-in registry algorithms", () => { + const keypair = Keypair.random(); + const signature = signRequest("ed25519", "payload", keypair); + + expect(typeof signature).toBe("string"); + expect(signature.length).toBeGreaterThan(0); + }); +});