From 9ef26afcf5f473fe5a2d77be484e21c8bf0f73c4 Mon Sep 17 00:00:00 2001 From: TheCodingChef-eth Date: Mon, 31 Aug 2026 09:07:55 +0100 Subject: [PATCH 1/4] fix(relayer): use exponential backoff with jitter for reconnect (#539) Replace linear reconnect delay (delay * attempt) with exponential backoff capped at maxReconnectDelayMs and randomized jitter (0.5-1.0x). Prevents thundering herd when multiple clients reconnect after a server restart. --- src/relayer/WebSocketRelayer.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/relayer/WebSocketRelayer.ts b/src/relayer/WebSocketRelayer.ts index 18d9a0f..7d523f8 100644 --- a/src/relayer/WebSocketRelayer.ts +++ b/src/relayer/WebSocketRelayer.ts @@ -20,6 +20,7 @@ export interface WebSocketRelayerOptions { maxPendingMessages?: number; maxReconnectAttempts?: number; reconnectDelayMs?: number; + maxReconnectDelayMs?: number; onStateChange?: StateChangeHandler; } @@ -37,6 +38,7 @@ export class WebSocketRelayer { private reconnectExhausted = false; private maxReconnectAttempts: number; private reconnectDelayMs: number; + private maxReconnectDelayMs: number; private pendingMessages: WebSocketMessage[] = []; private maxPendingMessages: number; private stateTransition: RelayerStateTransition = 'disconnected'; @@ -45,6 +47,7 @@ export class WebSocketRelayer { this.url = url; this.maxReconnectAttempts = options?.maxReconnectAttempts ?? 5; this.reconnectDelayMs = options?.reconnectDelayMs ?? 1000; + this.maxReconnectDelayMs = options?.maxReconnectDelayMs ?? 30_000; this.maxPendingMessages = options?.maxPendingMessages ?? 1000; if (options?.onStateChange) { this.stateChangeHandlers.add(options.onStateChange); @@ -304,7 +307,11 @@ export class WebSocketRelayer { this.reconnectAttempts++; this.emitStateChange('reconnecting'); - await new Promise((r) => setTimeout(r, this.reconnectDelayMs * this.reconnectAttempts)); + const delay = Math.min( + this.maxReconnectDelayMs, + this.reconnectDelayMs * 2 ** (this.reconnectAttempts - 1), + ) * (0.5 + Math.random() * 0.5); + await new Promise((r) => setTimeout(r, delay)); if (this.isDestroyed) return; await this.establishConnection(); From 71ad474f291f7523b8793b70b2c6fb541210d414 Mon Sep 17 00:00:00 2001 From: TheCodingChef-eth Date: Mon, 31 Aug 2026 09:09:36 +0100 Subject: [PATCH 2/4] fix(builder): validate batch-item amount as bigint or decimal string (#538) Replace Number-based amount validation with toStroops-based validation that accepts bigint or decimal strings. Rejects Number inputs and scientific notation strings that lose precision for large stroop amounts. --- src/builder.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/builder.ts b/src/builder.ts index d1f2c02..7c48774 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -1,5 +1,5 @@ import { StrKey, Address, nativeToScVal } from '@stellar/stellar-sdk'; -import { bigintSafeStringify } from './utils.js'; +import { bigintSafeStringify, toStroops } from './utils.js'; import { boolToScVal } from './soroban.js'; import { buildBatchTransactions, @@ -573,11 +573,24 @@ function validatePayload(streams: unknown): string[] { } } - // Validate amount field — must be a positive finite number (where present) + // Validate amount field — must be a bigint or a decimal string parseable by toStroops if (obj.amount !== undefined && obj.amount !== null) { - const amount = Number(obj.amount); - if (!Number.isFinite(amount) || amount <= 0) { - errors.push(`Batch item at index ${i}: amount must be a positive finite number, got "${obj.amount}"`); + const amt = obj.amount; + if (typeof amt === 'bigint') { + if (amt <= 0n) { + errors.push(`Batch item at index ${i}: amount must be a positive bigint, got ${amt.toString()}`); + } + } else if (typeof amt === 'string') { + try { + const stroops = toStroops(amt); + if (stroops <= 0n) { + errors.push(`Batch item at index ${i}: amount must be a positive value, got "${amt}"`); + } + } catch { + errors.push(`Batch item at index ${i}: amount must be a valid decimal string (e.g. "1000" or "1.5"), got "${amt}"`); + } + } else { + errors.push(`Batch item at index ${i}: amount must be a bigint or decimal string, got ${typeof amt}`); } } } From 72de082c4ae9ac46eb973229b2c255d79b794a72 Mon Sep 17 00:00:00 2001 From: TheCodingChef-eth Date: Mon, 31 Aug 2026 09:10:48 +0100 Subject: [PATCH 3/4] fix(factory): clamp pagination offset to valid u32 range (#537) Add clampOffset() to validate and clamp the offset parameter in streamsBySender/streamsByRecipient. Prevents raw stellar-sdk conversion errors from non-integer, negative, or >2^32-1 offset values. --- src/constants.ts | 10 ++++++++++ src/factory.ts | 6 +++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 84c5194..75542db 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -49,6 +49,16 @@ export function clampListLimit(limit: number): number { return Math.min(Math.max(Math.trunc(limit), 0), MAX_LIST_LIMIT); } +/** + * Clamp a caller-supplied pagination `offset` to a non-negative integer + * within the valid u32 range (`[0, 2^32 - 1]`). Non-finite or negative + * input returns 0 rather than producing an invalid u32 conversion. + */ +export function clampOffset(offset: number): number { + if (!Number.isFinite(offset)) return 0; + return Math.min(Math.max(Math.trunc(offset), 0), 0xFFFFFFFF); +} + /** * Bit-flags packed into the on-chain `StreamInfo.flags` (`u32`). `paused`, * `cancelled` and `clawback_enabled` are NOT individual struct fields — they diff --git a/src/factory.ts b/src/factory.ts index 0749537..87e7992 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -6,7 +6,7 @@ import { nativeToScVal, xdr, Address } from '@stellar/stellar-sdk'; import type { ConduitConfig } from './types/index.js'; import type { WalletAdapter } from './adapters/types.js'; import { KeypairWalletAdapter } from './adapters/keypair.js'; -import { ZERO_ADDR, DEFAULT_LIST_LIMIT, clampListLimit } from './constants.js'; +import { ZERO_ADDR, DEFAULT_LIST_LIMIT, clampListLimit, clampOffset } from './constants.js'; import { buildContractCallTx, simulateReadOnly, @@ -190,7 +190,7 @@ export class FactoryModule { this.factoryId, 'streams_by_sender', [ new Address(address).toScVal(), - nativeToScVal(offset, { type: 'u32' }), + nativeToScVal(clampOffset(offset), { type: 'u32' }), nativeToScVal(clampListLimit(limit), { type: 'u32' }), ], ); @@ -211,7 +211,7 @@ export class FactoryModule { this.factoryId, 'streams_by_recipient', [ new Address(address).toScVal(), - nativeToScVal(offset, { type: 'u32' }), + nativeToScVal(clampOffset(offset), { type: 'u32' }), nativeToScVal(clampListLimit(limit), { type: 'u32' }), ], ); From 1d9ec8276d694e985763a756060b84b5b6b7ab55 Mon Sep 17 00:00:00 2001 From: TheCodingChef-eth Date: Mon, 31 Aug 2026 09:11:39 +0100 Subject: [PATCH 4/4] fix(utils): validate durationSecs in calculateRate/calculateYield (#536) Add guard to reject non-integer, negative, zero, NaN, or Infinity durationSecs inputs. Prevents raw RangeError from BigInt conversion and negative rate/yield outputs. --- src/utils.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/utils.ts b/src/utils.ts index af50552..a05d6dd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -57,6 +57,9 @@ export function fromStroops(stroops: bigint, decimals = 7): string { * @param decimals Token decimal places (default 7 for Stellar assets) */ export function calculateRate(depositAmount: string, durationSecs: number, decimals = 7): bigint { + if (!Number.isInteger(durationSecs) || durationSecs <= 0) { + throw new Error(`durationSecs must be a positive integer, got ${durationSecs}`); + } const stroops = toStroops(depositAmount, decimals); const divisor = BigInt(durationSecs); if (divisor === 0n) return 0n; @@ -80,6 +83,9 @@ export function calculateYield( durationSecs = 31_536_000, decimals = 7, ): string { + if (!Number.isInteger(durationSecs) || durationSecs <= 0) { + throw new Error(`durationSecs must be a positive integer, got ${durationSecs}`); + } const totalStroops = ratePerSecond * BigInt(durationSecs); return fromStroops(totalStroops, decimals); }