Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
31 changes: 27 additions & 4 deletions src/requestSigner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SigningAlgorithm>();

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<RPCRequest> => {
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,
Expand Down
20 changes: 18 additions & 2 deletions src/slaTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
};
}
25 changes: 20 additions & 5 deletions src/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<typeof setTimeout> | 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 {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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(() => {
Expand All @@ -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 {
Expand All @@ -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 });
Expand Down
13 changes: 13 additions & 0 deletions test/requestSigner.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions test/slaTracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
24 changes: 24 additions & 0 deletions test/websocket.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});