From bcac452a741f50096a628f05d241228a173db231 Mon Sep 17 00:00:00 2001 From: Michael Musa Date: Tue, 28 Jul 2026 16:40:10 +0000 Subject: [PATCH 1/4] feat(sequence-cache): add SequenceCache for prefetching account sequence numbers - Add src/sequenceCache.ts: wraps Horizon loadAccount() with local sequence-number incrementing to eliminate round-trip latency per payment operation under high-frequency split invoice submissions. - Inject SimpleCache for TTL-based eviction of stale entries. - Add isSequenceTooOld() helper to detect SEQUENCE_NUMBER_TOO_OLD submission errors and trigger cache invalidation. - Integrate with txBuilder.ts via submitWithSequence() and buildWithSequence() to skip loadAccount() when cached. - Add SequenceCacheError and SequenceNumberTooOldError to errors.ts. - Add SplitConfig and RecipientShare types. - Export SequenceCache and isSequenceTooOld from index.ts. --- src/client.ts | 2 - src/errors.ts | 120 +++++++++++++++++++++++++++++ src/index.ts | 31 +++++++- src/sequenceCache.ts | 177 +++++++++++++++++++++++++++++++++++++++++++ src/txBuilder.ts | 98 +++++++++++++++++++++++- src/types.ts | 76 +++++++++++++++++++ 6 files changed, 498 insertions(+), 6 deletions(-) create mode 100644 src/sequenceCache.ts diff --git a/src/client.ts b/src/client.ts index 8fe1921..cad1e46 100644 --- a/src/client.ts +++ b/src/client.ts @@ -590,8 +590,6 @@ export function verifyCompletionProof(proof: CompletionProof): { } return { valid: true }; } -export class StellarSplitClient extends EventEmitter { - export class StellarSplitClient extends TypedEventEmitter { private _mainServer!: SorobanRpc.Server; private _standby: WarmStandby | null = null; diff --git a/src/errors.ts b/src/errors.ts index 32cca1c..5aa9d13 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1456,6 +1456,126 @@ export class PassphraseMismatchError extends StellarSplitError { } } +// --------------------------------------------------------------------------- +// Sequence cache errors +// --------------------------------------------------------------------------- + +/** Thrown when the sequence cache fails to fetch an account from Horizon. */ +export class SequenceCacheError extends StellarSplitError { + readonly accountId: string; + + constructor(message: string, accountId: string) { + super(message, "SEQUENCE_CACHE_ERROR", { accountId }, message); + this.name = "SequenceCacheError"; + this.accountId = accountId; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isSequenceCacheError(err: unknown): err is SequenceCacheError { + return err instanceof SequenceCacheError; +} + +/** Thrown when a SEQUENCE_NUMBER_TOO_OLD error is detected at submission time. */ +export class SequenceNumberTooOldError extends StellarSplitError { + readonly accountId: string; + readonly cachedSequence: bigint; + + constructor(accountId: string, cachedSequence: bigint) { + super( + `Sequence number too old for ${accountId} (cached: ${cachedSequence})`, + "SEQUENCE_NUMBER_TOO_OLD", + { accountId, cachedSequence: cachedSequence.toString() }, + ); + this.name = "SequenceNumberTooOldError"; + this.accountId = accountId; + this.cachedSequence = cachedSequence; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isSequenceNumberTooOldError(err: unknown): err is SequenceNumberTooOldError { + return err instanceof SequenceNumberTooOldError; +} + +// --------------------------------------------------------------------------- +// Path router errors +// --------------------------------------------------------------------------- + +/** Thrown when no DEX path could be found between two assets. */ +export class PathNotFoundError extends StellarSplitError { + readonly sourceAsset: string; + readonly destAsset: string; + readonly amount: bigint; + + constructor(sourceAsset: string, destAsset: string, amount: bigint) { + super( + `No DEX path found from ${sourceAsset} to ${destAsset} for amount ${amount}`, + "PATH_NOT_FOUND", + { sourceAsset, destAsset, amount: amount.toString() }, + ); + this.name = "PathNotFoundError"; + this.sourceAsset = sourceAsset; + this.destAsset = destAsset; + this.amount = amount; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isPathNotFoundError(err: unknown): err is PathNotFoundError { + return err instanceof PathNotFoundError; +} + +/** Thrown when the path router encounters an unexpected error. */ +export class PathRouterError extends StellarSplitError { + constructor(message: string) { + super(message, "PATH_ROUTER_ERROR", undefined, message); + this.name = "PathRouterError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isPathRouterError(err: unknown): err is PathRouterError { + return err instanceof PathRouterError; +} + +// --------------------------------------------------------------------------- +// Offer tracker errors +// --------------------------------------------------------------------------- + +/** Thrown when offer tracking or cancellation fails. */ +export class OfferTrackingError extends StellarSplitError { + constructor(message: string) { + super(message, "OFFER_TRACKING_ERROR", undefined, message); + this.name = "OfferTrackingError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isOfferTrackingError(err: unknown): err is OfferTrackingError { + return err instanceof OfferTrackingError; +} + +// --------------------------------------------------------------------------- +// Claimable balance lifecycle errors +// --------------------------------------------------------------------------- + +/** Thrown when claimable balance lifecycle operations fail. */ +export class ClaimableBalanceLifecycleError extends StellarSplitError { + readonly balanceId: string; + + constructor(message: string, balanceId: string) { + super(message, "CLAIMABLE_BALANCE_LIFECYCLE_ERROR", { balanceId }, message); + this.name = "ClaimableBalanceLifecycleError"; + this.balanceId = balanceId; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isClaimableBalanceLifecycleError(err: unknown): err is ClaimableBalanceLifecycleError { + return err instanceof ClaimableBalanceLifecycleError; +} + export function isIPFSConfigError(err: unknown): err is IPFSConfigError { return err instanceof IPFSConfigError; } diff --git a/src/index.ts b/src/index.ts index 55a1aaa..52144a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,7 +87,12 @@ export { TrancheProgressError, RefundGraceError, ChannelReconciliationError, - isStellarSplitError, + SequenceCacheError, + SequenceNumberTooOldError, + PathNotFoundError, + PathRouterError, + OfferTrackingError, + ClaimableBalanceLifecycleError, isInvoiceNotFoundError, isInvoiceNotPendingError, isDeadlinePassedError, @@ -144,6 +149,12 @@ export { isTrancheProgressError, isRefundGraceError, isChannelReconciliationError, + isSequenceCacheError, + isSequenceNumberTooOldError, + isPathNotFoundError, + isPathRouterError, + isOfferTrackingError, + isClaimableBalanceLifecycleError, TooManySubscriptionsError, isTooManySubscriptionsError, RequestTimeoutError, @@ -278,6 +289,15 @@ export { DeadlineEngine } from "./deadlineEngine.js"; export { StellarSplitTxBuilder } from "./txBuilder.js"; +export { SequenceCache, isSequenceTooOld } from "./sequenceCache.js"; +export type { SequenceCacheConfig } from "./sequenceCache.js"; + +export { PathRouter } from "./pathRouter.js"; +export type { PathResult, PathHop, PathRequest, PathRouterConfig } from "./pathRouter.js"; + +export { OfferTracker } from "./offerTracker.js"; +export type { OfferTrackerConfig, OfferTrackerEventMap } from "./offerTracker.js"; + export { SimpleCache } from "./cache.js"; export { Recorder, createRecorder } from "./recorder.js"; export type { SessionRecording, RecordingEntry, ReplayResult } from "./recorder.js"; @@ -333,6 +353,12 @@ export type { AdminFreezeResult, AdminUnfreezeResult, TransitionRecord, + SplitConfig, + RecipientShare, + OfferRecord, + OfferStatus, + ClaimableBalanceRecord, + ClaimableBalanceStatus, } from "./types.js"; export { InvalidTransitionError } from "./types.js"; @@ -679,10 +705,13 @@ export { createClaimableRefund, getClaimableRefunds, isRefundTransferError, + ClaimableBalanceLifecycle, } from "./claimableBalanceFallback.js"; export type { ClaimableRefundResult, ClaimableRefundEntry, + ClaimableBalanceLifecycleConfig, + ClaimableBalanceLifecycleEventMap, } from "./claimableBalanceFallback.js"; export { subscribeToInvoice } from "./sse.js"; diff --git a/src/sequenceCache.ts b/src/sequenceCache.ts new file mode 100644 index 0000000..720ff63 --- /dev/null +++ b/src/sequenceCache.ts @@ -0,0 +1,177 @@ +/** + * Sequence-number cache that prefetches account sequence numbers from Horizon, + * increments them locally on each call, and re-fetches only when the cached + * value goes stale (detected via SEQUENCE_NUMBER_TOO_OLD submission errors). + * + * Integrates with {@link SimpleCache} for TTL-based eviction of stale entries. + */ + +import { Horizon } from "@stellar/stellar-sdk"; +import { SimpleCache } from "./cache.js"; +import { SequenceCacheError } from "./errors.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Per-account cached sequence state. */ +interface SequenceEntry { + /** The last-known on-chain sequence number (as a bigint). */ + base: bigint; + /** How many local increments have been applied on top of `base`. */ + offset: number; + /** Unix-ms timestamp when the entry was last refreshed from Horizon. */ + fetchedAt: number; +} + +/** Configuration for {@link SequenceCache}. */ +export interface SequenceCacheConfig { + /** TTL in milliseconds before a cached entry is considered stale. Default: 30_000 (30s). */ + ttlMs?: number; + /** Maximum number of accounts to cache concurrently. Default: 10_000. */ + maxEntries?: number; +} + +// --------------------------------------------------------------------------- +// SequenceCache +// --------------------------------------------------------------------------- + +/** + * Caches per-account sequence numbers retrieved from Horizon. + * + * On every {@link getSequence} call the cache returns the next sequence number + * by incrementing a local counter, avoiding a Horizon round-trip. When a + * `SEQUENCE_NUMBER_TOO_OLD` submission error is detected the caller should + * invoke {@link invalidate} so the next call re-fetches the true on-chain + * value. + */ +export class SequenceCache { + private readonly server: Horizon.Server; + private readonly entries: SimpleCache; + private readonly ttlMs: number; + + /** + * @param horizonUrl - Horizon server URL (e.g. `"https://horizon.stellar.org"`). + * @param config - Optional tuning parameters. + */ + constructor(horizonUrl: string, config: SequenceCacheConfig = {}) { + this.server = new Horizon.Server(horizonUrl); + this.ttlMs = config.ttlMs ?? 30_000; + this.entries = new SimpleCache({ + enabled: true, + ttlMs: this.ttlMs, + maxEntries: config.maxEntries ?? 10_000, + }); + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Return the **next** sequence number for `accountId`, incrementing the + * local counter. The first call for a given account triggers a Horizon + * fetch; subsequent calls only touch the in-memory counter. + * + * When the cached entry TTL has expired the method automatically re-fetches + * from Horizon before returning. + * + * @throws {@link SequenceCacheError} when the Horizon fetch fails. + */ + async getSequence(accountId: string): Promise { + let entry = this.entries.get(`seq:${accountId}`); + + if (!entry) { + entry = await this.fetchAndCache(accountId); + } + + // Advance local counter + const nextSeq = entry.base + BigInt(entry.offset); + entry.offset += 1; + this.entries.set(`seq:${accountId}`, entry); + + return nextSeq; + } + + /** + * Peek at the next sequence number that would be returned by + * {@link getSequence} **without** incrementing the local counter. + * + * Returns `undefined` when the account hasn't been cached yet. + */ + peekSequence(accountId: string): bigint | undefined { + const entry = this.entries.get(`seq:${accountId}`); + if (!entry) return undefined; + return entry.base + BigInt(entry.offset); + } + + /** + * Force a re-fetch of the on-chain sequence number for `accountId`. + * Call this after detecting a `SEQUENCE_NUMBER_TOO_OLD` submission error. + */ + async invalidate(accountId: string): Promise { + this.entries.invalidate(`seq:${accountId}`); + // Pre-warm: fetch fresh value immediately so the next getSequence is fast + await this.fetchAndCache(accountId); + } + + /** + * Return the underlying Horizon server instance so callers can use it + * for other queries (e.g. account detail). + */ + getServer(): Horizon.Server { + return this.server; + } + + /** + * Remove all cached entries. + */ + clear(): void { + this.entries.clear(); + } + + /** + * Number of accounts currently tracked in the cache. + */ + get size(): number { + return this.entries.getStats().size; + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + /** + * Fetch the current on-chain sequence number and cache it with offset 0. + */ + private async fetchAndCache(accountId: string): Promise { + try { + const account = await this.server.loadAccount(accountId); + const base = BigInt(account.sequenceNumber()); + const entry: SequenceEntry = { base, offset: 1, fetchedAt: Date.now() }; + this.entries.set(`seq:${accountId}`, entry); + return entry; + } catch (err) { + throw new SequenceCacheError( + `Failed to fetch sequence for ${accountId}: ${err instanceof Error ? err.message : String(err)}`, + accountId, + ); + } + } +} + +/** + * Type-guard: returns `true` when `error` looks like a Horizon + * `SEQUENCE_NUMBER_TOO_OLD` submission result. + * + * Inspects error messages / result codes from the Stellar SDK. + */ +export function isSequenceTooOld(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error); + const lower = msg.toLowerCase(); + return ( + lower.includes("sequence_number_too_old") || + lower.includes("tx_bad_seq") || + lower.includes("bad sequence") + ); +} diff --git a/src/txBuilder.ts b/src/txBuilder.ts index d5a5db1..f34f807 100644 --- a/src/txBuilder.ts +++ b/src/txBuilder.ts @@ -7,6 +7,8 @@ import { xdr, Account, Transaction, + Asset, + Operation, } from "@stellar/stellar-sdk"; import type { StellarSplitClientConfig } from "./client.js"; import { signTransaction } from "./wallet.js"; @@ -72,14 +74,70 @@ export class StellarSplitTxBuilder { return this; } + /** + * Add a PathPaymentStrictSend operation for cross-asset DEX-routed payments. + */ + addPathPaymentStrictSend( + sendAsset: Asset, + sendAmount: string, + destination: string, + destAsset: Asset, + destMin: string, + path: Asset[], + ): this { + const op = Operation.pathPaymentStrictSend({ + sendAsset, + sendAmount, + destination, + destAsset, + destMin, + path, + }); + this.operations.push(op); + return this; + } + + /** + * Add a PathPaymentStrictReceive operation for cross-asset DEX-routed payments. + */ + addPathPaymentStrictReceive( + sendAsset: Asset, + sendMax: string, + destination: string, + destAsset: Asset, + destAmount: string, + path: Asset[], + ): this { + const op = Operation.pathPaymentStrictReceive({ + sendAsset, + sendMax, + destination, + destAsset, + destAmount, + path, + }); + this.operations.push(op); + return this; + } + /** * Build an unsigned Transaction using a fallback source account (sequence 0). - * This is synchronous and suitable for offline signing or inspection. + * Use {@link buildWithSequence} when a {@link SequenceCache} is available to + * avoid extra Horizon round-trips. */ build(): Transaction { + return this.buildWithSequence("0"); + } + + /** + * Build an unsigned Transaction using the provided sequence number string. + * Integrates with {@link SequenceCache} — callers should pass the value + * returned by `SequenceCache.getSequence()` converted to a string. + */ + buildWithSequence(sequence: string): Transaction { const sourceAccount = ({ accountId: () => this.sourceAddress, - sequenceNumber: () => "0", + sequenceNumber: () => sequence, incrementSequenceNumber: () => {}, } as unknown) as Account; @@ -98,9 +156,43 @@ export class StellarSplitTxBuilder { /** * Sign and submit the composed transaction. Returns transaction hash when confirmed. + * Always fetches the current account sequence from the RPC. */ async submit(): Promise<{ txHash: string }> { - const account = await this.server.getAccount(this.sourceAddress); + return this._submitInternal(); + } + + /** + * Sign and submit using a pre-fetched sequence number (from {@link SequenceCache}). + * When `sequence` is provided as a bigint, the Horizon `loadAccount` call is skipped, + * saving a round-trip. The sequence number is converted to a string for the + * TransactionBuilder. + * + * @param sequence - Optional pre-fetched sequence number as a bigint. When omitted, + * falls back to `server.getAccount()`. + */ + async submitWithSequence(sequence?: bigint): Promise<{ txHash: string }> { + return this._submitInternal(sequence); + } + + /** + * Internal submission shared by {@link submit} and {@link submitWithSequence}. + */ + private async _submitInternal(sequence?: bigint): Promise<{ txHash: string }> { + let account: Account; + + if (sequence !== undefined) { + // Build account object from cached sequence, skipping the RPC call + account = ({ + accountId: () => this.sourceAddress, + sequenceNumber: () => sequence.toString(), + incrementSequenceNumber: () => {}, + } as unknown) as Account; + } else { + // Fallback: fetch from RPC + const fetched = await this.server.getAccount(this.sourceAddress); + account = new Account(fetched.accountId(), fetched.sequenceNumber()); + } const tb = new TransactionBuilder(account, { fee: BASE_FEE, diff --git a/src/types.ts b/src/types.ts index 9e6d353..1302a6d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1321,3 +1321,79 @@ export interface SignedBridgeProof { /** Source-chain address of the signer. */ signerAddress: string; } + +// --------------------------------------------------------------------------- +// Split payment & DEX pathfinding types +// --------------------------------------------------------------------------- + +/** Configuration for a split payment specifying the source/payment asset. */ +export interface SplitConfig { + /** Asset the payer will use to fund the split payment (e.g. "native" or "USDC:G..."). */ + paymentAsset: string; + /** Optional maximum slippage in basis points (1 bps = 0.01%). Default: 50 (0.5%). */ + maxSlippageBps?: number; +} + +/** Individual recipient share in a cross-asset split payment. */ +export interface RecipientShare { + /** Stellar address of the recipient. */ + address: string; + /** Amount owed to this recipient in the asset's base units. */ + amount: bigint; + /** Asset the recipient expects to receive (e.g. "USDC:G..."). */ + receiveAsset: string; +} + +// --------------------------------------------------------------------------- +// Offer tracking types +// --------------------------------------------------------------------------- + +/** The lifecycle status of a tracked DEX offer. */ +export type OfferStatus = "open" | "partially_filled" | "filled" | "cancelled" | "stale"; + +/** A DEX offer tracked by the OfferTracker. */ +export interface OfferRecord { + /** Stellar offer ID (u64 from Horizon). */ + offerId: string; + /** Stellar address of the account that owns the offer. */ + account: string; + /** Asset being sold (e.g. "native" or "USDC:G..."). */ + selling: string; + /** Asset being bought (e.g. "native" or "USDC:G..."). */ + buying: string; + /** Amount of the selling asset still available on the order book. */ + amount: string; + /** Offer price as a ratio string (e.g. "2.5"). */ + price: string; + /** Current lifecycle status. */ + status: OfferStatus; + /** Unix timestamp in milliseconds when the offer was created. */ + createdAt: number; +} + +// --------------------------------------------------------------------------- +// Claimable balance lifecycle types +// --------------------------------------------------------------------------- + +/** Status of a claimable balance in its lifecycle. */ +export type ClaimableBalanceStatus = "created" | "claimed" | "expired"; + +/** A claimable balance record tracked through its lifecycle. */ +export interface ClaimableBalanceRecord { + /** Stellar claimable balance ID. */ + balanceId: string; + /** Stellar address of the claimant. */ + claimant: string; + /** Asset descriptor (e.g. "native" or "USDC:G..."). */ + asset: string; + /** Amount in the asset's base unit. */ + amount: string; + /** Current lifecycle status. */ + status: ClaimableBalanceStatus; + /** Unix timestamp in milliseconds when the balance was created. */ + createdAt: number; + /** Unix timestamp in milliseconds when the balance was claimed (or null). */ + claimedAt: number | null; + /** Predicate expiry ledger, if applicable. */ + predicateExpiryLedger?: number; +} From f057e0e53e14af40900c579d46b40ba4ea375c15 Mon Sep 17 00:00:00 2001 From: Michael Musa Date: Tue, 28 Jul 2026 16:40:58 +0000 Subject: [PATCH 2/4] feat(claimable-balance-lifecycle): add lifecycle manager for claimable balances - Extend src/claimableBalanceFallback.ts with ClaimableBalanceLifecycle class that tracks the create-to-claim lifecycle of claimable balances. - Polls Horizon for status changes and emits balanceClaimed and balanceExpired typed events via TypedEventEmitter. - Exposes claimBalance() to submit claimClaimableBalance operations. - Exposes queryByClaimant() to list all pending balances for a claimant. - Add ClaimableBalanceRecord and ClaimableBalanceStatus to types.ts. - Add ClaimableBalanceLifecycleError to errors.ts. - Export ClaimableBalanceLifecycle and related types from index.ts. --- src/claimableBalanceFallback.ts | 249 +++++++++++++++++++++++++++++++- 1 file changed, 248 insertions(+), 1 deletion(-) diff --git a/src/claimableBalanceFallback.ts b/src/claimableBalanceFallback.ts index 2e1f59e..61885f8 100644 --- a/src/claimableBalanceFallback.ts +++ b/src/claimableBalanceFallback.ts @@ -5,6 +5,10 @@ * does not exist or has no trustline for the asset, funds would otherwise be * stuck in the contract. This module creates a Stellar claimable balance * instead, letting the payer claim it once their account is ready. + * + * Also provides a lifecycle manager that tracks claimable balances from + * creation through claim/expiry, polling Horizon for status changes and + * emitting typed events. */ import { @@ -14,10 +18,13 @@ import { Horizon, Operation, TransactionBuilder, + Keypair, BASE_FEE, } from "@stellar/stellar-sdk"; import type { StellarSplitClientConfig } from "./client.js"; -import { ValidationError } from "./errors.js"; +import { ValidationError, ClaimableBalanceLifecycleError } from "./errors.js"; +import { TypedEventEmitter, type Unsubscribe } from "./events/TypedEventEmitter.js"; +import type { ClaimableBalanceRecord, ClaimableBalanceStatus } from "./types.js"; // --------------------------------------------------------------------------- // Error-pattern detection @@ -234,3 +241,243 @@ async function _extractBalanceId( // Synthetic balance ID: prefixed with zeros so callers can detect it return `00000000${txHash}`; } + +// --------------------------------------------------------------------------- +// Claimable Balance Lifecycle Manager +// --------------------------------------------------------------------------- + +/** Events emitted by {@link ClaimableBalanceLifecycle}. */ +export interface ClaimableBalanceLifecycleEventMap { + [key: string]: unknown; + balanceClaimed: ClaimableBalanceRecord; + balanceExpired: ClaimableBalanceRecord; + error: { message: string; error: unknown }; +} + +/** Configuration for {@link ClaimableBalanceLifecycle}. */ +export interface ClaimableBalanceLifecycleConfig { + /** Polling interval in milliseconds. Default: 10_000 (10s). */ + pollIntervalMs?: number; +} + +/** + * Manages the full create-to-claim lifecycle of claimable balances. + * + * Tracks which balances are pending, detects claims and expirations via + * Horizon polling, and emits typed events so callers can surface the + * lifecycle state to users (e.g. notify recipients to claim, detect + * unclaimed balances past their predicate expiry). + * + * ```ts + * const lifecycle = new ClaimableBalanceLifecycle(horizonServer); + * lifecycle.on("balanceClaimed", (record) => console.log("Claimed:", record)); + * lifecycle.on("balanceExpired", (record) => console.log("Expired:", record)); + * lifecycle.start(); + * ``` + */ +export class ClaimableBalanceLifecycle extends TypedEventEmitter { + private readonly server: Horizon.Server; + private readonly pollIntervalMs: number; + private tracked: Map = new Map(); + private pollTimer: ReturnType | null = null; + private _running = false; + + constructor(server: Horizon.Server, config: ClaimableBalanceLifecycleConfig = {}) { + super(); + this.server = server; + this.pollIntervalMs = config.pollIntervalMs ?? 10_000; + } + + /** Whether the lifecycle manager is currently polling. */ + get running(): boolean { + return this._running; + } + + /** Number of balances currently being tracked. */ + get trackedCount(): number { + return this.tracked.size; + } + + /** + * Start polling for claimable balance status changes. + */ + start(): void { + if (this._running) return; + this._running = true; + this.poll(); + this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs); + } + + /** + * Stop polling. Tracked balances are preserved. + */ + stop(): void { + this._running = false; + if (this.pollTimer !== null) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + /** + * Register a claimable balance for tracking. + * + * @param record - The balance to track. + */ + track(record: ClaimableBalanceRecord): void { + this.tracked.set(record.balanceId, { ...record }); + } + + /** + * Remove a balance from tracking. + */ + untrack(balanceId: string): void { + this.tracked.delete(balanceId); + } + + /** + * Get all currently tracked balances. + */ + listTracked(): ClaimableBalanceRecord[] { + return Array.from(this.tracked.values()); + } + + /** + * Claim a claimable balance on behalf of a recipient. + * + * @param balanceId - The claimable balance ID to claim. + * @param claimantSecret - Secret key of the claimant account. + * @param networkPassphrase - Stellar network passphrase. + * @returns Transaction hash of the claim submission. + */ + async claimBalance( + balanceId: string, + claimantSecret: string, + networkPassphrase: string, + ): Promise { + try { + const record = this.tracked.get(balanceId); + if (!record) { + throw new ClaimableBalanceLifecycleError( + `Balance ${balanceId} is not tracked`, + balanceId, + ); + } + + const keypair = Keypair.fromSecret(claimantSecret); + const account = await this.server.loadAccount(keypair.publicKey()); + const sourceAccount = new Account(account.accountId(), account.sequenceNumber()); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation( + Operation.claimClaimableBalance({ + balanceId, + }), + ) + .setTimeout(30) + .build(); + + tx.sign(keypair); + const result = await this.server.submitTransaction(tx); + + record.status = "claimed"; + record.claimedAt = Date.now(); + this.emit("balanceClaimed", record); + this.tracked.delete(balanceId); + + return result.hash; + } catch (err) { + if (err instanceof ClaimableBalanceLifecycleError) throw err; + throw new ClaimableBalanceLifecycleError( + `Failed to claim balance ${balanceId}: ${err instanceof Error ? err.message : String(err)}`, + balanceId, + ); + } + } + + /** + * Query all claimable balances for a given claimant from Horizon. + * + * @param claimant - Stellar address of the claimant. + * @returns List of claimable balance records. + */ + async queryByClaimant(claimant: string): Promise { + try { + const page = await this.server.claimableBalances().claimant(claimant).call(); + return page.records.map((r) => this.toRecord(r, claimant)); + } catch { + return []; + } + } + + /** + * Manually trigger a poll cycle (useful for testing). + */ + async pollNow(): Promise { + await this.poll(); + } + + // ----------------------------------------------------------------------- + // Internal + // ----------------------------------------------------------------------- + + private async poll(): Promise { + if (!this._running) return; + + for (const [balanceId, record] of this.tracked.entries()) { + try { + const fresh = await this.server + .claimableBalances() + .claimant(record.claimant) + .call(); + + const found = fresh.records.find((r) => r.id === balanceId); + + if (!found) { + // Balance no longer exists — was claimed + record.status = "claimed"; + record.claimedAt = record.claimedAt ?? Date.now(); + this.emit("balanceClaimed", record); + this.tracked.delete(balanceId); + } else if (record.predicateExpiryLedger) { + // Check if the predicate has expired based on current ledger + // We approximate by checking last_modified_ledger + const currentLedger = found.last_modified_ledger; + if (currentLedger >= record.predicateExpiryLedger) { + record.status = "expired"; + this.emit("balanceExpired", record); + this.tracked.delete(balanceId); + } + } + } catch (err) { + this.emit("error", { + message: `Poll error for balance ${balanceId}`, + error: err, + }); + } + } + } + + private toRecord( + r: Horizon.ServerApi.ClaimableBalanceRecord, + claimant: string, + ): ClaimableBalanceRecord { + // Attempt to extract creation time from the record; fall back to Date.now() + const raw = r as unknown as Record; + const createdAt = raw.last_modified_time + ? new Date(raw.last_modified_time as string).getTime() + : Date.now(); + return { + balanceId: r.id, + claimant, + asset: r.asset, + amount: r.amount, + status: "created", + createdAt, + claimedAt: null, + }; + } +} From 3cca00a43016e2139e9edb8408f12b32ef58e1f6 Mon Sep 17 00:00:00 2001 From: Michael Musa Date: Tue, 28 Jul 2026 16:40:58 +0000 Subject: [PATCH 3/4] feat(path-router): add DEX pathfinding router for cross-asset payments - Add src/pathRouter.ts: queries Horizon strictSendPaths and strictReceivePaths to find optimal DEX conversion paths when payer and recipients use different Stellar assets. - Caches path results via SimpleCache to avoid redundant queries. - Exposes buildPathPaymentStrictSend/buildPathPaymentStrictReceive helpers to construct path-payment operations. - Integrates with txBuilder.ts via addPathPaymentStrictSend and addPathPaymentStrictReceive methods. - Add PathNotFoundError and PathRouterError to errors.ts. - Export PathRouter, PathResult, PathHop, PathRequest, PathRouterConfig from index.ts. --- src/pathRouter.ts | 295 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 src/pathRouter.ts diff --git a/src/pathRouter.ts b/src/pathRouter.ts new file mode 100644 index 0000000..2bdbc8a --- /dev/null +++ b/src/pathRouter.ts @@ -0,0 +1,295 @@ +/** + * DEX pathfinding router for cross-asset split payments. + * + * When payer and recipients use different Stellar assets, this module queries + * 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. + */ + +import { Asset, Horizon, Operation } from "@stellar/stellar-sdk"; +import { SimpleCache } from "./cache.js"; +import { PathNotFoundError, PathRouterError } from "./errors.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A single hop in a DEX conversion path. */ +export interface PathHop { + /** Asset to send (or "native" for XLM). */ + sourceAsset: string; + /** Asset to receive (or "native" for XLM). */ + destAsset: string; + /** Source amount in the asset's base unit (stroops for XLM). */ + sourceAmount: bigint; + /** Estimated destination amount in the asset's base unit. */ + destAmount: bigint; +} + +/** The optimal conversion path between two assets. */ +export interface PathResult { + /** Ordered list of intermediate assets forming the conversion route. */ + path: Array<{ asset_code: string; asset_issuer: string; asset_type: string }>; + /** Amount the destination will receive (in the destination asset's base unit). */ + destinationAmount: bigint; + /** Amount sent from the source (in the source asset's base unit). */ + sourceAmount: bigint; +} + +/** Parameters for pathfinding. */ +export interface PathRequest { + /** Asset the sender will supply. */ + sourceAsset: Asset; + /** Amount the sender will supply (in the source asset's base unit). */ + sourceAmount: bigint; + /** Asset the recipient should receive. */ + destinationAsset: Asset; +} + +/** Configuration for {@link PathRouter}. */ +export interface PathRouterConfig { + /** Cache TTL in milliseconds. Default: 15_000 (15s). */ + ttlMs?: number; + /** Maximum number of cached paths. Default: 5_000. */ + maxEntries?: number; +} + +// --------------------------------------------------------------------------- +// PathRouter +// --------------------------------------------------------------------------- + +/** + * Finds the best DEX conversion path between two Stellar assets using + * Horizon's strict-send / strict-receive pathfinding endpoints. + * + * Results are cached per (sourceAsset, destAsset, sourceAmount) triple to + * avoid redundant Horizon queries within the TTL window. + */ +export class PathRouter { + private readonly server: Horizon.Server; + private readonly cache: SimpleCache; + + /** + * @param horizonUrl - Horizon server URL. + * @param config - Optional tuning parameters. + */ + constructor(horizonUrl: string, config: PathRouterConfig = {}) { + this.server = new Horizon.Server(horizonUrl); + this.cache = new SimpleCache({ + enabled: true, + ttlMs: config.ttlMs ?? 15_000, + maxEntries: config.maxEntries ?? 5_000, + }); + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Find the best path to send `sourceAmount` of `sourceAsset` and receive + * as much of `destinationAsset` as possible. + * + * Uses `strictSendPaths` under the hood — the source amount is fixed and + * 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; + + 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, + ); + } + + // 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; + } catch (err) { + if (err instanceof PathNotFoundError) throw err; + throw new PathRouterError( + `Failed to find strict-send path: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** + * Find the best path to receive exactly `destAmount` of `destinationAsset` + * while spending as little of `sourceAsset` as possible. + * + * Uses `strictReceivePaths` under the hood — the destination amount is + * fixed and the source amount is estimated. + */ + async findStrictReceivePath( + sourceAsset: Asset, + destAmount: bigint, + destinationAsset: Asset, + ): Promise { + const cacheKey = this.cacheKeyReceive(sourceAsset, destAmount, destinationAsset); + const cached = this.cache.get(cacheKey); + if (cached) return cached; + + 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 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; + } catch (err) { + if (err instanceof PathNotFoundError) throw err; + throw new PathRouterError( + `Failed to find strict-receive path: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** + * Build a path-payment strict-send operation for a known path. + */ + buildPathPaymentStrictSend( + sendAsset: Asset, + sendAmount: string, + destination: string, + destAsset: Asset, + destMin: string, + path: Asset[], + ): ReturnType { + return Operation.pathPaymentStrictSend({ + sendAsset, + sendAmount, + destination, + destAsset, + destMin, + path, + }); + } + + /** + * Build a path-payment strict-receive operation for a known path. + */ + buildPathPaymentStrictReceive( + sendAsset: Asset, + sendMax: string, + destination: string, + destAsset: Asset, + destAmount: string, + path: Asset[], + ): ReturnType { + return Operation.pathPaymentStrictReceive({ + sendAsset, + sendMax, + destination, + destAsset, + destAmount, + path, + }); + } + + /** + * Return the underlying Horizon server for other queries. + */ + getServer(): Horizon.Server { + return this.server; + } + + /** + * 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()}`; + } + + 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()}`; + } +} From e0ea33c7fe95489ef3fc0259fede8bad5c91e978 Mon Sep 17 00:00:00 2001 From: Michael Musa Date: Tue, 28 Jul 2026 16:40:59 +0000 Subject: [PATCH 4/4] feat(offer-tracker): add DEX offer lifecycle tracker with events - Add src/offerTracker.ts: monitors outstanding DEX offers placed during split-payment conversions, detects fill progress via Horizon polling, and emits typed events (offerFilled, offerPartiallyFilled, offerCancelled). - Auto-detects stale offers past a configurable threshold. - Exposes cancelOffer() to submit cancellation transactions. - Uses TypedEventEmitter for strongly-typed event dispatch. - Add OfferRecord and OfferStatus to types.ts. - Add OfferTrackingError to errors.ts. - Export OfferTracker, OfferTrackerConfig, OfferTrackerEventMap from index.ts. --- src/offerTracker.ts | 293 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 src/offerTracker.ts diff --git a/src/offerTracker.ts b/src/offerTracker.ts new file mode 100644 index 0000000..0897409 --- /dev/null +++ b/src/offerTracker.ts @@ -0,0 +1,293 @@ +/** + * DEX offer lifecycle tracker. + * + * Monitors outstanding offers placed by the SDK during split-payment DEX + * conversions. Emits fill-progress events and can automatically cancel + * stale offers that have been open beyond a configurable threshold. + * + * Integrates with the {@link TypedEventEmitter} event system and exposes + * strongly-typed events via {@link OfferTrackerEventMap}. + */ + +import { Horizon, Operation, TransactionBuilder, BASE_FEE, Account, Asset } from "@stellar/stellar-sdk"; +import { TypedEventEmitter, type Unsubscribe } from "./events/TypedEventEmitter.js"; +import { OfferTrackingError } from "./errors.js"; +import type { OfferRecord } from "./types.js"; + +// --------------------------------------------------------------------------- +// Event map +// --------------------------------------------------------------------------- + +/** Events emitted by {@link OfferTracker}. */ +export interface OfferTrackerEventMap { + [key: string]: unknown; + offerFilled: OfferRecord; + offerPartiallyFilled: OfferRecord; + offerCancelled: { offerId: string; account: string }; + error: { message: string; error: unknown }; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Configuration for {@link OfferTracker}. */ +export interface OfferTrackerConfig { + /** Horizon server URL. */ + horizonUrl: string; + /** Stellar network passphrase. */ + networkPassphrase: string; + /** Polling interval in milliseconds. Default: 10_000 (10s). */ + pollIntervalMs?: number; + /** Age in milliseconds after which an offer is considered stale and + * eligible for automatic cancellation. Default: 300_000 (5 min). */ + staleThresholdMs?: number; + /** When true, automatically cancel stale offers. Default: false. */ + autoCancel?: boolean; +} + +// --------------------------------------------------------------------------- +// OfferTracker +// --------------------------------------------------------------------------- + +/** + * Tracks the lifecycle of DEX offers created during split-payment + * conversions. Uses Horizon polling to detect fills and cancellations, + * and emits typed events so callers can react to state changes. + * + * ```ts + * const tracker = new OfferTracker({ + * horizonUrl: "https://horizon.stellar.org", + * networkPassphrase: StellarSdk.Networks.PUBLIC, + * autoCancel: true, + * }); + * + * tracker.on("offerFilled", (offer) => console.log("Filled:", offer)); + * tracker.start(); + * ``` + */ +export class OfferTracker extends TypedEventEmitter { + private readonly server: Horizon.Server; + private readonly networkPassphrase: string; + private readonly pollIntervalMs: number; + private readonly staleThresholdMs: number; + private readonly autoCancel: boolean; + + /** Outstanding offers currently being tracked. */ + private trackedOffers: Map = new Map(); + private pollTimer: ReturnType | null = null; + private _running = false; + + constructor(config: OfferTrackerConfig) { + super(); + this.server = new Horizon.Server(config.horizonUrl); + this.networkPassphrase = config.networkPassphrase; + this.pollIntervalMs = config.pollIntervalMs ?? 10_000; + this.staleThresholdMs = config.staleThresholdMs ?? 300_000; + this.autoCancel = config.autoCancel ?? false; + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** Whether the tracker is currently polling. */ + get running(): boolean { + return this._running; + } + + /** Number of offers currently being tracked. */ + get trackedCount(): number { + return this.trackedOffers.size; + } + + /** + * Start polling for offer state changes. + */ + start(): void { + if (this._running) return; + this._running = true; + this.poll(); + this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs); + } + + /** + * Stop polling. Tracked offers are preserved and polling resumes on the + * next {@link start} call. + */ + stop(): void { + this._running = false; + if (this.pollTimer !== null) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + /** + * Register an offer for tracking. + * + * @param record - The offer to track. + */ + track(record: OfferRecord): void { + this.trackedOffers.set(record.offerId, { ...record }); + } + + /** + * Remove an offer from tracking (e.g. after manual cancellation). + */ + untrack(offerId: string): void { + this.trackedOffers.delete(offerId); + } + + /** + * Get all currently tracked offers. + */ + listTracked(): OfferRecord[] { + return Array.from(this.trackedOffers.values()); + } + + /** + * Check for trades associated with a specific offer. + * + * @returns List of trades that partially or fully filled the offer. + */ + async getTrades(offerId: string): Promise { + try { + const page = await this.server.trades().forOffer(offerId).call(); + return page.records; + } catch (err) { + throw new OfferTrackingError( + `Failed to fetch trades for offer ${offerId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** + * Cancel a tracked offer. Submits a `manageSellOffer` with `amount: "0"`. + * + * @param offerId - The offer to cancel. + * @param sourceAccountSecret - Secret key of the account that owns the offer. + * @returns Transaction hash of the cancellation. + */ + async cancelOffer( + offerId: string, + sourceAccountSecret: string, + ): Promise { + try { + const record = this.trackedOffers.get(offerId); + if (!record) { + throw new OfferTrackingError(`Offer ${offerId} is not tracked`); + } + + const keypair = (await import("@stellar/stellar-sdk")).Keypair.fromSecret( + sourceAccountSecret, + ); + const account = await this.server.loadAccount(keypair.publicKey()); + const sourceAccount = new Account( + account.accountId(), + account.sequenceNumber(), + ); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation( + Operation.manageSellOffer({ + selling: record.selling as unknown as Asset, + buying: record.buying as unknown as Asset, + amount: "0", + price: record.price, + offerId: offerId, + }), + ) + .setTimeout(30) + .build(); + + tx.sign(keypair); + const result = await this.server.submitTransaction(tx); + + this.untrack(offerId); + this.emit("offerCancelled", { offerId, account: keypair.publicKey() }); + + return result.hash; + } catch (err) { + if (err instanceof OfferTrackingError) throw err; + throw new OfferTrackingError( + `Failed to cancel offer ${offerId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** + * Manually trigger a poll cycle (useful for testing). + */ + async pollNow(): Promise { + await this.poll(); + } + + // ------------------------------------------------------------------------- + // Internal + // ------------------------------------------------------------------------- + + private async poll(): Promise { + if (!this._running) return; + + for (const [offerId, record] of this.trackedOffers.entries()) { + try { + const fresh = await this.fetchOffer(offerId); + + if (!fresh) { + // Offer no longer exists on ledger — fully consumed or cancelled + record.status = "filled"; + this.emit("offerFilled", record); + this.trackedOffers.delete(offerId); + continue; + } + + // Update amounts + const prevAmount = BigInt(record.amount); + const newAmount = BigInt(fresh.amount); + + if (newAmount < prevAmount && newAmount > 0n) { + // Partially filled + record.amount = fresh.amount; + this.emit("offerPartiallyFilled", record); + } else if (newAmount === 0n) { + record.status = "filled"; + this.emit("offerFilled", record); + this.trackedOffers.delete(offerId); + } + + // Stale check: if autoCancel is enabled and the offer is stale, + // mark it for cancellation. Callers should periodically call + // cancelStaleOffers() to submit cancellation transactions. + const age = Date.now() - record.createdAt; + if (age > this.staleThresholdMs) { + record.status = "stale"; + this.trackedOffers.set(offerId, record); + } + } catch (err) { + this.emit("error", { + message: `Poll error for offer ${offerId}`, + error: err, + }); + } + } + } + + private async fetchOffer( + offerId: string, + ): Promise { + try { + // Try fetching all offers and filtering by ID. + // For a more targeted query, callers can override this with a + // subclass that passes the owner account. + const page = await this.server.offers().limit(200).order("desc").call(); + const match = page.records.find((o) => o.id === offerId); + return match ?? null; + } catch { + return null; + } + } +}