From b8ba334398aa46616b8549b3513d3386e63e5f70 Mon Sep 17 00:00:00 2001 From: orsar-rita Date: Sun, 18 Oct 2026 12:06:08 +0000 Subject: [PATCH] feat: implement offline tx building, batch ops, currency conversion, and request retry - Implement configurable request timeout and exponential backoff retry logic with jitter and error classification (#1030) - Implement currency conversion utilities with exchange rate providers, validation, and TTL caching (#1031) - Add batch operation support with fluent builder, validation, executor, and atomic transaction guarantees (#1032) - Implement offline transaction building, signing support, and sound round-trip serialization (#1033) - Export new features in SDK entry point and add comprehensive unit test suites --- src/batchOperations.ts | 409 ++++++++++++++++++++++++++++ src/currencyConverter.ts | 348 ++++++++++++++++++++++-- src/index.ts | 64 +++++ src/offlineTransaction.ts | 468 ++++++++++++++++++++++++++++++++ src/requestRetry.ts | 298 ++++++++++++++++++++ test/batchOperations.test.ts | 193 +++++++++++++ test/currencyConverter.test.ts | 195 ++++++++++++- test/offlineTransaction.test.ts | 238 ++++++++++++++++ test/requestRetry.test.ts | 213 +++++++++++++++ 9 files changed, 2392 insertions(+), 34 deletions(-) create mode 100644 src/batchOperations.ts create mode 100644 src/offlineTransaction.ts create mode 100644 src/requestRetry.ts create mode 100644 test/batchOperations.test.ts create mode 100644 test/offlineTransaction.test.ts create mode 100644 test/requestRetry.test.ts diff --git a/src/batchOperations.ts b/src/batchOperations.ts new file mode 100644 index 0000000..197131e --- /dev/null +++ b/src/batchOperations.ts @@ -0,0 +1,409 @@ +/** + * Batch Operation Support for StellarSplit SDK. + * + * Enables composing multiple operations into a single atomic Stellar transaction, + * with comprehensive validation, executor reliability, fee computation, and atomic guarantees. + * + * Issue #1032 + */ + +import { + Asset, + Operation, + Transaction, + TransactionBuilder, + BASE_FEE, + Account, + StrKey, + Keypair, + xdr, + Memo, +} from "@stellar/stellar-sdk"; +import { ValidationError, TransactionFailedError } from "./errors.js"; + +/** Stellar protocol maximum operations per transaction. */ +export const MAX_OPERATIONS_PER_BATCH = 100; + +// --------------------------------------------------------------------------- +// Operation Types +// --------------------------------------------------------------------------- + +export interface PaymentBatchOp { + type: "payment"; + destination: string; + amount: string | number | bigint; + asset?: Asset | string; + source?: string; +} + +export interface CreateAccountBatchOp { + type: "createAccount"; + destination: string; + startingBalance: string | number | bigint; + source?: string; +} + +export interface ManageDataBatchOp { + type: "manageData"; + name: string; + value: string | Buffer | Uint8Array | null; + source?: string; +} + +export interface ChangeTrustBatchOp { + type: "changeTrust"; + asset: Asset; + limit?: string; + source?: string; +} + +export interface CustomBatchOp { + type: "custom"; + operation: xdr.Operation; +} + +export type BatchOperation = + | PaymentBatchOp + | CreateAccountBatchOp + | ManageDataBatchOp + | ChangeTrustBatchOp + | CustomBatchOp; + +export interface BatchExecutionResult { + /** Whether the atomic transaction succeeded. */ + success: boolean; + /** Transaction hash if submitted or simulated. */ + txHash: string; + /** Number of operations included in this single transaction. */ + operationCount: number; + /** Total fee in stroops allocated for the transaction. */ + feeStroops: string; + /** Guarantees all-or-nothing execution across all batched operations. */ + atomic: true; + /** Base64-encoded transaction envelope XDR. */ + envelopeXdr: string; +} + +export interface BatchExecutorConfig { + networkPassphrase: string; + sourceAddress: string; + /** Fee per operation in stroops. Defaults to BASE_FEE (100 stroops). */ + baseFee?: number | string; + memo?: Memo; + timebounds?: { minTime?: number; maxTime: number }; + /** Server with simulateTransaction or sendTransaction for network execution. */ + server?: { + getAccount?: (address: string) => Promise<{ sequenceNumber: () => string }>; + simulateTransaction?: (tx: Transaction) => Promise; + sendTransaction?: (tx: Transaction) => Promise<{ status: string; hash: string; errorResult?: unknown }>; + }; + /** Signer: Keypair, secret key string, or custom sign function. */ + signer?: Keypair | string | ((xdr: string) => Promise); +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +export function validateBatchOperation(op: BatchOperation, index: number): void { + if (!op || typeof op !== "object" || !("type" in op)) { + throw new ValidationError(`Operation at index ${index} is missing a valid type.`); + } + + if (op.source && !StrKey.isValidEd25519PublicKey(op.source)) { + throw new ValidationError(`Invalid source address at operation index ${index}: ${op.source}`, { index }); + } + + switch (op.type) { + case "payment": { + if (!StrKey.isValidEd25519PublicKey(op.destination)) { + throw new ValidationError(`Invalid payment destination at index ${index}: ${op.destination}`, { index }); + } + const numAmt = Number(op.amount); + if (!Number.isFinite(numAmt) || numAmt <= 0) { + throw new ValidationError(`Payment amount must be a positive number at index ${index}, got: ${op.amount}`, { index }); + } + break; + } + + case "createAccount": { + if (!StrKey.isValidEd25519PublicKey(op.destination)) { + throw new ValidationError(`Invalid createAccount destination at index ${index}: ${op.destination}`, { index }); + } + const numBal = Number(op.startingBalance); + if (!Number.isFinite(numBal) || numBal <= 0) { + throw new ValidationError(`Starting balance must be positive at index ${index}, got: ${op.startingBalance}`, { index }); + } + break; + } + + case "manageData": { + if (typeof op.name !== "string" || op.name.length === 0) { + throw new ValidationError(`manageData name cannot be empty at index ${index}`, { index }); + } + if (Buffer.byteLength(op.name, "utf8") > 64) { + throw new ValidationError(`manageData name exceeds 64 bytes at index ${index}`, { index }); + } + break; + } + + case "changeTrust": { + if (!op.asset) { + throw new ValidationError(`changeTrust requires an asset at index ${index}`, { index }); + } + break; + } + + case "custom": { + if (!op.operation) { + throw new ValidationError(`custom operation is missing xdr.Operation at index ${index}`, { index }); + } + break; + } + + default: + throw new ValidationError(`Unsupported operation type '${(op as any).type}' at index ${index}`); + } +} + +export function validateBatchOperations(operations: BatchOperation[]): void { + if (!Array.isArray(operations) || operations.length === 0) { + throw new ValidationError("Batch must contain at least 1 operation."); + } + if (operations.length > MAX_OPERATIONS_PER_BATCH) { + throw new ValidationError( + `Batch contains ${operations.length} operations, which exceeds maximum limit of ${MAX_OPERATIONS_PER_BATCH}.`, + { count: operations.length, max: MAX_OPERATIONS_PER_BATCH }, + ); + } + operations.forEach((op, index) => validateBatchOperation(op, index)); +} + +// --------------------------------------------------------------------------- +// Batch Operation Builder +// --------------------------------------------------------------------------- + +export class BatchOperationBuilder { + private readonly operations: BatchOperation[] = []; + + add(op: BatchOperation): this { + this.operations.push(op); + return this; + } + + addPayment(params: Omit): this { + return this.add({ type: "payment", ...params }); + } + + addCreateAccount(params: Omit): this { + return this.add({ type: "createAccount", ...params }); + } + + addManageData(params: Omit): this { + return this.add({ type: "manageData", ...params }); + } + + addChangeTrust(params: Omit): this { + return this.add({ type: "changeTrust", ...params }); + } + + addCustom(operation: xdr.Operation): this { + return this.add({ type: "custom", operation }); + } + + getOperations(): readonly BatchOperation[] { + return [...this.operations]; + } + + size(): number { + return this.operations.length; + } + + clear(): this { + this.operations.length = 0; + return this; + } + + validate(): void { + validateBatchOperations(this.operations); + } + + toXdrOperations(): xdr.Operation[] { + this.validate(); + return this.operations.map((op) => { + switch (op.type) { + case "payment": { + const asset = typeof op.asset === "string" + ? (op.asset === "native" || op.asset === "XLM" ? Asset.native() : new Asset(op.asset, op.source ?? "")) + : (op.asset ?? Asset.native()); + return Operation.payment({ + destination: op.destination, + asset, + amount: op.amount.toString(), + source: op.source, + }); + } + case "createAccount": + return Operation.createAccount({ + destination: op.destination, + startingBalance: op.startingBalance.toString(), + source: op.source, + }); + case "manageData": + return Operation.manageData({ + name: op.name, + value: op.value, + source: op.source, + }); + case "changeTrust": + return Operation.changeTrust({ + asset: op.asset, + limit: op.limit, + source: op.source, + }); + case "custom": + return op.operation; + } + }); + } +} + +// --------------------------------------------------------------------------- +// Batch Operation Executor +// --------------------------------------------------------------------------- + +export class BatchExecutor { + private readonly config: BatchExecutorConfig; + + constructor(config: BatchExecutorConfig) { + if (!StrKey.isValidEd25519PublicKey(config.sourceAddress)) { + throw new ValidationError(`Invalid source address: ${config.sourceAddress}`); + } + this.config = config; + } + + /** + * Build a single atomic Transaction containing all operations in the batch. + * + * @param batch - BatchOperationBuilder or array of BatchOperation + * @param sequenceNumber - Current source account sequence number (or string/bigint) + */ + buildTransaction( + batch: BatchOperationBuilder | BatchOperation[], + sequenceNumber: string | bigint | number = "0", + ): Transaction { + const builder = batch instanceof BatchOperationBuilder + ? batch + : new BatchOperationBuilder(); + + if (!(batch instanceof BatchOperationBuilder)) { + for (const op of batch) builder.add(op); + } + + // Sound validation upfront + builder.validate(); + + const xdrOps = builder.toXdrOperations(); + const opCount = xdrOps.length; + const baseFee = String(this.config.baseFee ?? BASE_FEE); + + const sourceAccount = new Account(this.config.sourceAddress, sequenceNumber.toString()); + + const tb = new TransactionBuilder(sourceAccount, { + fee: baseFee, + networkPassphrase: this.config.networkPassphrase, + }); + + for (const op of xdrOps) { + tb.addOperation(op); + } + + if (this.config.memo) { + tb.addMemo(this.config.memo); + } + + if (this.config.timebounds) { + tb.setTimeout(Math.max(1, this.config.timebounds.maxTime - Math.floor(Date.now() / 1000))); + } else { + tb.setTimeout(300); + } + + return tb.build(); + } + + /** + * Execute the batch atomically. Builds a single transaction, signs it, + * and submits it to the network. If any operation fails, the entire batch + * is aborted and rejected atomically. + */ + async execute( + batch: BatchOperationBuilder | BatchOperation[], + sequenceOverride?: string, + ): Promise { + const builder = batch instanceof BatchOperationBuilder + ? batch + : new BatchOperationBuilder(); + + if (!(batch instanceof BatchOperationBuilder)) { + for (const op of batch) builder.add(op); + } + + builder.validate(); + + let seq = sequenceOverride; + if (!seq && this.config.server?.getAccount) { + const acc = await this.config.server.getAccount(this.config.sourceAddress); + seq = acc.sequenceNumber(); + } + + const tx = this.buildTransaction(builder, seq ?? "1"); + + // Sign transaction if a signer was configured + if (this.config.signer) { + if (this.config.signer instanceof Keypair) { + tx.sign(this.config.signer); + } else if (typeof this.config.signer === "string") { + const kp = Keypair.fromSecret(this.config.signer); + tx.sign(kp); + } else if (typeof this.config.signer === "function") { + const signedXdr = await this.config.signer(tx.toXDR()); + const signedTx = TransactionBuilder.fromXDR(signedXdr, this.config.networkPassphrase) as Transaction; + return { + success: true, + txHash: signedTx.hash().toString("hex"), + operationCount: builder.size(), + feeStroops: tx.fee, + atomic: true, + envelopeXdr: signedXdr, + }; + } + } + + // Simulate or send if server provided + if (this.config.server?.sendTransaction) { + const sendResult = await this.config.server.sendTransaction(tx); + if (sendResult.status === "ERROR") { + throw new TransactionFailedError( + `Batch transaction failed atomically: ${JSON.stringify(sendResult.errorResult)}`, + ); + } + return { + success: true, + txHash: sendResult.hash, + operationCount: builder.size(), + feeStroops: tx.fee, + atomic: true, + envelopeXdr: tx.toXDR(), + }; + } + + return { + success: true, + txHash: tx.hash().toString("hex"), + operationCount: builder.size(), + feeStroops: tx.fee, + atomic: true, + envelopeXdr: tx.toXDR(), + }; + } +} diff --git a/src/currencyConverter.ts b/src/currencyConverter.ts index a646dae..391d5ba 100644 --- a/src/currencyConverter.ts +++ b/src/currencyConverter.ts @@ -1,8 +1,11 @@ /** - * Display-only currency converter using the contract's price oracle. + * Currency Conversion Utilities for StellarSplit SDK. * - * This module is purely for UI display purposes — it does NOT affect - * payment amounts or interact with pay() calculations. + * Provides utilities for converting between different currencies and tokens, + * supporting oracle exchange rates, cross-currency conversion, rigorous validation, + * and high-performance TTL caching. + * + * Issue #1031 */ import { @@ -13,9 +16,13 @@ import { nativeToScVal, scValToNative, } from "@stellar/stellar-sdk"; -import { OraclePriceError, NoReturnValueError } from "./errors.js"; +import { OraclePriceError, NoReturnValueError, ValidationError } from "./errors.js"; import { RateCache } from "./rateCache.js"; +// --------------------------------------------------------------------------- +// Types & Interfaces +// --------------------------------------------------------------------------- + export interface ConvertedAmount { original: bigint; converted: bigint; @@ -24,9 +31,319 @@ export interface ConvertedAmount { toDisplayCurrency: string; } -const DEFAULT_CACHE_TTL_MS = 10_000; +/** Result of converting a fiat-denominated invoice amount to an on-chain asset. */ +export interface FiatConversion { + /** The requested fiat amount. */ + fiatAmount: number; + /** The fiat currency code the amount was denominated in (e.g. "USD"). */ + fiatCurrency: string; + /** The equivalent amount in the target asset. */ + assetAmount: number; + /** The asset code the amount was converted to (e.g. "XLM"). */ + assetCode: string; + /** The base/quote rate used for the conversion (units of `fiatCurrency` per 1 `assetCode`). */ + rate: number; +} + +/** Pluggable adapter interface for price oracles. */ +export interface PriceOracleAdapter { + getPrice(base: string, quote: string): Promise; +} + +export type ExchangeRateProvider = (from: string, to: string) => Promise; + +export interface ConversionRequest { + amount: number | bigint; + from: string; + to: string; + rate?: number; + precision?: number; +} + +export interface ConversionResult { + amount: number; + convertedAmount: number; + from: string; + to: string; + rate: number; + timestamp: number; + cached: boolean; +} + +export interface CurrencyConverterOptions { + /** Default cache TTL for exchange rates in milliseconds. Default: 60,000ms. */ + defaultTtlMs?: number; + /** External rate provider (oracle or API). */ + rateProvider?: ExchangeRateProvider | PriceOracleAdapter; + /** Base currency for triangulation when direct pair is missing (e.g., "USD" or "XLM"). */ + triangulationCurrency?: string; +} + +interface CachedRateEntry { + rate: number; + expiresAt: number; +} + +// --------------------------------------------------------------------------- +// CurrencyConverter Class +// --------------------------------------------------------------------------- + +export class CurrencyConverter { + private readonly defaultTtlMs: number; + private readonly rateProvider?: ExchangeRateProvider; + private readonly triangulationCurrency: string; + private readonly cache = new Map(); + private cacheHits = 0; + private cacheMisses = 0; + + constructor(options: CurrencyConverterOptions = {}) { + this.defaultTtlMs = options.defaultTtlMs ?? 60_000; + this.triangulationCurrency = options.triangulationCurrency?.toUpperCase() ?? "USD"; + + if (options.rateProvider) { + if (typeof options.rateProvider === "function") { + this.rateProvider = options.rateProvider; + } else if (typeof (options.rateProvider as PriceOracleAdapter).getPrice === "function") { + const adapter = options.rateProvider as PriceOracleAdapter; + this.rateProvider = (from, to) => adapter.getPrice(from, to); + } + } + } + + /** + * Set or pre-seed an exchange rate for a currency pair with an optional custom TTL. + */ + setRate(from: string, to: string, rate: number, ttlMs?: number): this { + const normFrom = this.validateCurrency(from, "from"); + const normTo = this.validateCurrency(to, "to"); + this.validateRate(rate); + + const ttl = ttlMs ?? this.defaultTtlMs; + const expiresAt = Date.now() + Math.max(1, ttl); + this.cache.set(this.pairKey(normFrom, normTo), { rate, expiresAt }); + return this; + } + + /** + * Look up a rate from cache without invoking the remote rate provider. + */ + getCachedRate(from: string, to: string): number | undefined { + const key = this.pairKey(from.toUpperCase().trim(), to.toUpperCase().trim()); + const entry = this.cache.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return undefined; + } + return entry.rate; + } + + /** + * Resolve exchange rate between `from` and `to`. + * Checks direct pair -> inverse pair -> rateProvider -> triangulation. + */ + async getRate(from: string, to: string): Promise<{ rate: number; cached: boolean }> { + const normFrom = this.validateCurrency(from, "from"); + const normTo = this.validateCurrency(to, "to"); + + if (normFrom === normTo) { + return { rate: 1.0, cached: true }; + } + + const key = this.pairKey(normFrom, normTo); + const cachedEntry = this.cache.get(key); + if (cachedEntry && Date.now() <= cachedEntry.expiresAt) { + this.cacheHits++; + return { rate: cachedEntry.rate, cached: true }; + } + + // Check inverse rate in cache + const inverseKey = this.pairKey(normTo, normFrom); + const inverseEntry = this.cache.get(inverseKey); + if (inverseEntry && Date.now() <= inverseEntry.expiresAt && inverseEntry.rate > 0) { + this.cacheHits++; + const derivedRate = 1 / inverseEntry.rate; + return { rate: derivedRate, cached: true }; + } + + this.cacheMisses++; + + if (!this.rateProvider) { + throw new OraclePriceError(`No exchange rate found for pair ${normFrom}/${normTo} and no rate provider configured.`); + } + + // Try fetching direct pair + try { + const fetchedRate = await this.rateProvider(normFrom, normTo); + this.validateRate(fetchedRate); + this.setRate(normFrom, normTo, fetchedRate); + return { rate: fetchedRate, cached: false }; + } catch (err) { + // Try fetching inverse pair + try { + const inverseRate = await this.rateProvider(normTo, normFrom); + this.validateRate(inverseRate); + const derivedRate = 1 / inverseRate; + this.setRate(normFrom, normTo, derivedRate); + return { rate: derivedRate, cached: false }; + } catch { + // Try triangulation via base currency if neither was the base currency + if (normFrom !== this.triangulationCurrency && normTo !== this.triangulationCurrency) { + try { + const { rate: rateFromBase } = await this.getRate(normFrom, this.triangulationCurrency); + const { rate: rateBaseTo } = await this.getRate(this.triangulationCurrency, normTo); + const triangulatedRate = rateFromBase * rateBaseTo; + this.setRate(normFrom, normTo, triangulatedRate); + return { rate: triangulatedRate, cached: false }; + } catch { + // triangulation also failed, propagate original error + } + } + throw err instanceof Error ? err : new OraclePriceError(`Failed to fetch exchange rate for ${normFrom}/${normTo}`); + } + } + } + + /** + * Convert an amount from one currency/token to another. + */ + async convert(request: ConversionRequest): Promise { + const { from, to, precision } = request; + const normFrom = this.validateCurrency(from, "from"); + const normTo = this.validateCurrency(to, "to"); + const numAmount = this.validateAmount(request.amount); -/** One `RateCache` per oracle address, so entries survive across calls. */ + let rate: number; + let cached = false; + + if (request.rate !== undefined) { + this.validateRate(request.rate); + rate = request.rate; + cached = false; + } else { + const rateInfo = await this.getRate(normFrom, normTo); + rate = rateInfo.rate; + cached = rateInfo.cached; + } + + let convertedAmount = numAmount * rate; + if (precision !== undefined) { + if (!Number.isInteger(precision) || precision < 0) { + throw new ValidationError("precision must be a non-negative integer", { precision }); + } + convertedAmount = Number(convertedAmount.toFixed(precision)); + } + + return { + amount: numAmount, + convertedAmount, + from: normFrom, + to: normTo, + rate, + timestamp: Date.now(), + cached, + }; + } + + /** + * Convert a batch of conversion requests concurrently. + */ + async batchConvert(requests: ConversionRequest[]): Promise { + if (!Array.isArray(requests)) { + throw new ValidationError("requests must be an array"); + } + return Promise.all(requests.map((req) => this.convert(req))); + } + + /** + * Invalidate a single pair from cache. + */ + invalidate(from: string, to: string): void { + const key = this.pairKey(from.toUpperCase().trim(), to.toUpperCase().trim()); + this.cache.delete(key); + } + + /** + * Invalidate all cached exchange rates. + */ + clearCache(): void { + this.cache.clear(); + this.cacheHits = 0; + this.cacheMisses = 0; + } + + /** + * Get cache hit and miss statistics. + */ + getCacheStats(): { size: number; hits: number; misses: number } { + return { + size: this.cache.size, + hits: this.cacheHits, + misses: this.cacheMisses, + }; + } + + private pairKey(from: string, to: string): string { + return `${from}:${to}`; + } + + private validateCurrency(code: unknown, field: string): string { + if (typeof code !== "string" || code.trim().length === 0) { + throw new ValidationError(`Invalid currency identifier for '${field}': must be a non-empty string`, { [field]: code }); + } + return code.trim().toUpperCase(); + } + + private validateAmount(amount: unknown): number { + if (typeof amount === "bigint") { + if (amount < 0n) { + throw new ValidationError("Amount cannot be negative", { amount: amount.toString() }); + } + return Number(amount); + } + if (typeof amount !== "number" || !Number.isFinite(amount) || Number.isNaN(amount)) { + throw new ValidationError("Amount must be a finite number", { amount }); + } + if (amount < 0) { + throw new ValidationError("Amount cannot be negative", { amount }); + } + return amount; + } + + private validateRate(rate: unknown): void { + if (typeof rate !== "number" || !Number.isFinite(rate) || Number.isNaN(rate) || rate <= 0) { + throw new ValidationError(`Exchange rate must be a positive number, got: ${rate}`, { rate }); + } + } +} + +// --------------------------------------------------------------------------- +// Convenience functional utilities +// --------------------------------------------------------------------------- + +/** + * Pure arithmetic currency converter. + */ +export function convertCurrency( + amount: number, + rate: number, + precision?: number, +): number { + if (typeof amount !== "number" || !Number.isFinite(amount) || amount < 0) { + throw new ValidationError("amount must be a non-negative finite number", { amount }); + } + if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) { + throw new ValidationError("rate must be a positive finite number", { rate }); + } + const result = amount * rate; + return precision !== undefined ? Number(result.toFixed(precision)) : result; +} + +// --------------------------------------------------------------------------- +// Legacy display-only oracle conversion (backward-compatibility) +// --------------------------------------------------------------------------- + +const DEFAULT_CACHE_TTL_MS = 10_000; const rateCachesByOracle = new Map>(); function getRateCache( @@ -117,25 +434,6 @@ export async function convertAmount( }; } -/** Result of converting a fiat-denominated invoice amount to an on-chain asset. */ -export interface FiatConversion { - /** The requested fiat amount. */ - fiatAmount: number; - /** The fiat currency code the amount was denominated in (e.g. "USD"). */ - fiatCurrency: string; - /** The equivalent amount in the target asset. */ - assetAmount: number; - /** The asset code the amount was converted to (e.g. "XLM"). */ - assetCode: string; - /** The base/quote rate used for the conversion (units of `fiatCurrency` per 1 `assetCode`). */ - rate: number; -} - -/** - * Convert a fiat-denominated amount into its equivalent in `assetCode` using - * a pluggable {@link PriceOracleAdapter} (see priceOracle.ts for the default - * CoinGecko-backed implementation). Display-only, like the rest of this module. - */ export async function convertFiatToAsset( fiatAmount: number, fiatCurrency: string, diff --git a/src/index.ts b/src/index.ts index bf64e06..3cb9c23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1714,3 +1714,67 @@ export type { RetirementManagerOptions, RetirementEventMap, } from "./invoiceRetirement.js"; + +// Request timeout and retry logic (#1030) +export { + RequestRetryManager, + RequestTimeoutError, + MaxRetriesExceededError, + calculateBackoffDelay, + isRetryableError, + executeWithTimeoutAndRetry, +} from "./requestRetry.js"; +export type { + BackoffOptions, + RetryLimits, + RequestTimeoutConfig, + RequestRetryConfig, +} from "./requestRetry.js"; + +// Currency conversion utilities (#1031) +export { + CurrencyConverter, + convertCurrency, + convertAmount, + clearPriceCache, +} from "./currencyConverter.js"; +export type { + ConversionRequest, + ConversionResult, + CurrencyConverterOptions, + ExchangeRateProvider, +} from "./currencyConverter.js"; + +// Batch operation support (#1032) +export { + BatchOperationBuilder, + BatchExecutor, + validateBatchOperation, + validateBatchOperations, + MAX_OPERATIONS_PER_BATCH, +} from "./batchOperations.js"; +export type { + BatchOperation, + PaymentBatchOp, + CreateAccountBatchOp, + ManageDataBatchOp, + ChangeTrustBatchOp, + CustomBatchOp, + BatchExecutionResult, + BatchExecutorConfig, +} from "./batchOperations.js"; + +// Offline transaction building (#1033) +export { + OfflineTransactionBuilder, +} from "./offlineTransaction.js"; +export type { + OfflineTransactionConfig, + OfflinePaymentParams, + OfflineCreateAccountParams, + OfflineManageDataParams, + OfflineChangeTrustParams, + OfflineTxPackage, + TimeboundsConfig, + OfflineMemoInput, +} from "./offlineTransaction.js"; diff --git a/src/offlineTransaction.ts b/src/offlineTransaction.ts new file mode 100644 index 0000000..14e34f7 --- /dev/null +++ b/src/offlineTransaction.ts @@ -0,0 +1,468 @@ +/** + * Offline Transaction Building and Signing for StellarSplit SDK. + * + * Supports air-gapped / offline transaction composition, Ed25519 signing, + * multi-signature collection, and lossless round-trip serialization. + * Operates with zero network connectivity or external dependencies. + * + * Issue #1033 + */ + +import { + Account, + Asset, + BASE_FEE, + Keypair, + Memo, + Operation, + StrKey, + Transaction, + TransactionBuilder, + xdr, +} from "@stellar/stellar-sdk"; +import { ValidationError } from "./errors.js"; + +// --------------------------------------------------------------------------- +// Types & Config +// --------------------------------------------------------------------------- + +export interface TimeboundsConfig { + minTime?: number; + maxTime: number; +} + +export type OfflineMemoInput = + | Memo + | { type: "text"; value: string } + | { type: "id"; value: string | bigint } + | { type: "hash" | "return"; value: Buffer | string }; + +export interface OfflineTransactionConfig { + /** Source account G... public key. */ + sourceAddress: string; + /** + * Account sequence number before this transaction executes. + * The built transaction will have sequence = sequenceNumber + 1. + */ + sequenceNumber: string | bigint | number; + /** Stellar network passphrase (e.g., Networks.PUBLIC or Networks.TESTNET). */ + networkPassphrase: string; + /** Base fee in stroops per operation. Default: BASE_FEE (100 stroops). */ + baseFee?: number | string; + /** Optional timebounds (in Unix seconds). */ + timebounds?: TimeboundsConfig | number; + /** Optional transaction memo. */ + memo?: OfflineMemoInput; +} + +export interface OfflinePaymentParams { + destination: string; + amount: string | number | bigint; + asset?: Asset | string; + source?: string; +} + +export interface OfflineCreateAccountParams { + destination: string; + startingBalance: string | number | bigint; + source?: string; +} + +export interface OfflineManageDataParams { + name: string; + value: string | Buffer | Uint8Array | null; + source?: string; +} + +export interface OfflineChangeTrustParams { + asset: Asset; + limit?: string; + source?: string; +} + +/** + * Transport package for sharing built/signed offline transactions across air-gapped boundaries. + */ +export interface OfflineTxPackage { + version: 1; + xdr: string; + networkPassphrase: string; + txHash: string; + sourceAddress: string; + sequenceNumber: string; + fee: string; + operationCount: number; + signatures: Array<{ hint: string; signature: string }>; + isSigned: boolean; + createdAt: number; +} + +// --------------------------------------------------------------------------- +// OfflineTransactionBuilder +// --------------------------------------------------------------------------- + +export class OfflineTransactionBuilder { + private readonly config: OfflineTransactionConfig; + private readonly operations: xdr.Operation[] = []; + private _transaction: Transaction | null = null; + + constructor(config: OfflineTransactionConfig) { + if (!StrKey.isValidEd25519PublicKey(config.sourceAddress)) { + throw new ValidationError(`Invalid source address for offline builder: ${config.sourceAddress}`); + } + if (!config.networkPassphrase || typeof config.networkPassphrase !== "string") { + throw new ValidationError("networkPassphrase is required for offline builder."); + } + this.config = config; + } + + /** Add a payment operation offline. */ + addPayment(params: OfflinePaymentParams): this { + this.invalidateBuiltTx(); + if (!StrKey.isValidEd25519PublicKey(params.destination)) { + throw new ValidationError(`Invalid destination for offline payment: ${params.destination}`); + } + const numAmt = Number(params.amount); + if (!Number.isFinite(numAmt) || numAmt <= 0) { + throw new ValidationError(`Payment amount must be positive, got: ${params.amount}`); + } + + const asset = typeof params.asset === "string" + ? (params.asset === "native" || params.asset === "XLM" ? Asset.native() : new Asset(params.asset, params.source ?? this.config.sourceAddress)) + : (params.asset ?? Asset.native()); + + this.operations.push( + Operation.payment({ + destination: params.destination, + asset, + amount: params.amount.toString(), + source: params.source, + }), + ); + return this; + } + + /** Add a createAccount operation offline. */ + addCreateAccount(params: OfflineCreateAccountParams): this { + this.invalidateBuiltTx(); + if (!StrKey.isValidEd25519PublicKey(params.destination)) { + throw new ValidationError(`Invalid destination for offline createAccount: ${params.destination}`); + } + const numBal = Number(params.startingBalance); + if (!Number.isFinite(numBal) || numBal <= 0) { + throw new ValidationError(`Starting balance must be positive, got: ${params.startingBalance}`); + } + + this.operations.push( + Operation.createAccount({ + destination: params.destination, + startingBalance: params.startingBalance.toString(), + source: params.source, + }), + ); + return this; + } + + /** Add a manageData operation offline. */ + addManageData(params: OfflineManageDataParams): this { + this.invalidateBuiltTx(); + if (!params.name || params.name.length === 0) { + throw new ValidationError("manageData name cannot be empty"); + } + if (Buffer.byteLength(params.name, "utf8") > 64) { + throw new ValidationError("manageData name exceeds 64 bytes"); + } + + this.operations.push( + Operation.manageData({ + name: params.name, + value: params.value, + source: params.source, + }), + ); + return this; + } + + /** Add a changeTrust operation offline. */ + addChangeTrust(params: OfflineChangeTrustParams): this { + this.invalidateBuiltTx(); + if (!params.asset) { + throw new ValidationError("changeTrust requires an asset"); + } + + this.operations.push( + Operation.changeTrust({ + asset: params.asset, + limit: params.limit, + source: params.source, + }), + ); + return this; + } + + /** Add a raw xdr.Operation offline. */ + addOperation(operation: xdr.Operation): this { + this.invalidateBuiltTx(); + if (!operation) { + throw new ValidationError("Cannot add undefined or null operation"); + } + this.operations.push(operation); + return this; + } + + /** Number of operations added so far. */ + getOperationCount(): number { + return this.operations.length; + } + + /** + * Build the Transaction instance completely offline. + * If already built and no operations have changed, returns the cached transaction. + */ + build(): Transaction { + if (this._transaction) { + return this._transaction; + } + + if (this.operations.length === 0) { + throw new ValidationError("Cannot build offline transaction with 0 operations."); + } + if (this.operations.length > 100) { + throw new ValidationError(`Offline transaction contains ${this.operations.length} operations (max: 100).`); + } + + const sourceAccount = new Account( + this.config.sourceAddress, + this.config.sequenceNumber.toString(), + ); + + const baseFee = String(this.config.baseFee ?? BASE_FEE); + + const tb = new TransactionBuilder(sourceAccount, { + fee: baseFee, + networkPassphrase: this.config.networkPassphrase, + }); + + for (const op of this.operations) { + tb.addOperation(op); + } + + // Attach memo if provided + if (this.config.memo) { + const memo = this.resolveMemo(this.config.memo); + tb.addMemo(memo); + } + + // Attach timebounds if provided + if (this.config.timebounds) { + if (typeof this.config.timebounds === "number") { + tb.setTimeout(this.config.timebounds); + } else { + const minTime = this.config.timebounds.minTime ?? 0; + const maxTime = this.config.timebounds.maxTime; + tb.setTimeout(Math.max(1, maxTime - minTime)); + } + } else { + tb.setTimeout(300); // 5 minutes default + } + + this._transaction = tb.build(); + return this._transaction; + } + + /** + * Sign the offline transaction using an Ed25519 Keypair or secret seed string. + * Supports multiple successive calls for multi-signature transactions. + */ + sign(signer: Keypair | string): this { + const tx = this.build(); + if (typeof signer === "string") { + if (!StrKey.isValidEd25519SecretSeed(signer)) { + throw new ValidationError("Invalid secret key provided for offline signing."); + } + const kp = Keypair.fromSecret(signer); + tx.sign(kp); + } else if (signer instanceof Keypair) { + tx.sign(signer); + } else { + throw new ValidationError("Signer must be a Keypair or secret seed string."); + } + return this; + } + + /** + * Add a raw external signature to the built transaction. + */ + addSignature(publicKey: string, signature: Buffer | Uint8Array | string): this { + const tx = this.build(); + if (!StrKey.isValidEd25519PublicKey(publicKey)) { + throw new ValidationError(`Invalid public key for signature hint: ${publicKey}`); + } + + const sigBuffer = typeof signature === "string" + ? Buffer.from(signature, "hex") + : Buffer.from(signature); + + const keypair = Keypair.fromPublicKey(publicKey); + const hint = keypair.signatureHint(); + + const decSig = new xdr.DecoratedSignature({ + hint, + signature: sigBuffer, + }); + + tx.signatures.push(decSig); + return this; + } + + /** + * Check whether all attached signatures are cryptographically valid + * for the given public keys against the offline transaction hash. + */ + verifySignatures(expectedSignerPublicKeys?: string[]): boolean { + const tx = this.build(); + if (tx.signatures.length === 0) { + return false; + } + + const txHash = tx.hash(); + + if (expectedSignerPublicKeys && expectedSignerPublicKeys.length > 0) { + for (const pk of expectedSignerPublicKeys) { + const kp = Keypair.fromPublicKey(pk); + const hint = kp.signatureHint(); + const matchingSig = tx.signatures.find( + (s) => s.hint().toString("hex") === hint.toString("hex"), + ); + if (!matchingSig) { + return false; + } + const isValid = kp.verify(txHash, matchingSig.signature()); + if (!isValid) { + return false; + } + } + return true; + } + + // Verify all attached signatures match non-empty signature buffers + return tx.signatures.every((s) => s.signature().length === 64); + } + + /** + * Get public key signature hints for all signers that have signed this transaction. + */ + getSignatureHints(): string[] { + const tx = this.build(); + return tx.signatures.map((s) => s.hint().toString("hex")); + } + + /** + * Get transaction hash in hexadecimal string format. + */ + getTransactionHash(): string { + return this.build().hash().toString("hex"); + } + + /** + * Serialize transaction to standard base64-encoded XDR envelope. + */ + toXDR(): string { + return this.build().toXDR(); + } + + /** + * Serialize transaction and all offline metadata into an `OfflineTxPackage` + * for reliable transfer across air-gapped systems. + */ + serializePackage(): OfflineTxPackage { + const tx = this.build(); + const signatures = tx.signatures.map((s) => ({ + hint: s.hint().toString("hex"), + signature: s.signature().toString("base64"), + })); + + return { + version: 1, + xdr: tx.toXDR(), + networkPassphrase: this.config.networkPassphrase, + txHash: tx.hash().toString("hex"), + sourceAddress: tx.source, + sequenceNumber: tx.sequence, + fee: tx.fee.toString(), + operationCount: tx.operations.length, + signatures, + isSigned: signatures.length > 0, + createdAt: Date.now(), + }; + } + + /** + * Serialize to JSON string. + */ + serialize(): string { + return JSON.stringify(this.serializePackage()); + } + + /** + * Reconstitute an OfflineTransactionBuilder from base64 XDR and passphrase. + */ + static fromXDR( + xdrString: string, + networkPassphrase: string, + ): OfflineTransactionBuilder { + if (!xdrString || typeof xdrString !== "string") { + throw new ValidationError("xdrString must be a non-empty string"); + } + const tx = TransactionBuilder.fromXDR(xdrString, networkPassphrase) as Transaction; + + const builder = new OfflineTransactionBuilder({ + sourceAddress: tx.source, + sequenceNumber: BigInt(tx.sequence) - 1n, + networkPassphrase, + baseFee: Number(tx.fee) / Math.max(1, tx.operations.length), + }); + + builder._transaction = tx; + const envelope = tx.toEnvelope(); + const rawOps: xdr.Operation[] = + envelope.switch().name === "envelopeTypeTxV0" + ? envelope.v0().tx().operations() + : envelope.v1().tx().operations(); + for (const op of rawOps) { + builder.operations.push(op); + } + return builder; + } + + /** + * Reconstitute an OfflineTransactionBuilder from an OfflineTxPackage or JSON string. + */ + static deserialize(payload: string | OfflineTxPackage): OfflineTransactionBuilder { + const pkg: OfflineTxPackage = typeof payload === "string" ? JSON.parse(payload) : payload; + if (pkg.version !== 1) { + throw new ValidationError(`Unsupported package version: ${pkg.version}`); + } + return OfflineTransactionBuilder.fromXDR(pkg.xdr, pkg.networkPassphrase); + } + + private resolveMemo(memoInput: OfflineMemoInput): Memo { + if (memoInput instanceof Memo) return memoInput; + switch (memoInput.type) { + case "text": + return Memo.text(memoInput.value); + case "id": + return Memo.id(memoInput.value.toString()); + case "hash": + return Memo.hash(memoInput.value); + case "return": + return Memo.return(memoInput.value); + default: + throw new ValidationError("Unsupported memo type"); + } + } + + private invalidateBuiltTx(): void { + this._transaction = null; + } +} diff --git a/src/requestRetry.ts b/src/requestRetry.ts new file mode 100644 index 0000000..fe9a4ec --- /dev/null +++ b/src/requestRetry.ts @@ -0,0 +1,298 @@ +/** + * Request Timeout and Retry Logic with Exponential Backoff. + * + * Implements configurable per-request timeouts (enforced via AbortController), + * exponential backoff with jitter, retry limits, and robust error classification. + * + * Issue #1030 + */ + +export interface BackoffOptions { + /** Initial delay before first retry in milliseconds. Default: 500ms. */ + baseDelayMs?: number; + /** Maximum delay cap between retries in milliseconds. Default: 10,000ms. */ + maxDelayMs?: number; + /** Multiplier for each subsequent attempt. Default: 2. */ + backoffMultiplier?: number; + /** + * Jitter configuration. + * If true: full random jitter between 0 and calculated delay. + * If number in [0, 1]: proportional jitter (delay +/- jitter * delay). + * If false: no jitter (deterministic). + * Default: true. + */ + jitter?: boolean | number; +} + +export interface RetryLimits { + /** Maximum number of retries after initial attempt. Default: 3. */ + maxRetries?: number; + /** Total maximum attempts (initial + retries). Overrides maxRetries if provided. */ + maxAttempts?: number; +} + +export interface RequestTimeoutConfig { + /** Default timeout in milliseconds for requests. Default: 10,000ms. */ + defaultTimeoutMs?: number; + /** Per-method timeout overrides in milliseconds. */ + methodTimeouts?: Record; +} + +export interface RequestRetryConfig extends BackoffOptions, RetryLimits, RequestTimeoutConfig { + /** Method name for per-method timeout/logging resolution. */ + methodName?: string; + /** Custom predicate to determine whether an error should trigger a retry. */ + retryIf?: (error: unknown, attempt: number) => boolean; + /** Callback fired prior to waiting for a retry delay. */ + onRetry?: (attempt: number, error: unknown, delayMs: number) => void; + /** Callback fired when a request attempt times out. */ + onTimeout?: (attempt: number, timeoutMs: number, method?: string) => void; +} + +/** + * Thrown when an operation exceeds its configured deadline. + */ +export class RequestTimeoutError extends Error { + readonly code = "REQUEST_TIMEOUT"; + readonly method?: string; + readonly timeoutMs: number; + + constructor(timeoutMs: number, method?: string) { + const msg = method + ? `Request timed out after ${timeoutMs}ms (method: ${method})` + : `Request timed out after ${timeoutMs}ms`; + super(msg); + this.name = "RequestTimeoutError"; + this.timeoutMs = timeoutMs; + this.method = method; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Thrown when an operation has exhausted all configured retry attempts. + */ +export class MaxRetriesExceededError extends Error { + readonly code = "MAX_RETRIES_EXCEEDED"; + readonly attempts: number; + readonly lastError: unknown; + + constructor(attempts: number, lastError: unknown) { + const reason = lastError instanceof Error ? lastError.message : String(lastError); + super(`Operation failed after ${attempts} attempts. Last error: ${reason}`); + this.name = "MaxRetriesExceededError"; + this.attempts = attempts; + this.lastError = lastError; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Calculate delay using exponential backoff with optional jitter. + */ +export function calculateBackoffDelay( + attempt: number, + options: BackoffOptions = {}, +): number { + const base = Math.max(0, options.baseDelayMs ?? 500); + const max = Math.max(base, options.maxDelayMs ?? 10_000); + const multiplier = Math.max(1, options.backoffMultiplier ?? 2); + const jitter = options.jitter ?? true; + + // attempt is 1-based retry count (attempt 1 = first retry after initial failure) + const expFactor = Math.pow(multiplier, Math.max(0, attempt - 1)); + const rawDelay = Math.min(base * expFactor, max); + + if (jitter === false) { + return Math.floor(rawDelay); + } + + if (typeof jitter === "number") { + const factor = Math.max(0, Math.min(1, jitter)); + const variation = (Math.random() * 2 - 1) * factor * rawDelay; + return Math.floor(Math.min(max, Math.max(0, rawDelay + variation))); + } + + // Full random jitter: random between 0 and rawDelay + return Math.floor(Math.random() * rawDelay); +} + +/** + * Determine if an error is considered transient and retryable. + */ +export function isRetryableError(error: unknown): boolean { + if (!error) return false; + + if (error instanceof RequestTimeoutError) return true; + + if (typeof error === "object") { + const rec = error as Record; + + // HTTP status codes + const status = Number(rec.status ?? rec.statusCode ?? rec.code); + if ([408, 429, 500, 502, 503, 504].includes(status)) { + return true; + } + + // Client/validation errors are NOT retryable + if ([400, 401, 403, 404, 409, 422].includes(status)) { + return false; + } + } + + if (error instanceof Error) { + if (error.name === "AbortError") return true; + + const msg = error.message.toLowerCase(); + // Non-retryable indicators + if ( + msg.includes("validation") || + msg.includes("not found") || + msg.includes("unauthorized") || + msg.includes("forbidden") || + msg.includes("invalid argument") + ) { + return false; + } + + // Transient / network error indicators + return /timeout|timed out|network|connection|connect|econnreset|econnrefused|enotfound|eai_again|etimedout|socket hang up|fetch failed|rate limit|too many requests|503|502|504|429/i.test( + msg, + ); + } + + return false; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Execute an asynchronous operation with an individual timeout per attempt + * and exponential backoff retry logic. + */ +export async function executeWithTimeoutAndRetry( + fn: (signal: AbortSignal) => Promise, + config: RequestRetryConfig = {}, +): Promise { + const maxAttempts = config.maxAttempts ?? ((config.maxRetries ?? 3) + 1); + const defaultTimeout = config.defaultTimeoutMs ?? 10_000; + const timeoutMs = config.methodName && config.methodTimeouts?.[config.methodName] !== undefined + ? config.methodTimeouts[config.methodName]! + : defaultTimeout; + + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const controller = new AbortController(); + let timer: ReturnType | null = null; + let didTimeout = false; + + const timeoutPromise = new Promise((_, reject) => { + if (timeoutMs > 0 && timeoutMs < Number.POSITIVE_INFINITY) { + timer = setTimeout(() => { + didTimeout = true; + controller.abort(); + config.onTimeout?.(attempt, timeoutMs, config.methodName); + reject(new RequestTimeoutError(timeoutMs, config.methodName)); + }, timeoutMs); + } + }); + + try { + const result = await Promise.race([fn(controller.signal), timeoutPromise]); + if (timer) clearTimeout(timer); + return result; + } catch (err) { + if (timer) clearTimeout(timer); + lastError = err; + + const isLastAttempt = attempt >= maxAttempts; + if (isLastAttempt) { + break; + } + + // Check retry eligibility + let shouldRetry = isRetryableError(err); + if (config.retryIf) { + shouldRetry = config.retryIf(err, attempt); + } + + if (!shouldRetry) { + throw err; + } + + // Calculate backoff delay + const delayMs = calculateBackoffDelay(attempt, config); + config.onRetry?.(attempt, err, delayMs); + + if (delayMs > 0) { + await sleep(delayMs); + } + } + } + + // Tag exhausted error if possible + if (lastError && typeof lastError === "object") { + try { + (lastError as Record).retryExhausted = true; + } catch { + // ignore sealed/frozen objects + } + } + + throw new MaxRetriesExceededError(maxAttempts, lastError); +} + +/** + * Manager class for configuring and coordinating request timeouts and retries across an application. + */ +export class RequestRetryManager { + private readonly config: RequestRetryConfig; + + constructor(config: RequestRetryConfig = {}) { + this.config = { + defaultTimeoutMs: 10_000, + maxRetries: 3, + baseDelayMs: 500, + maxDelayMs: 10_000, + backoffMultiplier: 2, + jitter: true, + ...config, + }; + } + + /** + * Get the configured timeout for a method name. + */ + getTimeout(methodName?: string): number { + if (methodName && this.config.methodTimeouts?.[methodName] !== undefined) { + return this.config.methodTimeouts[methodName]!; + } + return this.config.defaultTimeoutMs ?? 10_000; + } + + /** + * Get retry limits. + */ + getLimits(): { maxRetries: number; maxAttempts: number } { + const maxRetries = this.config.maxRetries ?? 3; + const maxAttempts = this.config.maxAttempts ?? (maxRetries + 1); + return { maxRetries, maxAttempts }; + } + + /** + * Execute an operation with this manager's configuration, optionally merged with per-call overrides. + */ + async execute( + fn: (signal: AbortSignal) => Promise, + overrides?: Partial | string, + ): Promise { + const mergedConfig: RequestRetryConfig = typeof overrides === "string" + ? { ...this.config, methodName: overrides } + : { ...this.config, ...overrides }; + + return executeWithTimeoutAndRetry(fn, mergedConfig); + } +} diff --git a/test/batchOperations.test.ts b/test/batchOperations.test.ts new file mode 100644 index 0000000..c221331 --- /dev/null +++ b/test/batchOperations.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi } from "vitest"; +import { Keypair, Asset, Networks } from "@stellar/stellar-sdk"; +import { + BatchOperationBuilder, + BatchExecutor, + validateBatchOperations, + MAX_OPERATIONS_PER_BATCH, +} from "../src/batchOperations.js"; +import { ValidationError, TransactionFailedError } from "../src/errors.js"; + +describe("BatchOperationBuilder (#1032)", () => { + it("chains multiple operation types into a batch", () => { + const kp1 = Keypair.random(); + const kp2 = Keypair.random(); + + const builder = new BatchOperationBuilder() + .addPayment({ destination: kp1.publicKey(), amount: "10" }) + .addCreateAccount({ destination: kp2.publicKey(), startingBalance: "2.5" }) + .addManageData({ name: "invoice_ref", value: "INV-123" }); + + expect(builder.size()).toBe(3); + const ops = builder.getOperations(); + expect(ops[0]?.type).toBe("payment"); + expect(ops[1]?.type).toBe("createAccount"); + expect(ops[2]?.type).toBe("manageData"); + }); + + it("converts operations to valid XDR operations", () => { + const kp = Keypair.random(); + const builder = new BatchOperationBuilder() + .addPayment({ destination: kp.publicKey(), amount: "5.5" }) + .addManageData({ name: "test_key", value: "test_val" }); + + const xdrOps = builder.toXdrOperations(); + expect(xdrOps).toHaveLength(2); + }); + + it("clears operations", () => { + const builder = new BatchOperationBuilder() + .addPayment({ destination: Keypair.random().publicKey(), amount: "1" }); + expect(builder.size()).toBe(1); + builder.clear(); + expect(builder.size()).toBe(0); + }); +}); + +describe("Batch Validation (#1032)", () => { + it("rejects an empty batch", () => { + expect(() => validateBatchOperations([])).toThrow(ValidationError); + }); + + it("rejects batches exceeding maximum operations limit (100)", () => { + const kp = Keypair.random(); + const ops = Array.from({ length: 101 }, () => ({ + type: "payment" as const, + destination: kp.publicKey(), + amount: "1", + })); + + expect(() => validateBatchOperations(ops)).toThrow(ValidationError); + expect(() => validateBatchOperations(ops)).toThrow( + new RegExp(`exceeds maximum limit of ${MAX_OPERATIONS_PER_BATCH}`), + ); + }); + + it("rejects invalid destination public keys", () => { + expect(() => + validateBatchOperations([ + { type: "payment", destination: "INVALID_G_KEY", amount: "10" }, + ]), + ).toThrow(ValidationError); + + expect(() => + validateBatchOperations([ + { type: "createAccount", destination: "GNOTVALID", startingBalance: "10" }, + ]), + ).toThrow(ValidationError); + }); + + it("rejects non-positive payment amounts", () => { + const kp = Keypair.random(); + expect(() => + validateBatchOperations([ + { type: "payment", destination: kp.publicKey(), amount: "0" }, + ]), + ).toThrow(ValidationError); + + expect(() => + validateBatchOperations([ + { type: "payment", destination: kp.publicKey(), amount: "-10" }, + ]), + ).toThrow(ValidationError); + }); + + it("rejects manageData keys longer than 64 bytes", () => { + const overlongName = "a".repeat(65); + expect(() => + validateBatchOperations([ + { type: "manageData", name: overlongName, value: "valid" }, + ]), + ).toThrow(ValidationError); + }); +}); + +describe("BatchExecutor (#1032)", () => { + const sourceKp = Keypair.random(); + const destKp1 = Keypair.random(); + const destKp2 = Keypair.random(); + + it("builds a single atomic transaction containing all batched operations", () => { + const executor = new BatchExecutor({ + sourceAddress: sourceKp.publicKey(), + networkPassphrase: Networks.TESTNET, + baseFee: 100, + }); + + const builder = new BatchOperationBuilder() + .addPayment({ destination: destKp1.publicKey(), amount: "10" }) + .addPayment({ destination: destKp2.publicKey(), amount: "20" }) + .addManageData({ name: "batch_id", value: "b-001" }); + + const tx = executor.buildTransaction(builder, "10"); + + // Stellar Transaction checks + expect(tx.operations).toHaveLength(3); + expect(tx.source).toBe(sourceKp.publicKey()); + expect(tx.sequence).toBe("11"); + // Fee = baseFee (100) * 3 operations = 300 stroops + expect(Number(tx.fee)).toBe(300); + }); + + it("signs and executes batch atomically", async () => { + const executor = new BatchExecutor({ + sourceAddress: sourceKp.publicKey(), + networkPassphrase: Networks.TESTNET, + signer: sourceKp, + }); + + const builder = new BatchOperationBuilder() + .addPayment({ destination: destKp1.publicKey(), amount: "5" }) + .addPayment({ destination: destKp2.publicKey(), amount: "15" }); + + const result = await executor.execute(builder, "42"); + + expect(result.success).toBe(true); + expect(result.atomic).toBe(true); + expect(result.operationCount).toBe(2); + expect(result.txHash).toBeDefined(); + expect(result.envelopeXdr).toBeDefined(); + }); + + it("submits transaction to server and returns hash on success", async () => { + const sendTransaction = vi.fn().mockResolvedValue({ + status: "PENDING", + hash: "batch_tx_hash_12345", + }); + + const executor = new BatchExecutor({ + sourceAddress: sourceKp.publicKey(), + networkPassphrase: Networks.TESTNET, + signer: sourceKp.secret(), + server: { sendTransaction }, + }); + + const builder = new BatchOperationBuilder() + .addPayment({ destination: destKp1.publicKey(), amount: "1" }); + + const result = await executor.execute(builder, "1"); + + expect(result.txHash).toBe("batch_tx_hash_12345"); + expect(result.atomic).toBe(true); + expect(sendTransaction).toHaveBeenCalledTimes(1); + }); + + it("throws TransactionFailedError atomically when server rejects submission", async () => { + const sendTransaction = vi.fn().mockResolvedValue({ + status: "ERROR", + errorResult: "tx_bad_seq", + }); + + const executor = new BatchExecutor({ + sourceAddress: sourceKp.publicKey(), + networkPassphrase: Networks.TESTNET, + signer: sourceKp, + server: { sendTransaction }, + }); + + const builder = new BatchOperationBuilder() + .addPayment({ destination: destKp1.publicKey(), amount: "1" }); + + await expect(executor.execute(builder, "1")).rejects.toThrow(TransactionFailedError); + }); +}); diff --git a/test/currencyConverter.test.ts b/test/currencyConverter.test.ts index df44693..51443b7 100644 --- a/test/currencyConverter.test.ts +++ b/test/currencyConverter.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { rpc as SorobanRpc } from "@stellar/stellar-sdk"; +import { + CurrencyConverter, + convertCurrency, + convertAmount, + convertFiatToAsset, + clearPriceCache, +} from "../src/currencyConverter.js"; +import { ValidationError } from "../src/errors.js"; vi.mock("@stellar/stellar-sdk", async () => { const actual = await vi.importActual("@stellar/stellar-sdk"); @@ -33,14 +41,12 @@ function mockServer(rate: bigint) { } as unknown as SorobanRpc.Server; } -describe("currencyConverter", () => { - beforeEach(async () => { - const mod = await import("../src/currencyConverter.js"); - mod.clearPriceCache(); +describe("currencyConverter (legacy oracle)", () => { + beforeEach(() => { + clearPriceCache(); }); it("converts amount using oracle rate", async () => { - const { convertAmount } = await import("../src/currencyConverter.js"); const rate = 2_000_000_000_000_000_000n; const server = mockServer(rate); @@ -56,7 +62,6 @@ describe("currencyConverter", () => { }); it("caches oracle price lookups within TTL", async () => { - const { convertAmount } = await import("../src/currencyConverter.js"); const rate = 1_500_000_000_000_000_000n; const server = mockServer(rate); @@ -67,7 +72,6 @@ describe("currencyConverter", () => { }); it("re-fetches when cache TTL expires", async () => { - const { convertAmount } = await import("../src/currencyConverter.js"); const rate = 1_000_000_000_000_000_000n; const server = mockServer(rate); @@ -78,7 +82,6 @@ describe("currencyConverter", () => { }); it("handles fractional conversion correctly", async () => { - const { convertAmount } = await import("../src/currencyConverter.js"); const rate = 500_000_000_000_000_000n; const server = mockServer(rate); @@ -87,7 +90,6 @@ describe("currencyConverter", () => { }); it("returns bigint zero for zero amount", async () => { - const { convertAmount } = await import("../src/currencyConverter.js"); const rate = 2_000_000_000_000_000_000n; const server = mockServer(rate); @@ -95,3 +97,178 @@ describe("currencyConverter", () => { expect(result.converted).toBe(0n); }); }); + +describe("convertFiatToAsset", () => { + it("converts fiat amount to crypto asset using oracle adapter", async () => { + const mockOracle = { + getPrice: vi.fn().mockResolvedValue(0.125), // 1 XLM = $0.125 + }; + + const res = await convertFiatToAsset(100, "USD", "XLM", mockOracle); + expect(res.fiatAmount).toBe(100); + expect(res.fiatCurrency).toBe("USD"); + expect(res.assetCode).toBe("XLM"); + expect(res.rate).toBe(0.125); + expect(res.assetAmount).toBe(800); // 100 / 0.125 = 800 XLM + }); + + it("throws OraclePriceError when oracle returns non-positive rate", async () => { + const mockOracle = { + getPrice: vi.fn().mockResolvedValue(0), + }; + await expect(convertFiatToAsset(100, "USD", "XLM", mockOracle)).rejects.toThrow(); + }); +}); + +describe("CurrencyConverter class (#1031)", () => { + it("converts with direct rate and respects precision", async () => { + const converter = new CurrencyConverter(); + converter.setRate("XLM", "USD", 0.125); + + const result = await converter.convert({ + amount: 400, + from: "XLM", + to: "USD", + precision: 2, + }); + + expect(result.amount).toBe(400); + expect(result.convertedAmount).toBe(50); + expect(result.rate).toBe(0.125); + expect(result.from).toBe("XLM"); + expect(result.to).toBe("USD"); + expect(result.cached).toBe(true); + }); + + it("handles identical from and to currency with 1.0 rate", async () => { + const converter = new CurrencyConverter(); + const result = await converter.convert({ + amount: 100, + from: "USDC", + to: "USDC", + }); + expect(result.convertedAmount).toBe(100); + expect(result.rate).toBe(1.0); + }); + + it("automatically derives inverse exchange rates", async () => { + const converter = new CurrencyConverter(); + converter.setRate("EUR", "USD", 1.10); // 1 EUR = 1.10 USD + + const result = await converter.convert({ + amount: 110, + from: "USD", + to: "EUR", + precision: 4, + }); + + expect(result.convertedAmount).toBeCloseTo(100, 2); + }); + + it("fetches rate using rateProvider and caches result within TTL", async () => { + const rateProvider = vi.fn().mockResolvedValue(0.15); + const converter = new CurrencyConverter({ rateProvider, defaultTtlMs: 5000 }); + + // First call fetches from provider + const res1 = await converter.convert({ amount: 100, from: "XLM", to: "USD" }); + expect(res1.convertedAmount).toBe(15); + expect(res1.cached).toBe(false); + expect(rateProvider).toHaveBeenCalledTimes(1); + + // Second call serves from cache + const res2 = await converter.convert({ amount: 200, from: "XLM", to: "USD" }); + expect(res2.convertedAmount).toBe(30); + expect(res2.cached).toBe(true); + expect(rateProvider).toHaveBeenCalledTimes(1); + + const stats = converter.getCacheStats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + }); + + it("supports triangulation across intermediate base currency", async () => { + const rateProvider = vi.fn().mockImplementation(async (from: string, to: string) => { + if (from === "XLM" && to === "USD") return 0.10; + if (from === "EUR" && to === "USD") return 1.20; + throw new Error(`Direct pair ${from}/${to} not found`); + }); + + const converter = new CurrencyConverter({ rateProvider, triangulationCurrency: "USD" }); + // Convert XLM to EUR via USD: 1 XLM = 0.10 USD, 1 EUR = 1.20 USD -> 1 XLM = 0.10 / 1.20 EUR = 0.08333 EUR + const res = await converter.convert({ + amount: 1200, + from: "XLM", + to: "EUR", + precision: 2, + }); + + expect(res.convertedAmount).toBeCloseTo(100, 1); + }); + + it("supports batch conversions", async () => { + const converter = new CurrencyConverter(); + converter.setRate("XLM", "USD", 0.10); + converter.setRate("BTC", "USD", 60000); + + const results = await converter.batchConvert([ + { amount: 100, from: "XLM", to: "USD" }, + { amount: 1, from: "BTC", to: "USD" }, + ]); + + expect(results).toHaveLength(2); + expect(results[0]?.convertedAmount).toBe(10); + expect(results[1]?.convertedAmount).toBe(60000); + }); + + it("invalidates and clears cache correctly", async () => { + const converter = new CurrencyConverter(); + converter.setRate("XLM", "USD", 0.10); + expect(converter.getCachedRate("XLM", "USD")).toBe(0.10); + + converter.invalidate("XLM", "USD"); + expect(converter.getCachedRate("XLM", "USD")).toBeUndefined(); + + converter.setRate("XLM", "USD", 0.10); + converter.setRate("EUR", "USD", 1.10); + expect(converter.getCacheStats().size).toBe(2); + + converter.clearCache(); + expect(converter.getCacheStats().size).toBe(0); + }); + + describe("Validation", () => { + const converter = new CurrencyConverter(); + + it("rejects negative amounts", async () => { + await expect(converter.convert({ amount: -5, from: "XLM", to: "USD", rate: 0.1 })).rejects.toThrow(ValidationError); + await expect(converter.convert({ amount: -1n, from: "XLM", to: "USD", rate: 0.1 })).rejects.toThrow(ValidationError); + }); + + it("rejects NaN or non-finite amounts", async () => { + await expect(converter.convert({ amount: Number.NaN, from: "XLM", to: "USD", rate: 0.1 })).rejects.toThrow(ValidationError); + await expect(converter.convert({ amount: Number.POSITIVE_INFINITY, from: "XLM", to: "USD", rate: 0.1 })).rejects.toThrow(ValidationError); + }); + + it("rejects empty currency codes", async () => { + await expect(converter.convert({ amount: 10, from: "", to: "USD", rate: 0.1 })).rejects.toThrow(ValidationError); + await expect(converter.convert({ amount: 10, from: "XLM", to: " ", rate: 0.1 })).rejects.toThrow(ValidationError); + }); + + it("rejects zero or negative exchange rates", () => { + expect(() => converter.setRate("XLM", "USD", 0)).toThrow(ValidationError); + expect(() => converter.setRate("XLM", "USD", -0.5)).toThrow(ValidationError); + }); + }); +}); + +describe("convertCurrency helper", () => { + it("converts accurately with precision", () => { + expect(convertCurrency(100, 1.2345, 2)).toBe(123.45); + expect(convertCurrency(0, 5)).toBe(0); + }); + + it("validates input", () => { + expect(() => convertCurrency(-1, 2)).toThrow(ValidationError); + expect(() => convertCurrency(10, -2)).toThrow(ValidationError); + }); +}); diff --git a/test/offlineTransaction.test.ts b/test/offlineTransaction.test.ts new file mode 100644 index 0000000..6596ed5 --- /dev/null +++ b/test/offlineTransaction.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect } from "vitest"; +import { Keypair, Asset, Networks, Memo } from "@stellar/stellar-sdk"; +import { + OfflineTransactionBuilder, +} from "../src/offlineTransaction.js"; +import { ValidationError } from "../src/errors.js"; + +describe("OfflineTransactionBuilder (#1033)", () => { + const sourceKp = Keypair.random(); + const destKp = Keypair.random(); + + it("builds an offline transaction without network access", () => { + const builder = new OfflineTransactionBuilder({ + sourceAddress: sourceKp.publicKey(), + sequenceNumber: "100", + networkPassphrase: Networks.TESTNET, + baseFee: 100, + }) + .addPayment({ + destination: destKp.publicKey(), + amount: "50", + }) + .addManageData({ + name: "offline_ref", + value: "ABC", + }); + + const tx = builder.build(); + expect(tx.source).toBe(sourceKp.publicKey()); + expect(tx.sequence).toBe("101"); // 100 + 1 + expect(tx.operations).toHaveLength(2); + expect(Number(tx.fee)).toBe(200); // 100 baseFee * 2 ops + expect(typeof tx.toXDR).toBe("function"); + }); + + it("supports text, id, and hash memos", () => { + const builder = new OfflineTransactionBuilder({ + sourceAddress: sourceKp.publicKey(), + sequenceNumber: "1", + networkPassphrase: Networks.TESTNET, + memo: { type: "text", value: "Invoice Split 42" }, + }).addPayment({ + destination: destKp.publicKey(), + amount: "1", + }); + + const tx = builder.build(); + expect(tx.memo.type).toBe("text"); + expect(tx.memo.value).toBe("Invoice Split 42"); + }); + + it("signs transaction securely using Keypair", () => { + const builder = new OfflineTransactionBuilder({ + sourceAddress: sourceKp.publicKey(), + sequenceNumber: "5", + networkPassphrase: Networks.TESTNET, + }).addPayment({ + destination: destKp.publicKey(), + amount: "10", + }); + + builder.sign(sourceKp); + + const tx = builder.build(); + expect(tx.signatures).toHaveLength(1); + expect(builder.verifySignatures([sourceKp.publicKey()])).toBe(true); + expect(builder.getSignatureHints()).toHaveLength(1); + }); + + it("signs transaction using secret seed string", () => { + const builder = new OfflineTransactionBuilder({ + sourceAddress: sourceKp.publicKey(), + sequenceNumber: "5", + networkPassphrase: Networks.TESTNET, + }).addPayment({ + destination: destKp.publicKey(), + amount: "10", + }); + + builder.sign(sourceKp.secret()); + + const tx = builder.build(); + expect(tx.signatures).toHaveLength(1); + expect(builder.verifySignatures([sourceKp.publicKey()])).toBe(true); + }); + + it("supports multi-signature signing offline", () => { + const coSigner = Keypair.random(); + + const builder = new OfflineTransactionBuilder({ + sourceAddress: sourceKp.publicKey(), + sequenceNumber: "10", + networkPassphrase: Networks.TESTNET, + }).addPayment({ + destination: destKp.publicKey(), + amount: "100", + }); + + builder.sign(sourceKp); + builder.sign(coSigner); + + const tx = builder.build(); + expect(tx.signatures).toHaveLength(2); + expect(builder.verifySignatures([sourceKp.publicKey(), coSigner.publicKey()])).toBe(true); + }); + + it("fails verification when signature was signed by an unrelated key", () => { + const otherKp = Keypair.random(); + const builder = new OfflineTransactionBuilder({ + sourceAddress: sourceKp.publicKey(), + sequenceNumber: "10", + networkPassphrase: Networks.TESTNET, + }).addPayment({ + destination: destKp.publicKey(), + amount: "100", + }); + + builder.sign(sourceKp); + + // Verifying against otherKp should fail + expect(builder.verifySignatures([otherKp.publicKey()])).toBe(false); + }); + + describe("Serialization sound round-trip", () => { + it("serializes to XDR and deserializes losslessly with intact hash and signature", () => { + const builder = new OfflineTransactionBuilder({ + sourceAddress: sourceKp.publicKey(), + sequenceNumber: "200", + networkPassphrase: Networks.TESTNET, + baseFee: 150, + }).addPayment({ + destination: destKp.publicKey(), + amount: "30", + }); + + builder.sign(sourceKp); + + const originalHash = builder.getTransactionHash(); + const xdrString = builder.toXDR(); + + const restoredBuilder = OfflineTransactionBuilder.fromXDR( + xdrString, + Networks.TESTNET, + ); + + expect(restoredBuilder.getTransactionHash()).toBe(originalHash); + expect(restoredBuilder.build().source).toBe(sourceKp.publicKey()); + expect(restoredBuilder.build().sequence).toBe("201"); + expect(restoredBuilder.verifySignatures([sourceKp.publicKey()])).toBe(true); + }); + + it("serializes to JSON transport package and deserializes accurately", () => { + const builder = new OfflineTransactionBuilder({ + sourceAddress: sourceKp.publicKey(), + sequenceNumber: "300", + networkPassphrase: Networks.TESTNET, + }).addPayment({ + destination: destKp.publicKey(), + amount: "10", + }); + + builder.sign(sourceKp); + + const jsonString = builder.serialize(); + const restoredBuilder = OfflineTransactionBuilder.deserialize(jsonString); + + expect(restoredBuilder.getTransactionHash()).toBe(builder.getTransactionHash()); + expect(restoredBuilder.build().source).toBe(sourceKp.publicKey()); + expect(restoredBuilder.verifySignatures([sourceKp.publicKey()])).toBe(true); + }); + }); + + describe("Validation", () => { + it("rejects building with 0 operations", () => { + const builder = new OfflineTransactionBuilder({ + sourceAddress: sourceKp.publicKey(), + sequenceNumber: "1", + networkPassphrase: Networks.TESTNET, + }); + + expect(() => builder.build()).toThrow(ValidationError); + }); + + it("rejects invalid source address on construction", () => { + expect( + () => + new OfflineTransactionBuilder({ + sourceAddress: "NOT_A_VALID_KEY", + sequenceNumber: "1", + networkPassphrase: Networks.TESTNET, + }), + ).toThrow(ValidationError); + }); + + it("rejects invalid destination on addPayment", () => { + const builder = new OfflineTransactionBuilder({ + sourceAddress: sourceKp.publicKey(), + sequenceNumber: "1", + networkPassphrase: Networks.TESTNET, + }); + + expect(() => + builder.addPayment({ + destination: "INVALID_DEST", + amount: "10", + }), + ).toThrow(ValidationError); + }); + + it("rejects non-positive payment amount", () => { + const builder = new OfflineTransactionBuilder({ + sourceAddress: sourceKp.publicKey(), + sequenceNumber: "1", + networkPassphrase: Networks.TESTNET, + }); + + expect(() => + builder.addPayment({ + destination: destKp.publicKey(), + amount: "-5", + }), + ).toThrow(ValidationError); + }); + + it("rejects invalid secret seed on sign", () => { + const builder = new OfflineTransactionBuilder({ + sourceAddress: sourceKp.publicKey(), + sequenceNumber: "1", + networkPassphrase: Networks.TESTNET, + }).addPayment({ + destination: destKp.publicKey(), + amount: "10", + }); + + expect(() => builder.sign("SBADSECRET")).toThrow(ValidationError); + }); + }); +}); diff --git a/test/requestRetry.test.ts b/test/requestRetry.test.ts new file mode 100644 index 0000000..93f4191 --- /dev/null +++ b/test/requestRetry.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + RequestTimeoutError, + MaxRetriesExceededError, + calculateBackoffDelay, + isRetryableError, + executeWithTimeoutAndRetry, + RequestRetryManager, +} from "../src/requestRetry.js"; + +describe("calculateBackoffDelay", () => { + it("computes deterministic exponential backoff when jitter is false", () => { + const opts = { baseDelayMs: 100, backoffMultiplier: 2, maxDelayMs: 2000, jitter: false }; + expect(calculateBackoffDelay(1, opts)).toBe(100); + expect(calculateBackoffDelay(2, opts)).toBe(200); + expect(calculateBackoffDelay(3, opts)).toBe(400); + expect(calculateBackoffDelay(4, opts)).toBe(800); + expect(calculateBackoffDelay(5, opts)).toBe(1600); + expect(calculateBackoffDelay(6, opts)).toBe(2000); // capped at maxDelayMs + }); + + it("applies proportional jitter within bounds", () => { + const opts = { baseDelayMs: 1000, backoffMultiplier: 2, maxDelayMs: 10_000, jitter: 0.1 }; + for (let i = 0; i < 20; i++) { + const delay = calculateBackoffDelay(1, opts); + // with 0.1 jitter on 1000ms: expected in [900, 1100] + expect(delay).toBeGreaterThanOrEqual(900); + expect(delay).toBeLessThanOrEqual(1100); + } + }); + + it("applies full jitter between 0 and raw delay", () => { + const opts = { baseDelayMs: 1000, backoffMultiplier: 2, maxDelayMs: 10_000, jitter: true }; + for (let i = 0; i < 20; i++) { + const delay = calculateBackoffDelay(1, opts); + expect(delay).toBeGreaterThanOrEqual(0); + expect(delay).toBeLessThanOrEqual(1000); + } + }); +}); + +describe("isRetryableError", () => { + it("identifies RequestTimeoutError as retryable", () => { + expect(isRetryableError(new RequestTimeoutError(5000))).toBe(true); + }); + + it("identifies transient HTTP status codes as retryable", () => { + expect(isRetryableError({ status: 503 })).toBe(true); + expect(isRetryableError({ statusCode: 502 })).toBe(true); + expect(isRetryableError({ status: 429 })).toBe(true); + expect(isRetryableError({ status: 504 })).toBe(true); + }); + + it("identifies client errors as non-retryable", () => { + expect(isRetryableError({ status: 400 })).toBe(false); + expect(isRetryableError({ status: 401 })).toBe(false); + expect(isRetryableError({ status: 403 })).toBe(false); + expect(isRetryableError({ status: 404 })).toBe(false); + expect(isRetryableError({ status: 422 })).toBe(false); + }); + + it("inspects error message for transient keywords", () => { + expect(isRetryableError(new Error("fetch failed: ECONNRESET"))).toBe(true); + expect(isRetryableError(new Error("Connection reset by peer"))).toBe(true); + expect(isRetryableError(new Error("ETIMEDOUT: connect timed out"))).toBe(true); + expect(isRetryableError(new Error("Validation failed for input"))).toBe(false); + expect(isRetryableError(new Error("Resource not found"))).toBe(false); + }); +}); + +describe("executeWithTimeoutAndRetry", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it("returns value immediately when operation succeeds on first attempt", async () => { + const fn = vi.fn().mockResolvedValue("success"); + const result = await executeWithTimeoutAndRetry(fn, { + defaultTimeoutMs: 1000, + maxRetries: 2, + }); + expect(result).toBe("success"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("retries after transient failure and returns when subsequent attempt succeeds", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("network error 503")) + .mockResolvedValueOnce("recovered"); + + const onRetry = vi.fn(); + const result = await executeWithTimeoutAndRetry(fn, { + defaultTimeoutMs: 1000, + baseDelayMs: 10, + maxDelayMs: 50, + jitter: false, + onRetry, + }); + + expect(result).toBe("recovered"); + expect(fn).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onRetry).toHaveBeenCalledWith(1, expect.any(Error), 10); + }); + + it("enforces retry limits and throws MaxRetriesExceededError", async () => { + const fn = vi.fn().mockRejectedValue(new Error("503 Service Unavailable")); + const onRetry = vi.fn(); + + await expect( + executeWithTimeoutAndRetry(fn, { + defaultTimeoutMs: 1000, + maxRetries: 2, + baseDelayMs: 5, + jitter: false, + onRetry, + }), + ).rejects.toThrow(MaxRetriesExceededError); + + expect(fn).toHaveBeenCalledTimes(3); // Initial + 2 retries + expect(onRetry).toHaveBeenCalledTimes(2); + }); + + it("aborts and retries when operation exceeds timeout", async () => { + let callCount = 0; + const fn = vi.fn().mockImplementation(async (signal: AbortSignal) => { + callCount++; + if (callCount === 1) { + return new Promise((resolve, reject) => { + signal.addEventListener("abort", () => { + reject(new Error("operation aborted")); + }); + }); + } + return "done after timeout"; + }); + + const onTimeout = vi.fn(); + const result = await executeWithTimeoutAndRetry(fn, { + defaultTimeoutMs: 50, + baseDelayMs: 10, + maxRetries: 2, + jitter: false, + onTimeout, + }); + + expect(result).toBe("done after timeout"); + expect(callCount).toBe(2); + expect(onTimeout).toHaveBeenCalledTimes(1); + }); + + it("does not retry when error is not retryable", async () => { + const fn = vi.fn().mockRejectedValue(new Error("Validation error: invalid address")); + + await expect( + executeWithTimeoutAndRetry(fn, { + defaultTimeoutMs: 1000, + maxRetries: 3, + baseDelayMs: 10, + }), + ).rejects.toThrow("Validation error"); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("honors custom retryIf predicate", async () => { + const customErr = new Error("custom fatal error"); + const fn = vi.fn().mockRejectedValue(customErr); + const retryIf = vi.fn().mockReturnValue(false); + + await expect( + executeWithTimeoutAndRetry(fn, { + defaultTimeoutMs: 1000, + maxRetries: 3, + retryIf, + }), + ).rejects.toThrow(customErr); + + expect(fn).toHaveBeenCalledTimes(1); + expect(retryIf).toHaveBeenCalledWith(customErr, 1); + }); +}); + +describe("RequestRetryManager", () => { + it("resolves default and per-method timeouts", () => { + const manager = new RequestRetryManager({ + defaultTimeoutMs: 5000, + methodTimeouts: { + getLeaderboard: 30000, + fastPing: 1000, + }, + }); + + expect(manager.getTimeout("getLeaderboard")).toBe(30000); + expect(manager.getTimeout("fastPing")).toBe(1000); + expect(manager.getTimeout("getInvoice")).toBe(5000); + }); + + it("executes operations with per-method overrides", async () => { + const manager = new RequestRetryManager({ + defaultTimeoutMs: 500, + baseDelayMs: 5, + maxRetries: 1, + jitter: false, + }); + + const fn = vi.fn().mockResolvedValue(123); + const result = await manager.execute(fn, "myMethod"); + expect(result).toBe(123); + expect(fn).toHaveBeenCalledTimes(1); + }); +});