diff --git a/src/builder.ts b/src/builder.ts index 08f4948..8cb5f00 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, @@ -574,11 +574,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}`); } } } diff --git a/src/constants.ts b/src/constants.ts index d6661da..2c8294c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -51,6 +51,16 @@ export function clampListLimit(limit: number): number { return Math.min(truncated, 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 59994ec..0980f52 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, @@ -197,7 +197,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' }), ], ); @@ -218,7 +218,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' }), ], ); diff --git a/src/relayer/WebSocketRelayer.ts b/src/relayer/WebSocketRelayer.ts index 1d6e056..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,8 +307,12 @@ export class WebSocketRelayer { this.reconnectAttempts++; this.emitStateChange('reconnecting'); - await new Promise((r) => setTimeout(r, this.reconnectDelayMs * this.reconnectAttempts)); - if (this.isDestroyed || !this.reconnectEnabled) return; + 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(); } catch { diff --git a/src/utils.ts b/src/utils.ts index 07f3f12..162e8c2 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); }