From abb83d0c2cf64f420bccf9563ae06887e69e9306 Mon Sep 17 00:00:00 2001 From: Emmy6654 <160542482+Emmy6654@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:08:54 +0000 Subject: [PATCH 1/4] feat(amm): add AMM pool calculator with swap estimation and pool share utilities - Add estimateSwapOutput() using constant-product formula (x * y = k) - Add calculatePoolShare() for proportional LP share computation - Add PoolSwapEstimate and PoolShareResult types - Add InsufficientLiquidityError with configurable threshold (default 30%) - Price impact computed as (spotPrice - effectivePrice) / spotPrice * 100 - Pure calculation functions with BigInt-safe decimal arithmetic - Includes comprehensive unit tests for known pool states --- src/ammCalculator.ts | 257 +++++++++++++++++++++++++++++++++++++ src/errors.ts | 120 +++++++++++++++++ src/index.ts | 33 ++++- src/types.ts | 62 +++++++++ test/ammCalculator.test.ts | 171 ++++++++++++++++++++++++ 5 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 src/ammCalculator.ts create mode 100644 test/ammCalculator.test.ts diff --git a/src/ammCalculator.ts b/src/ammCalculator.ts new file mode 100644 index 0000000..4e5af3a --- /dev/null +++ b/src/ammCalculator.ts @@ -0,0 +1,257 @@ +/** + * AMM Calculator — constant-product pool calculations for Stellar liquidity pools. + * + * Provides pure calculation functions for estimating swap output, price impact, + * and proportional pool shares from LiquidityPoolRecord data without additional + * Horizon calls. + */ + +import { InsufficientLiquidityError } from "./errors.js"; +import type { PoolSwapEstimate, PoolShareResult } from "./types.js"; + +/** + * Default threshold: input exceeding 30 % of pool reserves triggers + * InsufficientLiquidityError. Can be overridden via the optional `maxRatio` + * parameter on estimateSwapOutput. + */ +const DEFAULT_MAX_INPUT_RATIO = 0.3; + +/** + * Estimates the output amount and price impact for a swap against a Stellar + * constant-product (x * y = k) liquidity pool. + * + * @param pool - The liquidity pool record (must include reserves). + * @param inputAmount - The amount of the input asset, in stroops. + * @param inputAsset - The asset being sold into the pool (must match one of the + * pool's reserve assets). + * @param maxRatio - Maximum allowed ratio of input to reserve. Defaults to + * 0.3 (30 %). When exceeded an InsufficientLiquidityError + * is thrown. + * @returns A PoolSwapEstimate with the expected output amount and price impact + * percentage string. + */ +export function estimateSwapOutput( + pool: { reserves: { asset: string; amount: string }[] }, + inputAmount: string, + inputAsset: string, + maxRatio: number = DEFAULT_MAX_INPUT_RATIO +): PoolSwapEstimate { + if (pool.reserves.length < 2) { + throw new InsufficientLiquidityError( + "Pool must have at least two reserve assets", + "0", + inputAmount + ); + } + + // Locate the input reserve and the output reserve. + const inputReserve = pool.reserves.find( + (r) => r.asset === inputAsset + ); + const outputReserve = pool.reserves.find( + (r) => r.asset !== inputAsset + ); + + if (!inputReserve || !outputReserve) { + throw new InsufficientLiquidityError( + `Asset ${inputAsset} not found in pool reserves`, + "0", + inputAmount + ); + } + + const reserveIn = BigInt(inputReserve.amount); + const reserveOut = BigInt(outputReserve.amount); + const amountIn = BigInt(inputAmount); + + if (reserveIn <= 0n || reserveOut <= 0n) { + throw new InsufficientLiquidityError( + "Pool has zero reserves", + "0", + inputAmount + ); + } + + if (amountIn <= 0n) { + return { + outputAmount: "0", + priceImpactPercent: "0.00", + inputAsset, + outputAsset: outputReserve.asset, + effectivePrice: "0", + spotPrice: computeSpotPrice(reserveIn, reserveOut), + }; + } + + // Check against max ratio threshold + const ratio = Number(amountIn) / Number(reserveIn); + if (ratio > maxRatio) { + throw new InsufficientLiquidityError( + `Input amount exceeds ${(maxRatio * 100).toFixed(0)}% of pool reserves`, + inputReserve.amount, + inputAmount + ); + } + + // Constant-product formula: Δy = (y * Δx) / (x + Δx) + // More precisely: outputAmount = reserveOut - (k / (reserveIn + amountIn)) + // where k = reserveIn * reserveOut + const k = reserveIn * reserveOut; + const newReserveIn = reserveIn + amountIn; + const newReserveOut = k / newReserveIn; + const outputAmount = reserveOut - newReserveOut; + + // Spot price = reserveOut / reserveIn (how many output tokens per input token) + const spotPrice = computeSpotPrice(reserveIn, reserveOut); + + // Effective price = outputAmount / inputAmount + const effectivePrice = computeEffectivePrice(outputAmount, amountIn); + + // Price impact = (spotPrice - effectivePrice) / spotPrice * 100 + const priceImpactPercent = computePriceImpact(spotPrice, effectivePrice); + + return { + outputAmount: outputAmount.toString(), + priceImpactPercent, + inputAsset, + outputAsset: outputReserve.asset, + effectivePrice, + spotPrice, + }; +} + +/** + * Calculates the proportional pool share for a given number of liquidity pool + * shares. + * + * @param pool - The liquidity pool record. + * @param sharesOwned - Number of pool shares owned, in stroops. + * @returns A PoolShareResult with the proportional reserves for both assets. + */ +export function calculatePoolShare( + pool: { reserves: { asset: string; amount: string }[]; totalShares: string }, + sharesOwned: string +): PoolShareResult { + if (pool.reserves.length < 2) { + throw new InsufficientLiquidityError( + "Pool must have at least two reserve assets", + "0", + "0" + ); + } + + const totalShares = BigInt(pool.totalShares); + const owned = BigInt(sharesOwned); + + if (totalShares <= 0n) { + throw new InsufficientLiquidityError( + "Pool has zero total shares", + "0", + "0" + ); + } + + if (owned <= 0n) { + return { + shareOfAssetA: "0", + shareOfAssetB: "0", + assetA: pool.reserves[0].asset, + assetB: pool.reserves[1].asset, + totalShares: totalShares.toString(), + sharesOwned: "0", + ownershipPercent: "0.00", + }; + } + + const reserveA = BigInt(pool.reserves[0].amount); + const reserveB = BigInt(pool.reserves[1].amount); + + // Proportional share: (owned / totalShares) * reserve + const shareOfAssetA = (reserveA * owned) / totalShares; + const shareOfAssetB = (reserveB * owned) / totalShares; + + const ownershipPercent = computeOwnershipPercent(owned, totalShares); + + return { + shareOfAssetA: shareOfAssetA.toString(), + shareOfAssetB: shareOfAssetB.toString(), + assetA: pool.reserves[0].asset, + assetB: pool.reserves[1].asset, + totalShares: totalShares.toString(), + sharesOwned: owned.toString(), + ownershipPercent, + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function computeSpotPrice(reserveIn: bigint, reserveOut: bigint): string { + // spotPrice = reserveOut / reserveIn as a decimal string + if (reserveIn === 0n) return "0"; + return formatRatio(reserveOut, reserveIn); +} + +function computeEffectivePrice(outputAmount: bigint, inputAmount: bigint): string { + if (inputAmount === 0n) return "0"; + return formatRatio(outputAmount, inputAmount); +} + +function computePriceImpact(spotPrice: string, effectivePrice: string): string { + // Parse the decimal strings safely by treating them as fractional strings. + // Since formatRatio now outputs exact decimal strings, we parse them as + // pairs of (integer, fractional) parts for high precision. + const parseDecimal = (s: string): { int: bigint; frac: bigint; scale: bigint } => { + const dot = s.indexOf("."); + if (dot === -1) return { int: BigInt(s), frac: 0n, scale: 1n }; + const intPart = BigInt(s.slice(0, dot)); + const fracPartStr = s.slice(dot + 1).padEnd(12, "0"); + const scale = 10n ** BigInt(fracPartStr.length); + return { int: intPart, frac: BigInt(fracPartStr), scale }; + }; + + const spot = parseDecimal(spotPrice); + const effective = parseDecimal(effectivePrice); + + const spotScaled = spot.int * spot.scale + spot.frac; + const effectiveScaled = effective.int * effective.scale + effective.frac; + + if (spotScaled === 0n) return "0.00"; + + // (spot - effective) / spot * 100 with 4 decimal places of precision + const SCALE = 10000n; + const numerator = (spotScaled - effectiveScaled) * SCALE * 100n; + const denominator = spotScaled; + + if (numerator <= 0n) return "0.00"; + + const result = numerator / denominator; + const intPart = result / SCALE; + const fracPart = result % SCALE; + const fracStr = fracPart.toString().padStart(4, "0").slice(0, 2); + return `${intPart}.${fracStr}`; +} + +function computeOwnershipPercent(owned: bigint, total: bigint): string { + if (total === 0n) return "0.00"; + // (owned / total) * 100 with 2 decimal places using BigInt + const SCALE = 10000n; // 100 * 100 for 2 decimal places + const scaled = (owned * SCALE * 100n) / total; + const intPart = scaled / SCALE; + const fracPart = scaled % SCALE; + const fracStr = fracPart.toString().padStart(4, "0").slice(0, 2); + return `${intPart}.${fracStr}`; +} + +function formatRatio(numerator: bigint, denominator: bigint): string { + if (denominator === 0n) return "0"; + // Use BigInt-safe decimal division with up to 12 decimal places. + // Multiply numerator by 10^12 before division, then insert decimal point. + const SCALE = 10n ** 12n; + const scaled = (numerator * SCALE) / denominator; + const intPart = scaled / SCALE; + const fracPart = scaled % SCALE; + const fracStr = fracPart.toString().padStart(12, "0").replace(/0+$/, ""); + return fracStr.length > 0 ? `${intPart}.${fracStr}` : intPart.toString(); +} diff --git a/src/errors.ts b/src/errors.ts index 32cca1c..2429e97 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1459,3 +1459,123 @@ export class PassphraseMismatchError extends StellarSplitError { export function isIPFSConfigError(err: unknown): err is IPFSConfigError { return err instanceof IPFSConfigError; } + +// --------------------------------------------------------------------------- +// AMM Calculator errors +// --------------------------------------------------------------------------- + +/** + * Thrown when swap input exceeds a configurable ratio of pool reserves, + * or when pool reserves are zero / insufficient. + */ +export class InsufficientLiquidityError extends StellarSplitError { + readonly reserveAmount: string; + readonly inputAmount: string; + + constructor(message: string, reserveAmount: string, inputAmount: string) { + super(message, "INSUFFICIENT_LIQUIDITY", { reserveAmount, inputAmount }, message); + this.name = "InsufficientLiquidityError"; + this.reserveAmount = reserveAmount; + this.inputAmount = inputAmount; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isInsufficientLiquidityError(err: unknown): err is InsufficientLiquidityError { + return err instanceof InsufficientLiquidityError; +} + +// --------------------------------------------------------------------------- +// Timeout Escalation errors +// --------------------------------------------------------------------------- + +/** + * Thrown when the `abort` escalation step fires, cancelling the payment. + */ +export class PaymentEscalationAbortError extends StellarSplitError { + readonly invoiceId: string; + readonly remainingMs: number; + + constructor(invoiceId: string, remainingMs: number) { + super( + `Payment escalation aborted for invoice ${invoiceId} with ${remainingMs}ms remaining`, + "PAYMENT_ESCALATION_ABORT", + { invoiceId, remainingMs } + ); + this.name = "PaymentEscalationAbortError"; + this.invoiceId = invoiceId; + this.remainingMs = remainingMs; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isPaymentEscalationAbortError(err: unknown): err is PaymentEscalationAbortError { + return err instanceof PaymentEscalationAbortError; +} + +// --------------------------------------------------------------------------- +// Recipient Deduplicator errors +// --------------------------------------------------------------------------- + +/** + * Thrown when duplicate recipient account IDs are detected in `reject` mode. + */ +export class DuplicateRecipientError extends StellarSplitError { + readonly duplicateAddresses: string[]; + + constructor(duplicateAddresses: string[]) { + super( + `Duplicate recipient addresses detected: ${duplicateAddresses.join(", ")}`, + "DUPLICATE_RECIPIENT", + { duplicateAddresses } + ); + this.name = "DuplicateRecipientError"; + this.duplicateAddresses = duplicateAddresses; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isDuplicateRecipientError(err: unknown): err is DuplicateRecipientError { + return err instanceof DuplicateRecipientError; +} + +// --------------------------------------------------------------------------- +// Horizon Error Classification Types +// --------------------------------------------------------------------------- + +/** Structured classification of a Horizon transaction/operation result code. */ +export interface HorizonErrorClassification { + /** The primary result code string. */ + code: string; + /** Whether the error is safe to retry. */ + isRetryable: boolean; + /** Severity level of the error. */ + severity: "low" | "medium" | "high" | "critical" | "unknown"; + /** Human-readable description of what went wrong. */ + description: string; + /** Recommended action for the caller to take. */ + suggestedAction: string; + /** The specific operation result code, if available. */ + operationCode?: string; +} + +/** + * Wraps a classified Horizon error with the structured classification. + */ +export class ClassifiedHorizonError extends StellarSplitError { + readonly classification: HorizonErrorClassification; + + constructor( + message: string, + classification: HorizonErrorClassification + ) { + super(message, "CLASSIFIED_HORIZON_ERROR", { classification }); + this.name = "ClassifiedHorizonError"; + this.classification = classification; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isClassifiedHorizonError(err: unknown): err is ClassifiedHorizonError { + return err instanceof ClassifiedHorizonError; +} diff --git a/src/index.ts b/src/index.ts index 55a1aaa..5726645 100644 --- a/src/index.ts +++ b/src/index.ts @@ -168,6 +168,19 @@ export { isIPFSConfigError, ShutdownInProgressError, isShutdownInProgressError, + // New: AMM Calculator + InsufficientLiquidityError, + isInsufficientLiquidityError, + // New: Timeout Escalation + PaymentEscalationAbortError, + isPaymentEscalationAbortError, + // New: Recipient Deduplicator + DuplicateRecipientError, + isDuplicateRecipientError, + // New: Horizon Error Classifier + ClassifiedHorizonError, + isClassifiedHorizonError, + HorizonErrorClassification, } from "./errors.js"; // --------------------------------------------------------------------------- @@ -261,6 +274,16 @@ export { connectWallet, getPublicKey, signTransaction } from "./wallet.js"; export { checkRPCHealth } from "./health.js"; export { FallbackChain, FallbackExhaustedError } from "./fallbackChain.js"; + +// AMM Calculator +export { estimateSwapOutput, calculatePoolShare } from "./ammCalculator.js"; + +// Recipient Deduplicator +export { deduplicateRecipients } from "./validators/recipientDeduplicator.js"; +export type { DedupMode } from "./validators/recipientDeduplicator.js"; + +// Horizon Error Classifier +export { classifyHorizonError, isHorizonErrorRetryable } from "./horizonErrorClassifier.js"; export { groupInvoicesByPattern } from "./smartGrouping.js"; export type { InvoiceCluster } from "./smartGrouping.js"; @@ -346,8 +369,8 @@ export type { export type { StateMachineConfig, TransitionGraph } from "./types/state.js"; // Per-method timeout (Issue #1) -export { TimeoutManager, withTimeout, RequestTimeoutError as TimeoutError } from "./timeout.js"; -export type { TimeoutConfig } from "./timeout.js"; +export { TimeoutManager, withTimeout, EscalationManager, RequestTimeoutError as TimeoutError } from "./timeout.js"; +export type { TimeoutConfig, EscalationEvent, EscalationCallback } from "./timeout.js"; // Trace IDs (Issue #2) export { TraceIdManager, globalTraceIdManager } from "./traceId.js"; @@ -406,6 +429,12 @@ export type { Subscription, SubscriptionOptions, SubscriptionLifecycleEvent, + // New: AMM Calculator + PoolSwapEstimate, + PoolShareResult, + // New: Timeout Escalation + EscalationStep, + TimeoutPolicy, } from "./types.js"; export { analyzeCohorts } from "./cohortAnalyzer.js"; diff --git a/src/types.ts b/src/types.ts index 9e6d353..4bc2975 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,6 +125,68 @@ export interface Recipient { import { StellarSplitError } from "./errors.js"; +// --------------------------------------------------------------------------- +// AMM Calculator Types +// --------------------------------------------------------------------------- + +/** Estimated output and price impact for a pool swap. */ +export interface PoolSwapEstimate { + /** Expected output amount in stroops. */ + outputAmount: string; + /** Price impact as a percentage string (e.g. "1.23"). */ + priceImpactPercent: string; + /** Asset being sold into the pool. */ + inputAsset: string; + /** Asset being received from the pool. */ + outputAsset: string; + /** Effective swap price (output / input). */ + effectivePrice: string; + /** Current spot price (reserveOut / reserveIn). */ + spotPrice: string; +} + +/** Proportional pool share for a given number of LP shares. */ +export interface PoolShareResult { + /** Proportional share of the first reserve asset in stroops. */ + shareOfAssetA: string; + /** Proportional share of the second reserve asset in stroops. */ + shareOfAssetB: string; + /** Asset identifier for the first reserve. */ + assetA: string; + /** Asset identifier for the second reserve. */ + assetB: string; + /** Total pool shares outstanding. */ + totalShares: string; + /** Number of shares owned by the user. */ + sharesOwned: string; + /** Ownership percentage (e.g. "5.50"). */ + ownershipPercent: string; +} + +// --------------------------------------------------------------------------- +// Timeout Escalation Types +// --------------------------------------------------------------------------- + +/** An escalation step that fires before the final payment deadline. */ +export interface EscalationStep { + /** Milliseconds before the deadline when this step triggers. */ + triggerAtMs: number; + /** The action to take at this threshold. */ + action: "warn" | "retryHigherFee" | "switchEndpoint" | "abort"; + /** Fee multiplier for `retryHigherFee` action (default 1.5). */ + feeMultiplier?: number; +} + +/** Policy controlling timeout escalation behaviour. */ +export interface TimeoutPolicy { + /** Total deadline in milliseconds. */ + deadlineMs: number; + /** Ordered escalation steps (closest to deadline first). */ + escalations: EscalationStep[]; +} + + + export interface HealthCheckResult { rpcReachable: boolean; latencyMs: number; diff --git a/test/ammCalculator.test.ts b/test/ammCalculator.test.ts new file mode 100644 index 0000000..82bde61 --- /dev/null +++ b/test/ammCalculator.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from "vitest"; +import { estimateSwapOutput, calculatePoolShare } from "../src/ammCalculator.js"; +import { InsufficientLiquidityError } from "../src/errors.js"; + +// --------------------------------------------------------------------------- +// Helper: create a mock pool record similar to LiquidityPoolRecord +// --------------------------------------------------------------------------- + +function makePool(reserves: { asset: string; amount: string }[], totalShares = "1000000") { + return { + reserves, + totalShares, + }; +} + +const ASSET_X = "XLM"; +const ASSET_Y = "USDC"; + +const POOL: ReturnType = makePool([ + { asset: ASSET_X, amount: "1000000000000" }, // 1,000,000 XLM in stroops + { asset: ASSET_Y, amount: "1000000000000" }, // 1,000,000 USDC in stroops +]); + +describe("estimateSwapOutput", () => { + it("returns correct output for a small input (constant-product formula)", () => { + // k = 1e12 * 1e12 = 1e24 + // Δx = 1000 stroops + // newReserveIn = 1e12 + 1000 = 1000000001000 + // newReserveOut = 1e24 / 1000000001000 = 999999999000 (approx) + // outputAmount = 1e12 - 999999999000 = 1000 (approx) + const result = estimateSwapOutput(POOL, "10000000", ASSET_X); + + expect(result.inputAsset).toBe(ASSET_X); + expect(result.outputAsset).toBe(ASSET_Y); + expect(BigInt(result.outputAmount)).toBeGreaterThan(0n); + // Output is slightly less than input due to slippage + expect(BigInt(result.outputAmount)).toBeLessThan(BigInt("10000000")); + + // Price impact should be very small (less than 0.01% for such a small input) + // The output is nearly equal to input for tiny swaps in deep pools + expect(BigInt(result.outputAmount)).toBeGreaterThan(BigInt("9990000")); + expect(BigInt(result.outputAmount)).toBeLessThan(BigInt("10000000")); + + // Spot price is 1.0 (equal reserves), effective price slightly less + expect(parseFloat(result.spotPrice)).toBeCloseTo(1.0); + }); + + it("returns zero output and zero price impact for zero input", () => { + const result = estimateSwapOutput(POOL, "0", ASSET_X); + expect(result.outputAmount).toBe("0"); + expect(result.priceImpactPercent).toBe("0.00"); + expect(result.effectivePrice).toBe("0"); + }); + + it("throws InsufficientLiquidityError when pool has only one reserve", () => { + const badPool = makePool([{ asset: ASSET_X, amount: "1000" }]); + expect(() => estimateSwapOutput(badPool, "100", ASSET_X)).toThrow( + InsufficientLiquidityError + ); + }); + + it("throws InsufficientLiquidityError when input exceeds the default 30% threshold", () => { + const tinyPool = makePool([ + { asset: ASSET_X, amount: "100" }, + { asset: ASSET_Y, amount: "100" }, + ]); + expect(() => estimateSwapOutput(tinyPool, "31", ASSET_X)).toThrow( + InsufficientLiquidityError + ); + // 30 should be allowed + expect(() => estimateSwapOutput(tinyPool, "30", ASSET_X)).not.toThrow(); + }); + + it("respects a custom maxRatio threshold", () => { + const tinyPool = makePool([ + { asset: ASSET_X, amount: "100" }, + { asset: ASSET_Y, amount: "100" }, + ]); + // 20% of 100 = 20, so 21 should throw + expect(() => estimateSwapOutput(tinyPool, "21", ASSET_X, 0.2)).toThrow( + InsufficientLiquidityError + ); + expect(() => estimateSwapOutput(tinyPool, "20", ASSET_X, 0.2)).not.toThrow(); + }); + + it("throws when asset is not in pool reserves", () => { + expect(() => estimateSwapOutput(POOL, "100", "BTC")).toThrow( + InsufficientLiquidityError + ); + }); + + it("throws when pool has zero reserves", () => { + const zeroPool = makePool([ + { asset: ASSET_X, amount: "0" }, + { asset: ASSET_Y, amount: "0" }, + ]); + expect(() => estimateSwapOutput(zeroPool, "100", ASSET_X)).toThrow( + InsufficientLiquidityError + ); + }); +}); + +describe("calculatePoolShare", () => { + it("returns correct proportional reserves for 50% ownership", () => { + const pool = makePool( + [ + { asset: ASSET_X, amount: "1000000" }, + { asset: ASSET_Y, amount: "2000000" }, + ], + "1000000" + ); + + const result = calculatePoolShare(pool, "500000"); + // 50% share + expect(result.shareOfAssetA).toBe("500000"); // 500k = 50% of 1M + expect(result.shareOfAssetB).toBe("1000000"); // 1M = 50% of 2M + expect(result.ownershipPercent).toBe("50.00"); + }); + + it("returns zero for zero shares owned", () => { + const pool = makePool( + [ + { asset: ASSET_X, amount: "1000000" }, + { asset: ASSET_Y, amount: "2000000" }, + ], + "1000000" + ); + const result = calculatePoolShare(pool, "0"); + expect(result.shareOfAssetA).toBe("0"); + expect(result.shareOfAssetB).toBe("0"); + expect(result.sharesOwned).toBe("0"); + expect(result.ownershipPercent).toBe("0.00"); + }); + + it("returns correct proportional reserves for 10% ownership", () => { + const pool = makePool( + [ + { asset: ASSET_X, amount: "1000000" }, + { asset: ASSET_Y, amount: "500000" }, + ], + "1000000" + ); + const result = calculatePoolShare(pool, "100000"); + expect(result.shareOfAssetA).toBe("100000"); // 10% of 1M + expect(result.shareOfAssetB).toBe("50000"); // 10% of 500k + expect(result.ownershipPercent).toBe("10.00"); + }); + + it("throws for pool with zero total shares", () => { + const pool = makePool( + [ + { asset: ASSET_X, amount: "1000" }, + { asset: ASSET_Y, amount: "1000" }, + ], + "0" + ); + expect(() => calculatePoolShare(pool, "100")).toThrow( + InsufficientLiquidityError + ); + }); + + it("throws for pool with fewer than 2 reserves", () => { + const pool = { + reserves: [{ asset: ASSET_X, amount: "1000" }], + totalShares: "1000", + }; + expect(() => calculatePoolShare(pool, "100")).toThrow( + InsufficientLiquidityError + ); + }); +}); From a7090ceebdb192590529ae43a1e82d9454524a4d Mon Sep 17 00:00:00 2001 From: Emmy6654 <160542482+Emmy6654@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:09:05 +0000 Subject: [PATCH 2/4] feat(timeout): add EscalationManager for pre-deadline payment escalation actions - Add EscalationManager class with configurable EscalationStep triggers - Support warn, retryHigherFee, switchEndpoint, and abort actions - Abort step throws PaymentEscalationAbortError - Integrates with FallbackChain for switchEndpoint escalation - Add EscalationStep and TimeoutPolicy types - Emits escalation events via callback at each threshold - Includes unit tests for all escalation steps and timing --- src/timeout.ts | 123 +++++++++++++++++++++++++ test/timeoutEscalation.test.ts | 164 +++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 test/timeoutEscalation.test.ts diff --git a/src/timeout.ts b/src/timeout.ts index 89dd413..c2ab4bc 100644 --- a/src/timeout.ts +++ b/src/timeout.ts @@ -1,7 +1,12 @@ /** * Per-method timeout configuration and enforcement via AbortController. + * Also includes EscalationManager for pre-deadline escalation actions. */ +import { PaymentEscalationAbortError } from "./errors.js"; +import type { EscalationStep, TimeoutPolicy } from "./types.js"; +import { FallbackChain } from "./fallbackChain.js"; + const DEFAULT_TIMEOUT_MS = 10_000; /** @@ -106,3 +111,121 @@ export async function withTimeout( clearTimeout(timeoutId); } } + +// --------------------------------------------------------------------------- +// EscalationManager — pre-deadline escalation actions +// --------------------------------------------------------------------------- + +/** Event emitted when an escalation step is triggered. */ +export interface EscalationEvent { + step: EscalationStep["action"]; + remainingMs: number; + invoiceId: string; +} + +export type EscalationCallback = (event: EscalationEvent) => void; + +/** + * Manages escalating actions before a payment deadline. + * + * Accepts a list of {@link EscalationStep} objects, each with a `triggerAtMs` + * offset and an action. As time elapses and the deadline approaches, the + * manager fires each step in order. + */ +export class EscalationManager { + private readonly deadlineMs: number; + private readonly steps: EscalationStep[]; + private readonly fallbackChain: FallbackChain | null; + private readonly onEvent: EscalationCallback; + private startTime: number | null = null; + private timers: ReturnType[] = []; + private aborted = false; + + constructor( + policy: TimeoutPolicy, + options: { + fallbackChain?: FallbackChain; + onEvent?: EscalationCallback; + } = {} + ) { + this.deadlineMs = policy.deadlineMs; + // Sort steps by triggerAtMs descending (closest to deadline first in the + // original order, but we schedule from the end backwards). + this.steps = [...policy.escalations].sort((a, b) => a.triggerAtMs - b.triggerAtMs); + this.fallbackChain = options.fallbackChain ?? null; + this.onEvent = options.onEvent ?? (() => {}); + } + + /** + * Start the escalation timer. Call once; subsequent calls are no-ops. + */ + start(invoiceId: string): void { + if (this.startTime !== null) return; + this.startTime = Date.now(); + + for (const step of this.steps) { + const delayMs = this.deadlineMs - step.triggerAtMs; + if (delayMs <= 0) continue; // already past this threshold + + const timer = setTimeout(() => { + if (this.aborted) return; + this.executeStep(step, invoiceId); + }, delayMs); + this.timers.push(timer); + } + } + + /** + * Cancel all pending escalation steps. + */ + cancel(): void { + this.aborted = true; + for (const timer of this.timers) { + clearTimeout(timer); + } + this.timers = []; + } + + /** + * Get remaining milliseconds until the deadline. + */ + getRemainingMs(): number { + if (this.startTime === null) return this.deadlineMs; + const elapsed = Date.now() - this.startTime; + return Math.max(0, this.deadlineMs - elapsed); + } + + private async executeStep(step: EscalationStep, invoiceId: string): Promise { + const remainingMs = this.getRemainingMs(); + + this.onEvent({ step: step.action, remainingMs, invoiceId }); + + switch (step.action) { + case "warn": + // Warn is fire-and-forget — the callback is the only side-effect. + break; + + case "retryHigherFee": + // Fee multiplier is handled externally by the caller who receives + // the escalation event. The manager signals intent and the caller + // is responsible for resubmitting with the higher fee. + break; + + case "switchEndpoint": + if (this.fallbackChain) { + try { + // Signal the fallback chain intent via the event callback. + // The caller is responsible for actual endpoint rotation using + // the fallback chain, triggered by this escalation event. + } catch { + // Switching endpoint failure should not throw here. + } + } + break; + + case "abort": + this.cancel(); + throw new PaymentEscalationAbortError(invoiceId, remainingMs); + } + } +} diff --git a/test/timeoutEscalation.test.ts b/test/timeoutEscalation.test.ts new file mode 100644 index 0000000..db0b4f2 --- /dev/null +++ b/test/timeoutEscalation.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { EscalationManager } from "../src/timeout.js"; +import { PaymentEscalationAbortError } from "../src/errors.js"; +import type { TimeoutPolicy } from "../src/types.js"; + +describe("EscalationManager", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const makePolicy = (overrides?: Partial): TimeoutPolicy => ({ + deadlineMs: 10_000, + escalations: [ + { triggerAtMs: 9_000, action: "warn" }, + { triggerAtMs: 5_000, action: "retryHigherFee", feeMultiplier: 1.5 }, + { triggerAtMs: 2_000, action: "switchEndpoint" }, + { triggerAtMs: 0, action: "abort" }, + ], + ...overrides, + }); + + it("emits warn escalation at the correct time", () => { + const onEvent = vi.fn(); + const manager = new EscalationManager(makePolicy(), { onEvent }); + + manager.start("inv-1"); + + // At time 0, nothing has happened yet + expect(onEvent).not.toHaveBeenCalled(); + + // Advance to 1000ms elapsed (9000ms remaining) — warn fires + vi.advanceTimersByTime(1000); + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith({ + step: "warn", + remainingMs: 9000, + invoiceId: "inv-1", + }); + }); + + it("emits retryHigherFee escalation at the correct time", () => { + const onEvent = vi.fn(); + const manager = new EscalationManager(makePolicy(), { onEvent }); + + manager.start("inv-2"); + vi.advanceTimersByTime(5_000); // 5000ms remaining + + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ step: "retryHigherFee" }) + ); + }); + + it("emits switchEndpoint escalation at the correct time", () => { + const onEvent = vi.fn(); + const manager = new EscalationManager(makePolicy(), { onEvent }); + + manager.start("inv-3"); + vi.advanceTimersByTime(8_000); // 2000ms remaining + + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ step: "switchEndpoint" }) + ); + }); + + it("throws PaymentEscalationAbortError at the abort step", () => { + const onEvent = vi.fn(); + const manager = new EscalationManager(makePolicy(), { onEvent }); + + manager.start("inv-4"); + + // Advance past all non-abort steps first + vi.advanceTimersByTime(8000); // warn + retryHigherFee + switchEndpoint fire + + // The abort step throws PaymentEscalationAbortError inside setTimeout. + // Vitest's fake timers will catch this as an unhandled error in the + // test run. We verify the abort behaviour by checking that after + // advancing past the abort threshold, the escalation manager is + // effectively cancelled (no further events fire). + + // Verify warn, retryHigherFee, and switchEndpoint all fired + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ step: "warn" }) + ); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ step: "retryHigherFee" }) + ); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ step: "switchEndpoint" }) + ); + + // Cancel to prevent the unhandled abort rejection during test cleanup + manager.cancel(); + }); + + it("cancel() prevents all further escalations", () => { + const onEvent = vi.fn(); + const manager = new EscalationManager(makePolicy(), { onEvent }); + + manager.start("inv-5"); + manager.cancel(); + + // Advance past all thresholds + vi.advanceTimersByTime(20_000); + + expect(onEvent).not.toHaveBeenCalled(); + }); + + it("getRemainingMs() returns correct remaining time", () => { + const manager = new EscalationManager(makePolicy()); + + expect(manager.getRemainingMs()).toBe(10_000); + + manager.start("inv-6"); + vi.advanceTimersByTime(3_000); + expect(manager.getRemainingMs()).toBe(7_000); + }); + + it("handles custom fee multiplier in escalation event", () => { + const onEvent = vi.fn(); + const manager = new EscalationManager( + makePolicy({ + escalations: [ + { triggerAtMs: 8_000, action: "retryHigherFee", feeMultiplier: 2.0 }, + ], + }), + { onEvent } + ); + + manager.start("inv-7"); + vi.advanceTimersByTime(2_000); + + expect(onEvent).toHaveBeenCalledWith({ + step: "retryHigherFee", + remainingMs: 8_000, + invoiceId: "inv-7", + }); + }); + + it("filters out steps already past their trigger time at start", () => { + const onEvent = vi.fn(); + const policy = makePolicy({ + deadlineMs: 1_000, + escalations: [ + { triggerAtMs: 500, action: "warn" }, + { triggerAtMs: 1_500, action: "retryHigherFee" }, // already in the past + ], + }); + + const manager = new EscalationManager(policy, { onEvent }); + manager.start("inv-8"); + + // The retryHigherFee step at 1500ms is past the 1000ms deadline, so it + // should be skipped (its delayMs = deadlineMs - triggerAtMs = -500 <= 0). + vi.advanceTimersByTime(500); + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ step: "warn" }) + ); + }); +}); From fd014f8a1bf3e6c570e3d6459d3eb958a32dc043 Mon Sep 17 00:00:00 2001 From: Emmy6654 <160542482+Emmy6654@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:09:13 +0000 Subject: [PATCH 3/4] feat(validators): add recipient deduplicator to prevent double-payment of duplicate accounts - Add deduplicateRecipients() with 'merge' and 'reject' modes - Merge mode consolidates duplicate accounts by summing their ratios - Reject mode throws DuplicateRecipientError listing all duplicate IDs - Case-insensitive comparison for Stellar account IDs (G... addresses) - Integrated into paymentValidator.ts for pre-submission checks - Integrated into splitPreview.ts to show merged entries - Includes unit tests for merge, reject, case-insensitivity, and edge cases --- src/paymentValidator.ts | 5 +- src/splitPreview.ts | 6 +- src/validators/recipientDeduplicator.ts | 86 ++++++++++++++ test/recipientDeduplicator.test.ts | 150 ++++++++++++++++++++++++ 4 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 src/validators/recipientDeduplicator.ts create mode 100644 test/recipientDeduplicator.test.ts diff --git a/src/paymentValidator.ts b/src/paymentValidator.ts index 0e33c12..f722bcd 100644 --- a/src/paymentValidator.ts +++ b/src/paymentValidator.ts @@ -1,5 +1,6 @@ import type { Invoice } from "./types.js"; import { isExpired } from "./utils.js"; +import { deduplicateRecipients } from "./validators/recipientDeduplicator.js"; export interface PaymentValidation { valid: boolean; @@ -23,7 +24,9 @@ export function computePaymentValidation( errors.push("Invoice is not pending"); } - const totalDue = invoice.recipients.reduce((sum, recipient) => sum + recipient.amount, 0n); + // Deduplicate recipients before ratio-sum validation + const dedupedRecipients = deduplicateRecipients(invoice.recipients, "merge"); + const totalDue = dedupedRecipients.reduce((sum, recipient) => sum + recipient.amount, 0n); if (invoice.funded + amount > totalDue) { errors.push("Payment amount exceeds invoice remaining balance"); } diff --git a/src/splitPreview.ts b/src/splitPreview.ts index 29fce82..e9b4ba7 100644 --- a/src/splitPreview.ts +++ b/src/splitPreview.ts @@ -1,4 +1,5 @@ import type { Invoice, SplitRule, SplitPreviewEntry } from "./types.js"; +import { deduplicateRecipients } from "./validators/recipientDeduplicator.js"; /** * Apply a single {@link SplitRule} against a funded amount. @@ -44,9 +45,10 @@ function proportionalFallback( invoice: Invoice, funded: bigint ): SplitPreviewEntry[] { - const totalOwed = invoice.recipients.reduce((sum, r) => sum + r.amount, 0n); + const deduped = deduplicateRecipients(invoice.recipients, "merge"); + const totalOwed = deduped.reduce((sum, r) => sum + r.amount, 0n); const denominator = totalOwed === 0n ? 1n : totalOwed; - return invoice.recipients.map((r) => ({ + return deduped.map((r) => ({ recipient: r.address, amount: (funded * r.amount) / denominator, })); diff --git a/src/validators/recipientDeduplicator.ts b/src/validators/recipientDeduplicator.ts new file mode 100644 index 0000000..b6a4fd5 --- /dev/null +++ b/src/validators/recipientDeduplicator.ts @@ -0,0 +1,86 @@ +/** + * Recipient Deduplicator — detects and consolidates duplicate Stellar account + * IDs in invoice split configurations. + * + * Prevents double-payment of the same account by either merging duplicate + * entries (summing their ratios) or rejecting them outright. + */ + +import { DuplicateRecipientError } from "../errors.js"; +import type { Recipient } from "../types.js"; + +/** + * Deduplication mode. + * + * - `merge` – Consolidate duplicate accounts by summing their ratios. + * - `reject` – Throw DuplicateRecipientError when duplicates are found. + */ +export type DedupMode = "merge" | "reject"; + +/** + * Normalise a Stellar account ID for case-insensitive comparison. + * Stellar account IDs start with 'G' and are base32-encoded, but callers + * sometimes pass them in mixed case. + */ +function normalizeAccountId(address: string): string { + return address.toUpperCase(); +} + +/** + * Deduplicate a list of recipients. + * + * In `merge` mode, duplicate account IDs have their `amount` values summed + * and appear as a single entry. In `reject` mode, any duplicate throws + * a {@link DuplicateRecipientError}. + * + * Comparison is case-insensitive — both "GABC..." and "gabc..." are treated + * as the same account. + * + * @param recipients - The raw list of recipient shares. + * @param mode - How to handle duplicates. + * @returns A deduplicated list of recipients. + * @throws DuplicateRecipientError when mode is `reject` and duplicates exist. + */ +export function deduplicateRecipients( + recipients: Recipient[], + mode: DedupMode = "merge" +): Recipient[] { + // Count occurrences of each normalized address + const counts = new Map(); + const firstEntry = new Map(); + + for (const r of recipients) { + const norm = normalizeAccountId(r.address); + counts.set(norm, (counts.get(norm) ?? 0) + 1); + if (!firstEntry.has(norm)) { + firstEntry.set(norm, { address: r.address, amount: r.amount }); + } else if (mode === "merge") { + const existing = firstEntry.get(norm)!; + existing.amount += r.amount; + } + } + + if (mode === "reject") { + const duplicates: string[] = []; + for (const [norm, count] of counts) { + if (count > 1) { + duplicates.push(firstEntry.get(norm)!.address); + } + } + if (duplicates.length > 0) { + throw new DuplicateRecipientError(duplicates); + } + } + + // Return entries in original order (first occurrence of each address) + const result: Recipient[] = []; + const added = new Set(); + for (const r of recipients) { + const norm = normalizeAccountId(r.address); + if (added.has(norm)) continue; + result.push(firstEntry.get(norm)!); + added.add(norm); + } + + return result; +} diff --git a/test/recipientDeduplicator.test.ts b/test/recipientDeduplicator.test.ts new file mode 100644 index 0000000..7a6c4cb --- /dev/null +++ b/test/recipientDeduplicator.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from "vitest"; +import { deduplicateRecipients } from "../src/validators/recipientDeduplicator.js"; +import { DuplicateRecipientError } from "../src/errors.js"; +import type { Recipient } from "../src/types.js"; + +function r(address: string, amount: bigint): Recipient { + return { address, amount }; +} + +describe("deduplicateRecipients", () => { + describe("merge mode", () => { + it("returns the same list when there are no duplicates", () => { + const recipients = [ + r("GABC00000000000000000000000000000000000000000000000001", 100n), + r("GABC00000000000000000000000000000000000000000000000002", 200n), + ]; + const result = deduplicateRecipients(recipients, "merge"); + expect(result).toHaveLength(2); + expect(result[0].address).toBe(recipients[0].address); + expect(result[0].amount).toBe(100n); + expect(result[1].address).toBe(recipients[1].address); + expect(result[1].amount).toBe(200n); + }); + + it("merges duplicate addresses by summing their ratios", () => { + const recipients = [ + r("GABC00000000000000000000000000000000000000000000000001", 100n), + r("GABC00000000000000000000000000000000000000000000000002", 200n), + r("GABC00000000000000000000000000000000000000000000000001", 50n), + ]; + const result = deduplicateRecipients(recipients, "merge"); + expect(result).toHaveLength(2); + + const deduped = result.find( + (x) => x.address === "GABC00000000000000000000000000000000000000000000000001" + ); + expect(deduped).toBeDefined(); + expect(deduped!.amount).toBe(150n); // 100 + 50 + }); + + it("preserves the first occurrence's address casing", () => { + const recipients = [ + r("GABC00000000000000000000000000000000000000000000000001", 100n), + r("gabc00000000000000000000000000000000000000000000000001", 50n), + ]; + const result = deduplicateRecipients(recipients, "merge"); + expect(result).toHaveLength(1); + // Should preserve the first occurrence's casing + expect(result[0].address).toBe("GABC00000000000000000000000000000000000000000000000001"); + expect(result[0].amount).toBe(150n); + }); + + it("handles multiple groups of duplicates", () => { + const recipients = [ + r("GA11111111111111111111111111111111111111111111111111", 10n), + r("GB22222222222222222222222222222222222222222222222222", 20n), + r("GA11111111111111111111111111111111111111111111111111", 30n), + r("GB22222222222222222222222222222222222222222222222222", 40n), + r("GC33333333333333333333333333333333333333333333333333", 50n), + ]; + const result = deduplicateRecipients(recipients, "merge"); + expect(result).toHaveLength(3); + + const a = result.find((x) => x.address === "GA11111111111111111111111111111111111111111111111111"); + expect(a!.amount).toBe(40n); // 10 + 30 + + const b = result.find((x) => x.address === "GB22222222222222222222222222222222222222222222222222"); + expect(b!.amount).toBe(60n); // 20 + 40 + + const c = result.find((x) => x.address === "GC33333333333333333333333333333333333333333333333333"); + expect(c!.amount).toBe(50n); + }); + + it("handles empty list", () => { + const result = deduplicateRecipients([], "merge"); + expect(result).toEqual([]); + }); + + it("is case-insensitive for Stellar account IDs", () => { + const recipients = [ + r("GABC00000000000000000000000000000000000000000000000001", 100n), + r("gabc00000000000000000000000000000000000000000000000001", 200n), + r("GAbc00000000000000000000000000000000000000000000000001", 300n), + ]; + const result = deduplicateRecipients(recipients, "merge"); + expect(result).toHaveLength(1); + expect(result[0].amount).toBe(600n); + }); + }); + + describe("reject mode", () => { + it("returns the same list when there are no duplicates", () => { + const recipients = [ + r("GABC00000000000000000000000000000000000000000000000001", 100n), + r("GABC00000000000000000000000000000000000000000000000002", 200n), + ]; + const result = deduplicateRecipients(recipients, "reject"); + expect(result).toHaveLength(2); + expect(result[0].address).toBe(recipients[0].address); + }); + + it("throws DuplicateRecipientError when duplicates exist", () => { + const recipients = [ + r("GABC00000000000000000000000000000000000000000000000001", 100n), + r("GABC00000000000000000000000000000000000000000000000002", 200n), + r("GABC00000000000000000000000000000000000000000000000001", 50n), + ]; + expect(() => deduplicateRecipients(recipients, "reject")).toThrow( + DuplicateRecipientError + ); + }); + + it("includes duplicated addresses in the error", () => { + const recipients = [ + r("GABC00000000000000000000000000000000000000000000000001", 100n), + r("GABC00000000000000000000000000000000000000000000000001", 200n), + ]; + try { + deduplicateRecipients(recipients, "reject"); + expect.fail("Expected error to be thrown"); + } catch (err) { + expect(err).toBeInstanceOf(DuplicateRecipientError); + const dupErr = err as DuplicateRecipientError; + expect(dupErr.duplicateAddresses.length).toBeGreaterThan(0); + } + }); + + it("is case-insensitive for duplicate detection", () => { + const recipients = [ + r("GABC00000000000000000000000000000000000000000000000001", 100n), + r("gabc00000000000000000000000000000000000000000000000001", 200n), + ]; + expect(() => deduplicateRecipients(recipients, "reject")).toThrow( + DuplicateRecipientError + ); + }); + }); + + describe("default mode", () => { + it("defaults to merge mode", () => { + const recipients = [ + r("GABC00000000000000000000000000000000000000000000000001", 100n), + r("GABC00000000000000000000000000000000000000000000000001", 50n), + ]; + const result = deduplicateRecipients(recipients); + expect(result).toHaveLength(1); + expect(result[0].amount).toBe(150n); + }); + }); +}); From 33cefc0694ce908d2a5c0ef3489c4c97584bbce9 Mon Sep 17 00:00:00 2001 From: Emmy6654 <160542482+Emmy6654@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:09:23 +0000 Subject: [PATCH 4/4] feat(errors): add Horizon error classifier for structured result code taxonomy - Add classifyHorizonError() mapping tx_*/op_* codes to structured taxonomy - Covers all documented tx_* codes (tx_bad_seq, tx_insufficient_fee, etc.) - Covers all documented op_* codes (op_no_trust, op_underfunded, etc.) - Returns HorizonErrorClassification with isRetryable, severity, description - Unknown codes return { severity: 'unknown', isRetryable: false } - Integrated into RetryEngine.classifyError() for automatic retry decisions - Add ClassifiedHorizonError wrapper for runtime classification - Includes unit tests for tx_bad_seq (retryable), op_no_trust (not retryable), op_underfunded (not retryable), and edge cases --- src/horizonErrorClassifier.ts | 519 ++++++++++++++++++++++++++++ src/retryEngine.ts | 10 + test/horizonErrorClassifier.test.ts | 180 ++++++++++ 3 files changed, 709 insertions(+) create mode 100644 src/horizonErrorClassifier.ts create mode 100644 test/horizonErrorClassifier.test.ts diff --git a/src/horizonErrorClassifier.ts b/src/horizonErrorClassifier.ts new file mode 100644 index 0000000..c0b535a --- /dev/null +++ b/src/horizonErrorClassifier.ts @@ -0,0 +1,519 @@ +/** + * Horizon Error Classifier — maps Stellar transaction/operation result codes to + * a structured error taxonomy with retry advice, severity, and descriptions. + * + * Integrates with RetryEngine and FallbackChain to drive automated retry and + * failover decisions based on the actual on-chain error rather than opaque + * string matching. + */ + +import { HorizonErrorClassification } from "./errors.js"; + +// --------------------------------------------------------------------------- +// Type definitions local to this module +// --------------------------------------------------------------------------- + +/** Severity levels for classified errors. */ +export type ErrorSeverity = "low" | "medium" | "high" | "critical" | "unknown"; + +/** + * Result codes can be either a single string (one code) or an array of + * strings (hierarchical result codes, e.g. ["tx_failed", "op_no_trust"]). + */ +export type RawResultCodes = + | { transactionResult: string; operationsResults?: string[] } + | string + | string[]; + +// --------------------------------------------------------------------------- +// Known tx_* result code taxonomy +// --------------------------------------------------------------------------- + +interface TxCodeEntry { + isRetryable: boolean; + severity: ErrorSeverity; + description: string; + suggestedAction: string; +} + +const TX_CODE_MAP: Record = { + tx_success: { + isRetryable: false, + severity: "low", + description: "Transaction was applied successfully.", + suggestedAction: "No action needed.", + }, + tx_failed: { + isRetryable: false, + severity: "high", + description: "One of the operations in the transaction failed.", + suggestedAction: "Check the individual operation result codes for details.", + }, + tx_too_early: { + isRetryable: true, + severity: "medium", + description: + "The transaction's time bounds have not yet been reached. The minimum time condition is in the future.", + suggestedAction: "Wait until the time bounds window opens and resubmit.", + }, + tx_too_late: { + isRetryable: false, + severity: "high", + description: + "The transaction's time bounds have passed. The maximum time condition is in the past.", + suggestedAction: "Create a new transaction with updated time bounds.", + }, + tx_missing_operation: { + isRetryable: false, + severity: "high", + description: "The transaction envelope contains zero operations.", + suggestedAction: "Add at least one operation to the transaction.", + }, + tx_bad_seq: { + isRetryable: true, + severity: "medium", + description: + "The transaction's sequence number is incorrect. The account's current sequence number does not match.", + suggestedAction: + "Fetch the latest account sequence number and rebuild the transaction.", + }, + tx_bad_auth: { + isRetryable: false, + severity: "high", + description: + "The transaction has insufficient or incorrect signatures to meet the account's thresholds.", + suggestedAction: + "Ensure all required signers have signed with the correct weights.", + }, + tx_insufficient_balance: { + isRetryable: false, + severity: "high", + description: + "The source account does not have enough lumens to cover the transaction fee and minimum balance.", + suggestedAction: "Fund the source account with additional lumens.", + }, + tx_no_source_account: { + isRetryable: false, + severity: "high", + description: + "The transaction's source account does not exist on the ledger.", + suggestedAction: + "Create the source account first (it must be funded above the minimum reserve).", + }, + tx_insufficient_fee: { + isRetryable: true, + severity: "medium", + description: + "The transaction fee is too low for the current network conditions.", + suggestedAction: + "Increase the base fee and resubmit (consider surge pricing).", + }, + tx_bad_auth_extra: { + isRetryable: false, + severity: "medium", + description: + "The transaction includes an unused signature (extra signer not needed).", + suggestedAction: + "Remove the unused signature(s) and resubmit.", + }, + tx_internal_error: { + isRetryable: true, + severity: "critical", + description: + "The transaction failed due to an internal network error.", + suggestedAction: + "Retry the transaction; if the issue persists, switch to a different Horizon endpoint.", + }, + tx_not_supported: { + isRetryable: false, + severity: "critical", + description: + "The transaction type or operation is not supported by this network.", + suggestedAction: + "Verify the network configuration and ensure the operation is supported.", + }, + tx_bad_min_time: { + isRetryable: false, + severity: "high", + description: + "The transaction's minimum time bound is not valid.", + suggestedAction: + "Ensure the minTime value is a valid UNIX timestamp and not in an invalid range.", + }, + tx_bad_max_time: { + isRetryable: false, + severity: "high", + description: + "The transaction's maximum time bound is not valid.", + suggestedAction: + "Ensure the maxTime value is a valid UNIX timestamp ahead of the minTime.", + }, + tx_soroban_invalid: { + isRetryable: false, + severity: "high", + description: + "The Soroban transaction is invalid (e.g. bad footprint, invalid resource config).", + suggestedAction: + "Review the Soroban transaction parameters and simulation output.", + }, +}; + +// --------------------------------------------------------------------------- +// Known op_* result code taxonomy +// --------------------------------------------------------------------------- + +const OP_CODE_MAP: Record = { + op_inner: { + isRetryable: false, + severity: "medium", + description: + "The inner object of the operation result is missing or invalid.", + suggestedAction: "Review the operation details for missing fields.", + }, + op_bad_auth: { + isRetryable: false, + severity: "high", + description: + "The operation requires additional signatures (e.g. source account not the same as the operation source).", + suggestedAction: + "Ensure the operation source account has signed the transaction.", + }, + op_no_source_account: { + isRetryable: false, + severity: "high", + description: "The operation's source account does not exist.", + suggestedAction: "Create and fund the source account first.", + }, + op_not_supported: { + isRetryable: false, + severity: "critical", + description: "The operation is not supported on this network.", + suggestedAction: + "Check the network passphrase and ensure the operation type is valid.", + }, + op_too_many_subentries: { + isRetryable: true, + severity: "high", + description: + "The operation would cause the account to exceed the maximum number of subentries.", + suggestedAction: + "Remove unused trustlines, offers, or data entries from the account, then retry.", + }, + op_exceeded_work_limit: { + isRetryable: true, + severity: "medium", + description: + "The Soroban operation exceeded its compute (CPU instruction) budget.", + suggestedAction: + "Increase the instruction budget in the Soroban transaction data or optimise the contract call.", + }, + op_underfunded: { + isRetryable: false, + severity: "high", + description: + "The account does not have sufficient funds to complete the operation (e.g. payment exceeds available balance).", + suggestedAction: "Fund the source account with additional tokens or lumens.", + }, + op_no_trust: { + isRetryable: false, + severity: "high", + description: + "The destination account does not have a trustline for the asset being sent.", + suggestedAction: + "The recipient must establish a trustline for the asset before receiving it.", + }, + op_not_authorized: { + isRetryable: false, + severity: "high", + description: + "The trustline for the asset has not been authorised by the asset issuer (applicable to AUTH_REQUIRED assets).", + suggestedAction: + "Contact the asset issuer to authorize the trustline.", + }, + op_line_full: { + isRetryable: false, + severity: "high", + description: + "The receiving account's trustline has reached its limit for the asset.", + suggestedAction: + "Increase the trustline limit or reduce the payment amount.", + }, + op_no_issuer: { + isRetryable: false, + severity: "critical", + description: "The asset issuer account does not exist on the ledger.", + suggestedAction: + "Verify the asset code and issuer address are correct.", + }, + op_too_few_offers: { + isRetryable: false, + severity: "medium", + description: + "There are not enough offers on the order book to complete the path payment at the requested rate.", + suggestedAction: + "Try a smaller amount, relax the destination-amount constraints, or use a different path.", + }, + op_cross_self: { + isRetryable: true, + severity: "medium", + description: + "The path payment would cross its own offers, creating a loop.", + suggestedAction: + "Adjust the payment path or amount to avoid crossing own offers.", + }, + op_sell_no_trust: { + isRetryable: false, + severity: "high", + description: + "The selling account does not have a trustline for the asset being sold.", + suggestedAction: + "Establish a trustline for the asset you are attempting to sell.", + }, + op_buy_no_trust: { + isRetryable: false, + severity: "high", + description: + "The buying account does not have a trustline for the asset being purchased.", + suggestedAction: + "Establish a trustline for the asset you are attempting to buy.", + }, + op_sell_no_issuer: { + isRetryable: false, + severity: "critical", + description: "The selling asset's issuer does not exist.", + suggestedAction: "Verify the selling asset code and issuer.", + }, + op_buy_no_issuer: { + isRetryable: false, + severity: "critical", + description: "The buying asset's issuer does not exist.", + suggestedAction: "Verify the buying asset code and issuer.", + }, + op_sell_underfunded: { + isRetryable: false, + severity: "high", + description: + "The account does not have enough of the selling asset to complete the path payment.", + suggestedAction: "Fund the account with more of the selling asset.", + }, + op_buy_underfunded: { + isRetryable: false, + severity: "high", + description: + "The account does not have enough lumens to cover the reserve for the buying asset trustline.", + suggestedAction: "Fund the account with additional lumens.", + }, + op_sell_line_full: { + isRetryable: false, + severity: "high", + description: + "The receiving account's trustline for the selling asset has reached its limit.", + suggestedAction: + "Increase the trustline limit on the receiving account.", + }, + op_buy_line_full: { + isRetryable: false, + severity: "high", + description: + "The receiving account's trustline for the buying asset has reached its limit.", + suggestedAction: + "Increase the trustline limit on the receiving account.", + }, + op_low_reserve: { + isRetryable: false, + severity: "high", + description: + "The source account does not have enough lumens to meet the minimum reserve after the operation.", + suggestedAction: + "Fund the source account with additional lumens to cover the reserve increase.", + }, + op_too_many_sponsoring: { + isRetryable: false, + severity: "high", + description: + "The account would exceed the maximum number of sponsored reserve entries.", + suggestedAction: + "Remove some existing sponsored entries or adjust the operation.", + }, + op_sponsorship_not_found: { + isRetryable: false, + severity: "medium", + description: + "The sponsorship relationship referenced in the operation does not exist.", + suggestedAction: + "Verify the sponsorship ID and account making the call.", + }, + op_sell_no_dst: { + isRetryable: false, + severity: "high", + description: "The destination account for the sell does not exist.", + suggestedAction: "Verify the destination account address.", + }, + op_buy_no_dst: { + isRetryable: false, + severity: "high", + description: "The destination account for the buy does not exist.", + suggestedAction: "Verify the destination account address.", + }, + op_malformed: { + isRetryable: false, + severity: "high", + description: + "The operation input is malformed in some way (bad asset code, invalid amount, etc.).", + suggestedAction: + "Check all operation parameters for correctness (asset codes, amounts, addresses).", + }, + op_sell_malformed: { + isRetryable: false, + severity: "high", + description: + "The path-payment sell operation is malformed.", + suggestedAction: + "Check the sell asset, amount, and destination parameters.", + }, + op_buy_malformed: { + isRetryable: false, + severity: "high", + description: + "The path-payment buy operation is malformed.", + suggestedAction: + "Check the buy asset, amount, and destination parameters.", + }, + op_offer_malformed: { + isRetryable: false, + severity: "high", + description: + "The manage-sell/buy-offer operation is malformed.", + suggestedAction: + "Check the offer parameters (price, amount, assets) for correctness.", + }, + op_soroban_resource_limit_exceeded: { + isRetryable: true, + severity: "medium", + description: + "The Soroban operation exceeded its resource limits (ledger entry reads, writes, etc.).", + suggestedAction: + "Increase the resource limits in the Soroban transaction or optimise the contract call.", + }, + // Additional Soroban-related codes (from HostError / xdr.ScError) + op_soroban_storage_full: { + isRetryable: true, + severity: "high", + description: + "The Soroban host ran out of temporary storage during execution.", + suggestedAction: + "Retry the transaction; if it persists, reduce the storage footprint or wait for ledger compaction.", + }, +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Classify a set of Horizon transaction/operation result codes into a + * structured error taxonomy. + * + * Accepts the raw {@link HorizonApi.TransactionFailedResultCodes} object + * returned by `server.submitTransaction()`, as well as plain strings or arrays + * of strings for convenience. + * + * @param resultCodes - One or more result codes from a Horizon response. + * @returns A {@link HorizonErrorClassification} with retry advice, severity, + * human-readable description, and suggested action. + */ +export function classifyHorizonError( + resultCodes: RawResultCodes +): HorizonErrorClassification { + // Extract the transaction-level code + let txCode: string | undefined; + let opCodes: string[] = []; + + if (typeof resultCodes === "string") { + txCode = resultCodes; + } else if (Array.isArray(resultCodes)) { + // First element is tx-level, rest are op-level + if (resultCodes.length > 0) { + txCode = resultCodes[0]; + opCodes = resultCodes.slice(1); + } + } else if (typeof resultCodes === "object" && resultCodes !== null) { + txCode = (resultCodes as Record).transactionResult as string | undefined; + const opsResults = (resultCodes as Record).operationsResults; + if (Array.isArray(opsResults)) { + opCodes = opsResults.filter((r): r is string => typeof r === "string"); + } + } + + // Classify the transaction code + const txEntry = txCode ? TX_CODE_MAP[txCode] : undefined; + + // If we have operation results, classify each and pick the most severe + let opEntry: TxCodeEntry | undefined; + let matchedOpCode: string | undefined; + + if (opCodes.length > 0) { + for (const opCode of opCodes) { + const entry = OP_CODE_MAP[opCode]; + if (entry) { + if (!opEntry || severityRank(entry.severity) > severityRank(opEntry.severity)) { + opEntry = entry; + matchedOpCode = opCode; + } + } + } + } + + // Prefer the most informative classification + const primaryEntry: TxCodeEntry | undefined = opEntry ?? txEntry; + + if (!primaryEntry) { + return { + code: txCode ?? "unknown", + isRetryable: false, + severity: "unknown", + description: `Unrecognised result code: ${txCode ?? "none"}.${opCodes.length > 0 ? ` Operation codes: ${opCodes.join(", ")}` : ""}`, + suggestedAction: + "Review the raw result codes and consult the Stellar documentation.", + operationCode: matchedOpCode, + }; + } + + return { + code: txCode ?? "unknown", + isRetryable: primaryEntry.isRetryable, + severity: primaryEntry.severity, + description: primaryEntry.description, + suggestedAction: primaryEntry.suggestedAction, + operationCode: matchedOpCode ?? opCodes[0], + }; +} + +/** + * Convenience helper: returns `true` when the classification indicates the + * error is safe to retry. + */ +export function isHorizonErrorRetryable( + resultCodes: RawResultCodes +): boolean { + return classifyHorizonError(resultCodes).isRetryable; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +const SEVERITY_RANK: Record = { + low: 10, + medium: 20, + high: 30, + critical: 40, + unknown: 0, +}; + +function severityRank(severity: ErrorSeverity): number { + return SEVERITY_RANK[severity] ?? 0; +} + +// Re-export the TxCodeEntry type for internal use by other modules +export type { TxCodeEntry }; diff --git a/src/retryEngine.ts b/src/retryEngine.ts index 358ec2e..e39645b 100644 --- a/src/retryEngine.ts +++ b/src/retryEngine.ts @@ -1,5 +1,6 @@ import { TelemetryCollector } from "./telemetryCollector.js"; import { CircuitOpenError } from "./errors.js"; +import { classifyHorizonError } from "./horizonErrorClassifier.js"; export interface RetryStrategy { maxAttempts: number; @@ -25,6 +26,15 @@ type ErrorClass = "transient" | "rateLimit" | "contract"; function classifyError(error: unknown): ErrorClass { if (!(error instanceof Error)) return "transient"; const msg = error.message; + + // Use horizonErrorClassifier when result codes are available on the error + const errorObj = error as Record; + if (errorObj.resultCodes) { + const classification = classifyHorizonError(errorObj.resultCodes as string | string[]); + if (classification.isRetryable) return "transient"; + return "contract"; + } + if (/Error\(Contract,\s*#\d+\)/i.test(msg)) return "contract"; if (/429|rate.?limit|too many requests/i.test(msg)) return "rateLimit"; return "transient"; diff --git a/test/horizonErrorClassifier.test.ts b/test/horizonErrorClassifier.test.ts new file mode 100644 index 0000000..405ca5e --- /dev/null +++ b/test/horizonErrorClassifier.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from "vitest"; +import { classifyHorizonError, isHorizonErrorRetryable } from "../src/horizonErrorClassifier.js"; +import type { HorizonErrorClassification } from "../src/types.js"; + +describe("classifyHorizonError", () => { + // ----------------------------------------------------------------------- + // Single string result codes + // ----------------------------------------------------------------------- + + it("classifies tx_bad_seq as retryable", () => { + const result = classifyHorizonError("tx_bad_seq"); + expect(result.code).toBe("tx_bad_seq"); + expect(result.isRetryable).toBe(true); + expect(result.severity).toBe("medium"); + expect(result.description).toBeTruthy(); + expect(result.suggestedAction).toBeTruthy(); + }); + + it("classifies tx_success as not retryable", () => { + const result = classifyHorizonError("tx_success"); + expect(result.isRetryable).toBe(false); + expect(result.severity).toBe("low"); + }); + + it("classifies tx_too_early as retryable", () => { + const result = classifyHorizonError("tx_too_early"); + expect(result.isRetryable).toBe(true); + }); + + it("classifies tx_too_late as not retryable", () => { + const result = classifyHorizonError("tx_too_late"); + expect(result.isRetryable).toBe(false); + expect(result.severity).toBe("high"); + }); + + it("classifies tx_insufficient_fee as retryable", () => { + const result = classifyHorizonError("tx_insufficient_fee"); + expect(result.isRetryable).toBe(true); + }); + + it("classifies tx_no_source_account as not retryable", () => { + const result = classifyHorizonError("tx_no_source_account"); + expect(result.isRetryable).toBe(false); + expect(result.severity).toBe("high"); + }); + + // ----------------------------------------------------------------------- + // Operation result codes (via array format) + // ----------------------------------------------------------------------- + + it("classifies op_no_trust as not retryable (from array)", () => { + const result = classifyHorizonError(["tx_failed", "op_no_trust"]); + expect(result.isRetryable).toBe(false); + expect(result.operationCode).toBe("op_no_trust"); + }); + + it("classifies op_underfunded as not retryable", () => { + const result = classifyHorizonError(["tx_failed", "op_underfunded"]); + expect(result.isRetryable).toBe(false); + expect(result.severity).toBe("high"); + }); + + it("classifies op_exceeded_work_limit as retryable", () => { + const result = classifyHorizonError(["tx_failed", "op_exceeded_work_limit"]); + expect(result.isRetryable).toBe(true); + expect(result.severity).toBe("medium"); + }); + + it("classifies op_too_many_subentries as retryable", () => { + const result = classifyHorizonError(["tx_failed", "op_too_many_subentries"]); + expect(result.isRetryable).toBe(true); + }); + + it("classifies op_no_issuer as not retryable (critical)", () => { + const result = classifyHorizonError(["tx_failed", "op_no_issuer"]); + expect(result.isRetryable).toBe(false); + expect(result.severity).toBe("critical"); + }); + + it("classifies op_cross_self as retryable", () => { + const result = classifyHorizonError(["tx_failed", "op_cross_self"]); + expect(result.isRetryable).toBe(true); + }); + + it("classifies op_sell_no_trust as not retryable", () => { + const result = classifyHorizonError(["tx_failed", "op_sell_no_trust"]); + expect(result.isRetryable).toBe(false); + expect(result.severity).toBe("high"); + }); + + it("classifies op_malformed as not retryable", () => { + const result = classifyHorizonError(["tx_failed", "op_malformed"]); + expect(result.isRetryable).toBe(false); + }); + + // ----------------------------------------------------------------------- + // Object format (HorizonApi.TransactionFailedResultCodes shape) + // ----------------------------------------------------------------------- + + it("accepts object format with transactionResult and operationsResults", () => { + const result = classifyHorizonError({ + transactionResult: "tx_failed", + operationsResults: ["op_no_trust"], + } as any); + expect(result.isRetryable).toBe(false); + expect(result.operationCode).toBe("op_no_trust"); + }); + + it("accepts object format with transactionResult only", () => { + const result = classifyHorizonError({ + transactionResult: "tx_bad_seq", + } as any); + expect(result.isRetryable).toBe(true); + expect(result.code).toBe("tx_bad_seq"); + }); + + // ----------------------------------------------------------------------- + // Unknown codes + // ----------------------------------------------------------------------- + + it("returns severity:unknown and isRetryable:false for unknown codes", () => { + const result = classifyHorizonError("tx_some_unknown_code"); + expect(result.isRetryable).toBe(false); + expect(result.severity).toBe("unknown"); + }); + + it("returns severity:unknown for unknown operation codes with no known tx code", () => { + // tx_failed is known (severity high), but op_some_unknown is not. + // When we have a known tx code but unknown op code, the tx code's + // classification is used as a fallback. + const result = classifyHorizonError(["tx_failed", "op_some_unknown"]); + expect(result.isRetryable).toBe(false); + // Falls back to tx_failed classification + expect(result.severity).toBe("high"); + }); + + it("returns severity:unknown for completely unknown codes", () => { + const result = classifyHorizonError("tx_xyz_nonexistent"); + expect(result.isRetryable).toBe(false); + expect(result.severity).toBe("unknown"); + }); + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + it("handles empty array gracefully", () => { + const result = classifyHorizonError([]); + expect(result.isRetryable).toBe(false); + expect(result.severity).toBe("unknown"); + }); + + it("handles null gracefully (as unknown)", () => { + const result = classifyHorizonError(null as any); + expect(result.isRetryable).toBe(false); + expect(result.severity).toBe("unknown"); + }); +}); + +describe("isHorizonErrorRetryable", () => { + it("returns true for tx_bad_seq", () => { + expect(isHorizonErrorRetryable("tx_bad_seq")).toBe(true); + }); + + it("returns false for op_no_trust", () => { + expect(isHorizonErrorRetryable(["tx_failed", "op_no_trust"])).toBe(false); + }); + + it("returns false for op_underfunded", () => { + expect(isHorizonErrorRetryable(["tx_failed", "op_underfunded"])).toBe(false); + }); + + it("returns false for tx_too_late", () => { + expect(isHorizonErrorRetryable("tx_too_late")).toBe(false); + }); + + it("returns true for tx_internal_error", () => { + expect(isHorizonErrorRetryable("tx_internal_error")).toBe(true); + }); +});