From 86e21904b8bdd4d552f3a0f84db109a955ed0e87 Mon Sep 17 00:00:00 2001 From: Isaac Ochuko Date: Wed, 29 Jul 2026 16:29:04 +0000 Subject: [PATCH 1/4] Add RollbackCoordinator for split payment rollback reconciliation --- src/client.ts | 26 +++++++ src/errors.ts | 17 +++++ src/index.ts | 8 +++ src/snapshot.ts | 37 +++++++++- src/splitRollbackCoordinator.ts | 123 ++++++++++++++++++++++++++++++++ src/types.ts | 41 +++++++++++ 6 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/splitRollbackCoordinator.ts diff --git a/src/client.ts b/src/client.ts index d7b0d69..0b32abc 100644 --- a/src/client.ts +++ b/src/client.ts @@ -231,6 +231,7 @@ import { PriorityQueue } from "./priorityQueue.js"; import type { RequestPriority } from "./priorityQueue.js"; import { IdempotencyManager } from "./idempotency.js"; import type { IdempotencyConfig } from "./idempotency.js"; +import { RollbackCoordinator } from "./splitRollbackCoordinator.js"; import { validateInvoicePayload } from "./payloadGuard.js"; import { validateSplitRatiosOrThrow } from "./validators/splitRatioValidator.js"; import type { SplitConfig } from "./types.js"; @@ -620,6 +621,7 @@ export class StellarSplitClient extends TypedEventEmitter { private _retryOptions: RetryOptions | null = null; private _horizonReader: HorizonFallbackReader | null = null; private _idempotency: IdempotencyManager | null = null; + private _rollbackCoordinator: RollbackCoordinator | null = null; private _pool: ConnectionPool | null = null; /** * Effective pool size chosen at construction (or 0 when pooling is off). @@ -2099,6 +2101,19 @@ export class StellarSplitClient extends TypedEventEmitter { const result = await this._submitWaterfallTx(params.payer, operations); this._cache?.invalidate(params.invoiceId); + + // Record a rollback checkpoint for the submitted legs. The on-chain + // submission is atomic (all-or-nothing), so every funded step succeeded + // together; downstream app-layer failures (e.g. webhook delivery) are + // reconciled by callers via RollbackCoordinator.markLegFailed. + const coordinator = this.getRollbackCoordinator(); + coordinator.begin( + result.txHash, + params.invoiceId, + fundedSteps.map((step) => ({ recipient: step.recipient, amount: step.amount })), + ); + fundedSteps.forEach((_, index) => coordinator.markLegSuccess(result.txHash, index)); + return { txHash: result.txHash }; } @@ -2370,6 +2385,17 @@ export class StellarSplitClient extends TypedEventEmitter { return this._advancedCircuitBreaker; } + /** + * The rollback coordinator tracking split-payment leg checkpoints created + * by `submitPayment`'s waterfall path. Lazily instantiated on first use. + */ + getRollbackCoordinator(): RollbackCoordinator { + if (!this._rollbackCoordinator) { + this._rollbackCoordinator = new RollbackCoordinator(this._idempotency ?? undefined); + } + return this._rollbackCoordinator; + } + /** * The optimistic UI cache, or null when `optimisticCache` was not passed * to the constructor. Use `client.optimisticCache?.onRollback(...)` to diff --git a/src/errors.ts b/src/errors.ts index 47f1a5c..2ba6db0 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1699,3 +1699,20 @@ export class ClassifiedHorizonError extends StellarSplitError { export function isClassifiedHorizonError(err: unknown): err is ClassifiedHorizonError { return err instanceof ClassifiedHorizonError; } + +// --------------------------------------------------------------------------- +// Split rollback coordinator errors +// --------------------------------------------------------------------------- + +/** Thrown when a rollback coordinator operation references an unknown split checkpoint or leg. */ +export class UnknownSplitError extends StellarSplitError { + constructor(splitId: string) { + super(`No rollback checkpoint found for split: ${splitId}`, "UNKNOWN_SPLIT", { splitId }); + this.name = "UnknownSplitError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isUnknownSplitError(err: unknown): err is UnknownSplitError { + return err instanceof UnknownSplitError; +} diff --git a/src/index.ts b/src/index.ts index 38d8fe1..bed543d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -192,6 +192,9 @@ export { ClassifiedHorizonError, isClassifiedHorizonError, HorizonErrorClassification, + // New: Split Rollback Coordinator + UnknownSplitError, + isUnknownSplitError, } from "./errors.js"; // --------------------------------------------------------------------------- @@ -808,6 +811,11 @@ export type { export { IdempotencyManager } from "./idempotency.js"; export type { IdempotencyConfig } from "./idempotency.js"; +export { RollbackCoordinator } from "./splitRollbackCoordinator.js"; +export type { SplitRollbackEventMap } from "./splitRollbackCoordinator.js"; +export type { SplitRollbackRecord } from "./snapshot.js"; +export type { SplitLeg, SplitLegState, SplitResult, SplitRollbackCheckpoint } from "./types.js"; + export { validateInvoicePayload, PayloadSizeError, diff --git a/src/snapshot.ts b/src/snapshot.ts index 6923437..ee92619 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -1,5 +1,5 @@ import { createHash } from "crypto"; -import type { Invoice, Payment } from "./types.js"; +import type { Invoice, Payment, SplitLeg, SplitRollbackCheckpoint } from "./types.js"; export interface InvoiceSnapshot { snapshotId: string; @@ -31,3 +31,38 @@ export function snapshotInvoice(invoice: Invoice): InvoiceSnapshot { payments: frozenPayments, }); } + +/** An immutable, persisted record of a split rollback checkpoint. */ +export interface SplitRollbackRecord { + snapshotId: string; + capturedAt: number; + checkpoint: Readonly; +} + +/** + * Freeze a split rollback checkpoint into a persistable record, mirroring + * the shape produced by {@link snapshotInvoice} for invoice state. + */ +export function snapshotSplitRollback( + checkpoint: SplitRollbackCheckpoint +): SplitRollbackRecord { + const capturedAt = Date.now(); + const snapshotId = createHash("sha256") + .update(`${checkpoint.splitId}${capturedAt}`) + .digest("hex"); + + const frozenLegs = Object.freeze( + checkpoint.legs.map((leg) => Object.freeze({ ...leg })) + ) as Readonly; + + const frozenCheckpoint = Object.freeze({ + ...checkpoint, + legs: frozenLegs, + }) as Readonly; + + return Object.freeze({ + snapshotId, + capturedAt, + checkpoint: frozenCheckpoint, + }); +} diff --git a/src/splitRollbackCoordinator.ts b/src/splitRollbackCoordinator.ts new file mode 100644 index 0000000..e5b8762 --- /dev/null +++ b/src/splitRollbackCoordinator.ts @@ -0,0 +1,123 @@ +/** + * Coordinates rollback/reconciliation of multi-recipient split payments. + * + * A split payment groups several `pay` operations for one invoice into a + * single transaction envelope (see `submitPayment`'s waterfall path). The + * on-chain submission is atomic, but application-layer acknowledgement of + * each leg (webhooks, database writes) can still partially fail after the + * transaction lands. `RollbackCoordinator` records the intended legs of a + * split as a checkpoint, lets callers mark each leg's outcome, and exposes + * `initiateRollback` to identify legs that still need corrective action. + */ + +import { IdempotencyManager } from "./idempotency.js"; +import { snapshotSplitRollback, type SplitRollbackRecord } from "./snapshot.js"; +import { UnknownSplitError } from "./errors.js"; +import { TypedEventEmitter } from "./events/TypedEventEmitter.js"; +import type { SplitLeg, SplitLegState, SplitRollbackCheckpoint } from "./types.js"; + +/** Event payloads emitted by {@link RollbackCoordinator}. */ +export interface SplitRollbackEventMap { + splitRollbackInitiated: { splitId: string; incomplete: SplitLeg[] }; + splitRollbackCompleted: { splitId: string; record: SplitRollbackRecord }; +} + +/** + * Tracks per-leg acknowledgement state for split payments and coordinates + * rollback when some legs are left in an unknown state. + */ +export class RollbackCoordinator extends TypedEventEmitter { + private readonly checkpoints = new Map(); + private readonly rollbackRecords = new Map(); + private readonly idempotency: IdempotencyManager; + + constructor(idempotency: IdempotencyManager = new IdempotencyManager()) { + super(); + this.idempotency = idempotency; + } + + /** + * Create a persistent rollback checkpoint recording all intended split + * legs. Must be called before `markLegSuccess`/`markLegFailed`. + */ + begin( + splitId: string, + invoiceId: string, + legs: Array<{ recipient: string; amount: bigint }>, + ): SplitRollbackCheckpoint { + const checkpoint: SplitRollbackCheckpoint = { + splitId, + invoiceId, + createdAt: Date.now(), + legs: legs.map((leg) => ({ ...leg, state: "pending" as SplitLegState })), + }; + this.checkpoints.set(splitId, checkpoint); + return checkpoint; + } + + /** Mark the leg at `legIndex` as successfully acknowledged. */ + markLegSuccess(splitId: string, legIndex: number): void { + this.setLegState(splitId, legIndex, "succeeded"); + } + + /** Mark the leg at `legIndex` as confirmed failed. */ + markLegFailed(splitId: string, legIndex: number): void { + this.setLegState(splitId, legIndex, "failed"); + } + + /** Legs that are neither confirmed succeeded nor confirmed failed. */ + getIncomplete(splitId: string): SplitLeg[] { + return this.getCheckpoint(splitId).legs.filter((leg) => leg.state === "pending"); + } + + /** + * Initiate a rollback for `splitId`: emits `splitRollbackInitiated` with + * the current incomplete legs, persists a rollback record via + * {@link snapshotSplitRollback}, then emits `splitRollbackCompleted`. + * + * Idempotent per `splitId` — calling this more than once returns the + * originally stored record without re-emitting events. + */ + initiateRollback(splitId: string): SplitRollbackRecord { + const checkpoint = this.getCheckpoint(splitId); + const key = this.idempotency.generateKey(splitId, "rollback"); + + const existing = this.rollbackRecords.get(splitId); + if (existing && this.idempotency.isDuplicate(key)) { + return existing; + } + + const incomplete = this.getIncomplete(splitId); + this.emit("splitRollbackInitiated", { splitId, incomplete }); + + const record = snapshotSplitRollback(checkpoint); + this.rollbackRecords.set(splitId, record); + this.idempotency.tryClaim(key, { txHash: record.snapshotId }); + + this.emit("splitRollbackCompleted", { splitId, record }); + return record; + } + + /** The persisted rollback record for `splitId`, if a rollback was initiated. */ + getRollbackRecord(splitId: string): SplitRollbackRecord | undefined { + return this.rollbackRecords.get(splitId); + } + + /** The current checkpoint for `splitId`, if one exists. */ + getCheckpointFor(splitId: string): SplitRollbackCheckpoint | undefined { + return this.checkpoints.get(splitId); + } + + private getCheckpoint(splitId: string): SplitRollbackCheckpoint { + const checkpoint = this.checkpoints.get(splitId); + if (!checkpoint) throw new UnknownSplitError(splitId); + return checkpoint; + } + + private setLegState(splitId: string, legIndex: number, state: SplitLegState): void { + const checkpoint = this.getCheckpoint(splitId); + const leg = checkpoint.legs[legIndex]; + if (!leg) throw new UnknownSplitError(`${splitId}[${legIndex}]`); + leg.state = state; + } +} diff --git a/src/types.ts b/src/types.ts index fff3f03..ec83502 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,6 +125,47 @@ export interface Recipient { import { StellarSplitError } from "./errors.js"; +// --------------------------------------------------------------------------- +// Split Rollback Coordinator Types +// --------------------------------------------------------------------------- + +/** Lifecycle state of a single leg tracked by the rollback coordinator. */ +export type SplitLegState = "pending" | "succeeded" | "failed"; + +/** A single recipient leg within a multi-recipient split payment. */ +export interface SplitLeg { + /** Stellar address of this leg's recipient. */ + recipient: string; + /** Amount owed to this recipient, in stroops. */ + amount: bigint; + /** Current reconciliation state of this leg. */ + state: SplitLegState; +} + +/** Result of submitting a multi-recipient split payment. */ +export interface SplitResult { + /** Identifier grouping the legs of this split (typically the tx hash). */ + splitId: string; + /** Invoice this split payment was submitted for. */ + invoiceId: string; + /** Transaction hash of the on-chain submission. */ + txHash: string; + /** Per-recipient legs included in the split. */ + legs: SplitLeg[]; +} + +/** A persistent checkpoint recording the intended legs of a split payment. */ +export interface SplitRollbackCheckpoint { + /** Identifier grouping the legs of this split. */ + splitId: string; + /** Invoice this split payment was submitted for. */ + invoiceId: string; + /** Unix epoch ms when the checkpoint was created. */ + createdAt: number; + /** Per-recipient legs, in submission order. */ + legs: SplitLeg[]; +} + // --------------------------------------------------------------------------- // AMM Calculator Types // --------------------------------------------------------------------------- From bc8286ad3b176314137217bfc1367cd12b0f1ef2 Mon Sep 17 00:00:00 2001 From: Isaac Ochuko Date: Wed, 29 Jul 2026 16:29:45 +0000 Subject: [PATCH 2/4] Add PaymentProgressTracker for aggregated invoice payment progress --- src/index.ts | 9 ++ src/paymentProgressTracker.ts | 153 ++++++++++++++++++++++++++++++++++ src/types.ts | 28 +++++++ 3 files changed, 190 insertions(+) create mode 100644 src/paymentProgressTracker.ts diff --git a/src/index.ts b/src/index.ts index bed543d..fe7639f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -711,6 +711,15 @@ export type { TranchedInvoice, TrancheStatus, } from "./trancheProgress.js"; + +// Invoice payment progress tracking +export { PaymentProgressTracker } from "./paymentProgressTracker.js"; +export type { + PaymentProgressEventMap, + PaymentProgressTrackerOptions, +} from "./paymentProgressTracker.js"; +export type { InvoicePaymentProgress, RecipientPaymentState } from "./types.js"; + export { Sep41Adapter, createSep41Adapter } from "./sep41Adapter.js"; export type { Sep41TokenCapabilities } from "./sep41Adapter.js"; diff --git a/src/paymentProgressTracker.ts b/src/paymentProgressTracker.ts new file mode 100644 index 0000000..619d680 --- /dev/null +++ b/src/paymentProgressTracker.ts @@ -0,0 +1,153 @@ +/** + * Aggregates per-recipient payment status into a single progress view for + * long-running, multi-recipient invoices (multiple tranches or async + * recipient claims). + * + * One `InvoiceStatusPoller` (see poller.ts) is run per recipient leg, keyed + * by a composite `${invoiceId}:${accountId}` id so per-recipient pollers for + * the same invoice don't coalesce into a single poller. Progress is recomputed + * and emitted every time any recipient's status changes. + */ + +import { InvoiceStatusPoller } from "./poller.js"; +import { TypedEventEmitter } from "./events/TypedEventEmitter.js"; +import { getTrancheProgress } from "./trancheProgress.js"; +import type { + Invoice, + InvoicePaymentProgress, + RecipientPaymentState, +} from "./types.js"; + +/** Statuses treated as final/settled when computing percentComplete. */ +const SETTLED_STATUSES: ReadonlySet = new Set([ + "Released", + "Refunded", + "Cancelled", +]); + +/** Event map emitted by {@link PaymentProgressTracker}. */ +export interface PaymentProgressEventMap { + invoiceProgressUpdated: InvoicePaymentProgress; +} + +/** Options controlling {@link PaymentProgressTracker} behaviour. */ +export interface PaymentProgressTrackerOptions { + /** Poll interval per recipient leg, in milliseconds. Default: 5000. */ + pollIntervalMs?: number; + /** Fetch the current status of a single recipient's payment leg. */ + fetchRecipientStatus?: (invoiceId: string, accountId: string) => Promise; +} + +interface TrackedSubscription { + pollers: InvoiceStatusPoller[]; + unsubscribes: Array<() => void>; + states: Map; +} + +/** + * Subscribes callers to aggregated payment progress for an invoice, + * polling every recipient leg independently and reporting a single + * `InvoicePaymentProgress` snapshot on every change. + */ +export class PaymentProgressTracker extends TypedEventEmitter { + private readonly subscriptions = new Map(); + private readonly pollIntervalMs: number; + private readonly fetchRecipientStatus: (invoiceId: string, accountId: string) => Promise; + private readonly fetchInvoice: (invoiceId: string) => Promise; + + constructor( + fetchInvoice: (invoiceId: string) => Promise, + options: PaymentProgressTrackerOptions = {}, + ) { + super(); + this.fetchInvoice = fetchInvoice; + this.pollIntervalMs = options.pollIntervalMs ?? 5_000; + this.fetchRecipientStatus = options.fetchRecipientStatus ?? (async () => "Pending"); + } + + /** + * Start polling every recipient leg of `invoiceId` and invoke `onProgress` + * with an aggregated `InvoicePaymentProgress` snapshot on each change (and + * once immediately with the initial state). No-op if already subscribed. + */ + async subscribe( + invoiceId: string, + onProgress: (progress: InvoicePaymentProgress) => void, + ): Promise { + if (this.subscriptions.has(invoiceId)) return; + + const invoice = await this.fetchInvoice(invoiceId); + const trancheProgress = await getTrancheProgress(invoice).catch(() => undefined); + + const states = new Map(); + for (const recipient of invoice.recipients) { + states.set(recipient.address, { + accountId: recipient.address, + status: "Pending", + settledAmount: 0n, + lastUpdatedAt: Date.now(), + }); + } + + const subscription: TrackedSubscription = { pollers: [], unsubscribes: [], states }; + this.subscriptions.set(invoiceId, subscription); + + const emitProgress = () => { + const progress = this.computeProgress(invoiceId, states, trancheProgress); + this.emit("invoiceProgressUpdated", progress); + onProgress(progress); + }; + + for (const recipient of invoice.recipients) { + const compositeId = `${invoiceId}:${recipient.address}`; + const poller = new InvoiceStatusPoller( + { invoiceId: compositeId, pollIntervalMs: this.pollIntervalMs }, + () => this.fetchRecipientStatus(invoiceId, recipient.address), + ); + + const off = poller.on("invoiceStatusChanged", (event) => { + const state = states.get(recipient.address); + if (!state) return; + state.status = event.current; + state.lastUpdatedAt = event.timestamp; + if (SETTLED_STATUSES.has(event.current)) { + state.settledAmount = recipient.amount; + } + emitProgress(); + }); + + subscription.unsubscribes.push(off); + subscription.pollers.push(poller); + poller.start(); + } + + emitProgress(); + } + + /** Stop all pollers for `invoiceId` and remove its subscription. */ + unsubscribe(invoiceId: string): void { + const subscription = this.subscriptions.get(invoiceId); + if (!subscription) return; + for (const poller of subscription.pollers) poller.stop(); + for (const off of subscription.unsubscribes) off(); + this.subscriptions.delete(invoiceId); + } + + private computeProgress( + invoiceId: string, + states: Map, + trancheProgress: InvoicePaymentProgress["trancheProgress"], + ): InvoicePaymentProgress { + const recipientStates = Array.from(states.values()); + const settledLegs = recipientStates.filter((s) => SETTLED_STATUSES.has(s.status)).length; + const totalLegs = recipientStates.length; + const percentComplete = totalLegs === 0 ? 0 : (settledLegs / totalLegs) * 100; + + return { + invoiceId, + percentComplete, + recipientStates, + ...(trancheProgress ? { trancheProgress } : {}), + }; + } +} diff --git a/src/types.ts b/src/types.ts index ec83502..1d079a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1688,3 +1688,31 @@ export interface CursorStore { /** Delete a saved cursor. */ delete(key: string): Promise; } + +// --------------------------------------------------------------------------- +// Payment Progress Tracker Types +// --------------------------------------------------------------------------- + +/** Per-recipient payment state tracked by PaymentProgressTracker. */ +export interface RecipientPaymentState { + /** Stellar address of the recipient. */ + accountId: string; + /** Last known lifecycle status for this recipient's payment leg. */ + status: string; + /** Amount settled to this recipient so far, in stroops. */ + settledAmount: bigint; + /** Unix epoch ms when this state was last updated. */ + lastUpdatedAt: number; +} + +/** Aggregated payment progress across all recipients of an invoice. */ +export interface InvoicePaymentProgress { + /** Invoice this progress report describes. */ + invoiceId: string; + /** Settled legs divided by total legs, expressed as 0-100. */ + percentComplete: number; + /** Current state of every tracked recipient leg. */ + recipientStates: RecipientPaymentState[]; + /** Present when the invoice defines a tranche schedule (see trancheProgress.ts). */ + trancheProgress?: import("./trancheProgress.js").TrancheProgressReport; +} From 36989f05bacd9e51f594435abbf23f12dd6d0268 Mon Sep 17 00:00:00 2001 From: Isaac Ochuko Date: Wed, 29 Jul 2026 16:30:49 +0000 Subject: [PATCH 3/4] Add CoinGeckoPriceOracle default XLM-to-fiat price oracle adapter --- src/client.ts | 11 ++++++ src/currencyConverter.ts | 40 +++++++++++++++++++++ src/errors.ts | 34 ++++++++++++++++++ src/index.ts | 14 ++++++++ src/priceOracle.ts | 78 ++++++++++++++++++++++++++++++++++++++++ src/rateCache.ts | 51 ++++++++++++++++++++++++++ src/types.ts | 18 ++++++++++ 7 files changed, 246 insertions(+) create mode 100644 src/priceOracle.ts create mode 100644 src/rateCache.ts diff --git a/src/client.ts b/src/client.ts index 0b32abc..4f31f23 100644 --- a/src/client.ts +++ b/src/client.ts @@ -488,6 +488,12 @@ export interface StellarSplitClientConfig { * for inspecting in-flight transaction envelopes. Defaults to false. */ debug?: boolean; + /** + * Optional fiat-to-asset price oracle adapter (see `PriceOracleAdapter` in + * types.ts). Used by `convertFiatToAsset` in currencyConverter.ts to + * resolve display conversions. Defaults to no oracle configured. + */ + priceOracle?: import("./types.js").PriceOracleAdapter; } /** Network configuration. */ @@ -2396,6 +2402,11 @@ export class StellarSplitClient extends TypedEventEmitter { return this._rollbackCoordinator; } + /** The configured fiat-to-asset price oracle adapter, or null if none was provided. */ + get priceOracle(): import("./types.js").PriceOracleAdapter | null { + return this.config.priceOracle ?? null; + } + /** * The optimistic UI cache, or null when `optimisticCache` was not passed * to the constructor. Use `client.optimisticCache?.onRollback(...)` to diff --git a/src/currencyConverter.ts b/src/currencyConverter.ts index 5c1d906..64b428e 100644 --- a/src/currencyConverter.ts +++ b/src/currencyConverter.ts @@ -14,6 +14,7 @@ import { scValToNative, } from "@stellar/stellar-sdk"; import { OraclePriceError, NoReturnValueError } from "./errors.js"; +import type { PriceOracleAdapter } from "./types.js"; export interface ConvertedAmount { original: bigint; @@ -110,4 +111,43 @@ export async function convertAmount( fromToken, toDisplayCurrency, }; +} + +/** 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, + assetCode: string, + oracle: PriceOracleAdapter, +): Promise { + const rate = await oracle.getPrice(assetCode, fiatCurrency); + if (!(rate > 0)) { + throw new OraclePriceError(`Invalid oracle rate for ${assetCode}/${fiatCurrency}: ${rate}`); + } + + return { + fiatAmount, + fiatCurrency, + assetAmount: fiatAmount / rate, + assetCode, + rate, + }; } \ No newline at end of file diff --git a/src/errors.ts b/src/errors.ts index 2ba6db0..0253598 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1716,3 +1716,37 @@ export class UnknownSplitError extends StellarSplitError { export function isUnknownSplitError(err: unknown): err is UnknownSplitError { return err instanceof UnknownSplitError; } + +// --------------------------------------------------------------------------- +// Price oracle adapter errors +// --------------------------------------------------------------------------- + +/** Thrown when a price oracle request is rate-limited by the upstream provider. */ +export class RateLimitError extends StellarSplitError { + /** Seconds the caller should wait before retrying, if known. */ + readonly retryAfterSeconds?: number; + + constructor(message: string, retryAfterSeconds?: number) { + super(message, "RATE_LIMITED", { retryAfterSeconds }); + this.name = "RateLimitError"; + this.retryAfterSeconds = retryAfterSeconds; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isRateLimitError(err: unknown): err is RateLimitError { + return err instanceof RateLimitError; +} + +/** Thrown when a price oracle request fails for a reason other than rate limiting. */ +export class PriceOracleFetchError extends StellarSplitError { + constructor(message: string) { + super(message, "PRICE_ORACLE_FETCH_FAILED", undefined, message); + this.name = "PriceOracleFetchError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isPriceOracleFetchError(err: unknown): err is PriceOracleFetchError { + return err instanceof PriceOracleFetchError; +} diff --git a/src/index.ts b/src/index.ts index fe7639f..613f76a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -195,6 +195,11 @@ export { // New: Split Rollback Coordinator UnknownSplitError, isUnknownSplitError, + // New: Price Oracle Adapter + RateLimitError, + isRateLimitError, + PriceOracleFetchError, + isPriceOracleFetchError, } from "./errors.js"; // --------------------------------------------------------------------------- @@ -720,6 +725,15 @@ export type { } from "./paymentProgressTracker.js"; export type { InvoicePaymentProgress, RecipientPaymentState } from "./types.js"; +// Fiat-to-asset price oracle adapter +export { CoinGeckoPriceOracle } from "./priceOracle.js"; +export type { CoinGeckoPriceOracleOptions } from "./priceOracle.js"; +export { RateCache } from "./rateCache.js"; +export type { RateCacheConfig } from "./rateCache.js"; +export type { PriceOracleAdapter } from "./types.js"; +export { convertFiatToAsset } from "./currencyConverter.js"; +export type { FiatConversion, ConvertedAmount } from "./currencyConverter.js"; + export { Sep41Adapter, createSep41Adapter } from "./sep41Adapter.js"; export type { Sep41TokenCapabilities } from "./sep41Adapter.js"; diff --git a/src/priceOracle.ts b/src/priceOracle.ts new file mode 100644 index 0000000..6e00cbb --- /dev/null +++ b/src/priceOracle.ts @@ -0,0 +1,78 @@ +/** + * Default PriceOracleAdapter implementation backed by CoinGecko's public + * `/simple/price` REST endpoint. Callers needing a different provider can + * supply their own PriceOracleAdapter (see types.ts) instead. + */ + +import { PriceOracleFetchError, RateLimitError } from "./errors.js"; +import { RateCache } from "./rateCache.js"; +import type { PriceOracleAdapter } from "./types.js"; + +/** Default asset-code -> CoinGecko coin-id mapping for common Stellar assets. */ +const DEFAULT_COIN_IDS: Record = { + XLM: "stellar", + USDC: "usd-coin", +}; + +export interface CoinGeckoPriceOracleOptions { + /** Override the CoinGecko API base URL (e.g. for a proxy). Default: the public API. */ + apiBaseUrl?: string; + /** Additional or overriding asset-code -> CoinGecko coin-id mappings. */ + coinIds?: Record; + /** Cache used to avoid redundant requests. Defaults to a private RateCache. */ + cache?: RateCache; +} + +/** + * Fetches fiat/asset conversion rates from CoinGecko's public price API, + * caching results via {@link RateCache}. + */ +export class CoinGeckoPriceOracle implements PriceOracleAdapter { + private readonly apiBaseUrl: string; + private readonly coinIds: Record; + private readonly cache: RateCache; + + constructor(options: CoinGeckoPriceOracleOptions = {}) { + this.apiBaseUrl = options.apiBaseUrl ?? "https://api.coingecko.com/api/v3"; + this.coinIds = { ...DEFAULT_COIN_IDS, ...options.coinIds }; + this.cache = options.cache ?? new RateCache(); + } + + /** + * Resolve the price of one unit of `base` denominated in `quote`. + * `base` may be any key in `coinIds` (defaults include "XLM", "USDC") or a + * raw CoinGecko coin id; `quote` is a CoinGecko vs_currency code (e.g. "USD"). + */ + async getPrice(base: string, quote: string): Promise { + const cached = this.cache.get(base, quote); + if (cached !== undefined) return cached; + + const coinId = this.coinIds[base.toUpperCase()] ?? base.toLowerCase(); + const vsCurrency = quote.toLowerCase(); + const url = `${this.apiBaseUrl}/simple/price?ids=${encodeURIComponent(coinId)}&vs_currencies=${encodeURIComponent(vsCurrency)}`; + + const response = await fetch(url); + + if (response.status === 429) { + const retryAfterHeader = response.headers.get("retry-after"); + throw new RateLimitError( + `CoinGecko rate limit exceeded fetching ${base}/${quote}`, + retryAfterHeader ? Number(retryAfterHeader) : undefined, + ); + } + if (!response.ok) { + throw new PriceOracleFetchError( + `CoinGecko request failed with status ${response.status} fetching ${base}/${quote}`, + ); + } + + const payload = (await response.json()) as Record>; + const rate = payload?.[coinId]?.[vsCurrency]; + if (typeof rate !== "number") { + throw new PriceOracleFetchError(`CoinGecko response missing rate for ${base}/${quote}`); + } + + this.cache.set(base, quote, rate); + return rate; + } +} diff --git a/src/rateCache.ts b/src/rateCache.ts new file mode 100644 index 0000000..1dbfa21 --- /dev/null +++ b/src/rateCache.ts @@ -0,0 +1,51 @@ +/** + * Minimal TTL cache for price oracle rates, keyed by base/quote pair. + * + * Used by PriceOracleAdapter implementations (see priceOracle.ts) to avoid + * redundant upstream requests within the configured TTL window. + */ + +export interface RateCacheConfig { + /** Time-to-live for cached rates, in milliseconds. Default: 30_000 (30s). */ + ttlMs?: number; +} + +interface RateEntry { + rate: number; + expiresAt: number; +} + +export class RateCache { + private readonly store = new Map(); + private readonly ttlMs: number; + + constructor(config: RateCacheConfig = {}) { + this.ttlMs = config.ttlMs ?? 30_000; + } + + /** The cached rate for `base`/`quote`, or undefined if absent or expired. */ + get(base: string, quote: string): number | undefined { + const key = this.key(base, quote); + const entry = this.store.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return undefined; + } + return entry.rate; + } + + /** Cache `rate` for `base`/`quote`, expiring after this cache's TTL. */ + set(base: string, quote: string, rate: number): void { + this.store.set(this.key(base, quote), { rate, expiresAt: Date.now() + this.ttlMs }); + } + + /** Remove all cached rates. */ + clear(): void { + this.store.clear(); + } + + private key(base: string, quote: string): string { + return `${base.toUpperCase()}:${quote.toUpperCase()}`; + } +} diff --git a/src/types.ts b/src/types.ts index 1d079a2..f779100 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1716,3 +1716,21 @@ export interface InvoicePaymentProgress { /** Present when the invoice defines a tranche schedule (see trancheProgress.ts). */ trancheProgress?: import("./trancheProgress.js").TrancheProgressReport; } + +// --------------------------------------------------------------------------- +// Price Oracle Adapter Types +// --------------------------------------------------------------------------- + +/** + * Pluggable adapter contract for resolving fiat-to-asset conversion rates. + * Implementations may call an external REST API, an on-chain oracle + * contract, or a static table; the SDK ships {@link CoinGeckoPriceOracle} + * (see priceOracle.ts) as the default REST-backed implementation. + */ +export interface PriceOracleAdapter { + /** + * Resolve the price of one unit of `base` denominated in `quote` + * (e.g. `getPrice("XLM", "USD")` resolves to how many USD one XLM is worth). + */ + getPrice(base: string, quote: string): Promise; +} From 8d6f7f0880848f92d0093835f778905b207d05f1 Mon Sep 17 00:00:00 2001 From: Isaac Ochuko Date: Wed, 29 Jul 2026 16:31:33 +0000 Subject: [PATCH 4/4] Add PathQueryBuilder and refactor PathRouter to use it --- src/errors.ts | 17 ++++ src/index.ts | 10 +++ src/pathQueryBuilder.ts | 171 ++++++++++++++++++++++++++++++++++++++++ src/pathRouter.ts | 148 +++++++++------------------------- src/types.ts | 35 ++++++++ 5 files changed, 271 insertions(+), 110 deletions(-) create mode 100644 src/pathQueryBuilder.ts diff --git a/src/errors.ts b/src/errors.ts index 0253598..1e95a14 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1750,3 +1750,20 @@ export class PriceOracleFetchError extends StellarSplitError { export function isPriceOracleFetchError(err: unknown): err is PriceOracleFetchError { return err instanceof PriceOracleFetchError; } + +// --------------------------------------------------------------------------- +// Path query builder errors +// --------------------------------------------------------------------------- + +/** Thrown when a PathQueryBuilder query is missing required parameters or fails validation. */ +export class InvalidPathQueryError extends StellarSplitError { + constructor(message: string, context?: Record) { + super(message, "INVALID_PATH_QUERY", context); + this.name = "InvalidPathQueryError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isInvalidPathQueryError(err: unknown): err is InvalidPathQueryError { + return err instanceof InvalidPathQueryError; +} diff --git a/src/index.ts b/src/index.ts index 613f76a..81b3170 100644 --- a/src/index.ts +++ b/src/index.ts @@ -200,6 +200,9 @@ export { isRateLimitError, PriceOracleFetchError, isPriceOracleFetchError, + // New: Path Query Builder + InvalidPathQueryError, + isInvalidPathQueryError, } from "./errors.js"; // --------------------------------------------------------------------------- @@ -325,6 +328,13 @@ export type { SequenceCacheConfig } from "./sequenceCache.js"; export { PathRouter } from "./pathRouter.js"; export type { PathResult, PathHop, PathRequest, PathRouterConfig } from "./pathRouter.js"; +export { PathQueryBuilder } from "./pathQueryBuilder.js"; +export type { + StrictSendQueryParams, + StrictReceiveQueryParams, + PathQueryBuilderConfig, +} from "./pathQueryBuilder.js"; +export type { PathQuery, PathQueryResult, StrictSendPathQuery, StrictReceivePathQuery } from "./types.js"; export { OfferTracker } from "./offerTracker.js"; export type { OfferTrackerConfig, OfferTrackerEventMap } from "./offerTracker.js"; diff --git a/src/pathQueryBuilder.ts b/src/pathQueryBuilder.ts new file mode 100644 index 0000000..f3c310d --- /dev/null +++ b/src/pathQueryBuilder.ts @@ -0,0 +1,171 @@ +/** + * Builds and executes Horizon strict-send / strict-receive path queries. + * + * Encapsulates query assembly and validation so callers (and tests) can work + * with path queries independently of any particular routing strategy. + * {@link PathRouter} (pathRouter.ts) uses this builder internally. + */ + +import { Asset, Horizon } from "@stellar/stellar-sdk"; +import { SimpleCache } from "./cache.js"; +import { InvalidPathQueryError } from "./errors.js"; +import { isValidStellarAddress } from "./utils.js"; +import type { PathQuery, PathQueryResult } from "./types.js"; + +/** Parameters for {@link PathQueryBuilder.forStrictSend}. */ +export interface StrictSendQueryParams { + /** Asset the sender will supply. */ + sourceAsset: Asset; + /** Amount the sender will supply, in the source asset's base unit. */ + sourceAmount: bigint; + /** A specific destination account to query reachable assets for. */ + destinationAccount?: string; + /** A fixed list of acceptable destination assets. */ + destinationAssets?: Asset[]; +} + +/** Parameters for {@link PathQueryBuilder.forStrictReceive}. */ +export interface StrictReceiveQueryParams { + /** Asset the recipient should receive. */ + destinationAsset: Asset; + /** Amount the recipient should receive, in the destination asset's base unit. */ + destinationAmount: bigint; + /** A specific source account to query spendable assets for. */ + sourceAccount?: string; + /** A fixed list of acceptable source assets. */ + sourceAssets?: Asset[]; +} + +/** Configuration for {@link PathQueryBuilder}. */ +export interface PathQueryBuilderConfig { + /** Cache TTL in milliseconds. Default: 10_000 (10s). */ + ttlMs?: number; + /** Maximum number of cached query results. Default: 5_000. */ + maxEntries?: number; +} + +/** + * Assembles validated `PathQuery` objects and executes them against Horizon, + * caching results by query parameters within a configurable TTL. + */ +export class PathQueryBuilder { + private readonly server: Horizon.Server; + private readonly cache: SimpleCache; + + constructor(server: Horizon.Server, config: PathQueryBuilderConfig = {}) { + this.server = server; + this.cache = new SimpleCache({ + enabled: true, + ttlMs: config.ttlMs ?? 10_000, + maxEntries: config.maxEntries ?? 5_000, + }); + } + + /** + * Build a strict-send query: `sourceAmount` is fixed, the destination + * amount is estimated. Requires either `destinationAccount` or a non-empty + * `destinationAssets` list. + */ + forStrictSend(params: StrictSendQueryParams): PathQuery { + if (!(params.sourceAmount > 0n)) { + throw new InvalidPathQueryError("sourceAmount must be greater than 0", { + sourceAmount: params.sourceAmount.toString(), + }); + } + if (params.destinationAccount === undefined && (!params.destinationAssets || params.destinationAssets.length === 0)) { + throw new InvalidPathQueryError("Either destinationAccount or destinationAssets is required"); + } + if (params.destinationAccount !== undefined && !isValidStellarAddress(params.destinationAccount)) { + throw new InvalidPathQueryError(`Invalid destination account: ${params.destinationAccount}`, { + destinationAccount: params.destinationAccount, + }); + } + + return { + kind: "strictSend", + sourceAsset: params.sourceAsset, + sourceAmount: params.sourceAmount, + destination: params.destinationAccount ?? params.destinationAssets!, + }; + } + + /** + * Build a strict-receive query: `destinationAmount` is fixed, the source + * amount is estimated. Requires either `sourceAccount` or a non-empty + * `sourceAssets` list. + */ + forStrictReceive(params: StrictReceiveQueryParams): PathQuery { + if (!(params.destinationAmount > 0n)) { + throw new InvalidPathQueryError("destinationAmount must be greater than 0", { + destinationAmount: params.destinationAmount.toString(), + }); + } + if (params.sourceAccount === undefined && (!params.sourceAssets || params.sourceAssets.length === 0)) { + throw new InvalidPathQueryError("Either sourceAccount or sourceAssets is required"); + } + if (params.sourceAccount !== undefined && !isValidStellarAddress(params.sourceAccount)) { + throw new InvalidPathQueryError(`Invalid source account: ${params.sourceAccount}`, { + sourceAccount: params.sourceAccount, + }); + } + + return { + kind: "strictReceive", + source: params.sourceAccount ?? params.sourceAssets!, + destinationAsset: params.destinationAsset, + destinationAmount: params.destinationAmount, + }; + } + + /** + * Submit `query` to Horizon and return every matching path, sorted by + * best rate (highest destination amount for strict-send, lowest source + * amount for strict-receive). Cached by query parameters within the TTL. + */ + async execute(query: PathQuery): Promise { + const cacheKey = this.cacheKey(query); + const cached = this.cache.get(cacheKey); + if (cached) return cached; + + const records = + query.kind === "strictSend" + ? await this.server.strictSendPaths(query.sourceAsset, query.sourceAmount.toString(), query.destination).call() + : await this.server.strictReceivePaths(query.source, query.destinationAsset, query.destinationAmount.toString()).call(); + + const results: PathQueryResult[] = records.records.map((record) => ({ + path: record.path, + sourceAmount: BigInt(record.source_amount), + destinationAmount: BigInt(record.destination_amount), + })); + + results.sort((a, b) => { + if (query.kind === "strictSend") { + return a.destinationAmount === b.destinationAmount ? 0 : a.destinationAmount > b.destinationAmount ? -1 : 1; + } + return a.sourceAmount === b.sourceAmount ? 0 : a.sourceAmount < b.sourceAmount ? -1 : 1; + }); + + this.cache.set(cacheKey, results); + return results; + } + + /** Clear all cached query results. */ + clearCache(): void { + this.cache.clear(); + } + + private cacheKey(query: PathQuery): string { + if (query.kind === "strictSend") { + const dest = Array.isArray(query.destination) + ? query.destination.map(assetKey).join(",") + : query.destination; + return `strictSend:${assetKey(query.sourceAsset)}:${query.sourceAmount}:${dest}`; + } + const src = Array.isArray(query.source) ? query.source.map(assetKey).join(",") : query.source; + return `strictReceive:${src}:${assetKey(query.destinationAsset)}:${query.destinationAmount}`; + } +} + +function assetKey(asset: Asset): string { + return asset.isNative() ? "native" : `${asset.getCode()}:${asset.getIssuer()}`; +} diff --git a/src/pathRouter.ts b/src/pathRouter.ts index 2bdbc8a..dfea58f 100644 --- a/src/pathRouter.ts +++ b/src/pathRouter.ts @@ -5,13 +5,14 @@ * Horizon's path-payment endpoints to find the best conversion route and * returns the optimal path for each split leg. * - * Integrates with {@link SimpleCache} to avoid redundant Horizon calls for - * identical source/destination pairs within the cache TTL window. + * Delegates query assembly, validation, and caching to {@link PathQueryBuilder} + * to avoid redundant Horizon calls for identical source/destination pairs + * within the cache TTL window. */ import { Asset, Horizon, Operation } from "@stellar/stellar-sdk"; -import { SimpleCache } from "./cache.js"; import { PathNotFoundError, PathRouterError } from "./errors.js"; +import { PathQueryBuilder } from "./pathQueryBuilder.js"; // --------------------------------------------------------------------------- // Types @@ -70,7 +71,7 @@ export interface PathRouterConfig { */ export class PathRouter { private readonly server: Horizon.Server; - private readonly cache: SimpleCache; + private readonly queryBuilder: PathQueryBuilder; /** * @param horizonUrl - Horizon server URL. @@ -78,8 +79,7 @@ export class PathRouter { */ constructor(horizonUrl: string, config: PathRouterConfig = {}) { this.server = new Horizon.Server(horizonUrl); - this.cache = new SimpleCache({ - enabled: true, + this.queryBuilder = new PathQueryBuilder(this.server, { ttlMs: config.ttlMs ?? 15_000, maxEntries: config.maxEntries ?? 5_000, }); @@ -97,45 +97,24 @@ export class PathRouter { * the destination amount is estimated. */ async findStrictSendPath(req: PathRequest): Promise { - const cacheKey = this.cacheKey("send", req); - const cached = this.cache.get(cacheKey); - if (cached) return cached; + const sourceAssetType = assetType(req.sourceAsset); + const destAssetType = assetType(req.destinationAsset); try { - const sourceAssetType = req.sourceAsset.isNative() - ? "native" - : `${req.sourceAsset.getCode()}:${req.sourceAsset.getIssuer()}`; - const destAssetType = req.destinationAsset.isNative() - ? "native" - : `${req.destinationAsset.getCode()}:${req.destinationAsset.getIssuer()}`; - - const records = await this.server - .strictSendPaths( - req.sourceAsset, - req.sourceAmount.toString(), - [req.destinationAsset], - ) - .call(); - - if (records.records.length === 0) { - throw new PathNotFoundError( - sourceAssetType, - destAssetType, - req.sourceAmount, - ); + const query = this.queryBuilder.forStrictSend({ + sourceAsset: req.sourceAsset, + sourceAmount: req.sourceAmount, + destinationAssets: [req.destinationAsset], + }); + const results = await this.queryBuilder.execute(query); + + if (results.length === 0) { + throw new PathNotFoundError(sourceAssetType, destAssetType, req.sourceAmount); } - // First record is the best path (highest destination amount) - const best = records.records[0]!; - - const result: PathResult = { - path: best.path, - destinationAmount: BigInt(best.destination_amount), - sourceAmount: BigInt(best.source_amount), - }; - - this.cache.set(cacheKey, result); - return result; + // Results are sorted best-first (highest destination amount). + const best = results[0]!; + return { path: best.path, destinationAmount: best.destinationAmount, sourceAmount: best.sourceAmount }; } catch (err) { if (err instanceof PathNotFoundError) throw err; throw new PathRouterError( @@ -156,51 +135,24 @@ export class PathRouter { destAmount: bigint, destinationAsset: Asset, ): Promise { - const cacheKey = this.cacheKeyReceive(sourceAsset, destAmount, destinationAsset); - const cached = this.cache.get(cacheKey); - if (cached) return cached; + const sourceAssetType = assetType(sourceAsset); + const destAssetType = assetType(destinationAsset); try { - const sourceAssetType = sourceAsset.isNative() - ? "native" - : `${sourceAsset.getCode()}:${sourceAsset.getIssuer()}`; - const destAssetType = destinationAsset.isNative() - ? "native" - : `${destinationAsset.getCode()}:${destinationAsset.getIssuer()}`; - - const srcStr = sourceAsset.isNative() - ? "native" - : `${sourceAsset.getCode()}:${sourceAsset.getIssuer()}`; - const dstStr = destinationAsset.isNative() - ? "native" - : `${destinationAsset.getCode()}:${destinationAsset.getIssuer()}`; - - const records = await this.server - .strictReceivePaths( - srcStr, - destinationAsset, - destAmount.toString(), - ) - .call(); - - if (records.records.length === 0) { - throw new PathNotFoundError( - sourceAssetType, - destAssetType, - destAmount, - ); + const query = this.queryBuilder.forStrictReceive({ + sourceAssets: [sourceAsset], + destinationAsset, + destinationAmount: destAmount, + }); + const results = await this.queryBuilder.execute(query); + + if (results.length === 0) { + throw new PathNotFoundError(sourceAssetType, destAssetType, destAmount); } - const best = records.records[0]!; - - const result: PathResult = { - path: best.path, - destinationAmount: BigInt(best.destination_amount), - sourceAmount: BigInt(best.source_amount), - }; - - this.cache.set(cacheKey, result); - return result; + // Results are sorted best-first (lowest source amount). + const best = results[0]!; + return { path: best.path, destinationAmount: best.destinationAmount, sourceAmount: best.sourceAmount }; } catch (err) { if (err instanceof PathNotFoundError) throw err; throw new PathRouterError( @@ -262,34 +214,10 @@ export class PathRouter { * Clear all cached paths. */ clearCache(): void { - this.cache.clear(); - } - - // ------------------------------------------------------------------------- - // Internal helpers - // ------------------------------------------------------------------------- - - private cacheKey(kind: "send" | "receive", req: PathRequest): string { - const src = req.sourceAsset.isNative() - ? "native" - : `${req.sourceAsset.getCode()}:${req.sourceAsset.getIssuer()}`; - const dst = req.destinationAsset.isNative() - ? "native" - : `${req.destinationAsset.getCode()}:${req.destinationAsset.getIssuer()}`; - return `path:${kind}:${src}:${dst}:${req.sourceAmount.toString()}`; + this.queryBuilder.clearCache(); } +} - private cacheKeyReceive( - sourceAsset: Asset, - destAmount: bigint, - destinationAsset: Asset, - ): string { - const src = sourceAsset.isNative() - ? "native" - : `${sourceAsset.getCode()}:${sourceAsset.getIssuer()}`; - const dst = destinationAsset.isNative() - ? "native" - : `${destinationAsset.getCode()}:${destinationAsset.getIssuer()}`; - return `path:receive:${src}:${dst}:${destAmount.toString()}`; - } +function assetType(asset: Asset): string { + return asset.isNative() ? "native" : `${asset.getCode()}:${asset.getIssuer()}`; } diff --git a/src/types.ts b/src/types.ts index f779100..4c1a070 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1734,3 +1734,38 @@ export interface PriceOracleAdapter { */ getPrice(base: string, quote: string): Promise; } + +// --------------------------------------------------------------------------- +// Path Query Builder Types +// --------------------------------------------------------------------------- + +/** A validated strict-send Horizon path query, built by PathQueryBuilder. */ +export interface StrictSendPathQuery { + kind: "strictSend"; + sourceAsset: import("@stellar/stellar-sdk").Asset; + sourceAmount: bigint; + /** A specific destination account, or a fixed list of acceptable destination assets. */ + destination: string | import("@stellar/stellar-sdk").Asset[]; +} + +/** A validated strict-receive Horizon path query, built by PathQueryBuilder. */ +export interface StrictReceivePathQuery { + kind: "strictReceive"; + /** A specific source account, or a fixed list of acceptable source assets. */ + source: string | import("@stellar/stellar-sdk").Asset[]; + destinationAsset: import("@stellar/stellar-sdk").Asset; + destinationAmount: bigint; +} + +/** A path query built by PathQueryBuilder, ready to execute. */ +export type PathQuery = StrictSendPathQuery | StrictReceivePathQuery; + +/** A single resolved conversion path returned by executing a PathQuery. */ +export interface PathQueryResult { + /** Ordered list of intermediate assets forming the conversion route. */ + path: Array<{ asset_code: string; asset_issuer: string; asset_type: string }>; + /** Amount sent from the source, in the source asset's base unit. */ + sourceAmount: bigint; + /** Amount the destination receives, in the destination asset's base unit. */ + destinationAmount: bigint; +}