diff --git a/src/events.ts b/src/events.ts index b3b0529..783b43e 100644 --- a/src/events.ts +++ b/src/events.ts @@ -2,34 +2,48 @@ import { rpc as SorobanRpc } from "@stellar/stellar-sdk"; import { TypedEventEmitter } from "./events/TypedEventEmitter.js"; import type { FinalityStatus } from "./types.js"; -/** Type of contract event. */ +/** Event names emitted by Soroban contract activity during the invoice lifecycle. */ export type ContractEventType = "created" | "payment" | "released" | "refunded"; -/** A Soroban contract event. */ +/** Normalized Soroban contract event payload returned by the SDK replay helpers. */ export interface ContractEvent { - /** Event type. */ + /** The contract event verb that was emitted on-chain. */ type: ContractEventType; - /** Invoice ID associated with the event. */ + /** The invoice identifier extracted from the event body. */ invoiceId: string; - /** Event data. */ + /** The raw event value emitted by the Soroban host. */ data: unknown; - /** Ledger sequence number. */ + /** The ledger sequence that included the event. */ ledger: number; - /** Unix timestamp of the event. */ + /** Unix timestamp, in seconds, inferred from the event metadata. */ timestamp: number; } +/** SDK-level events emitted as internal workflows progress. */ export interface SDKEventMap extends Record { + /** Emitted when a monitored stream has stopped producing data within the allowed interval. */ streamStallDetected: { streamId: string }; + /** Emitted after the SDK automatically reconnects or resets a stalled stream. */ streamAutoReset: { streamId: string }; + /** Emitted when a tracked invoice transaction reaches a finality state. */ invoiceFinalized: { txHash: string; finality: FinalityStatus }; + /** Emitted when an approval workflow requests a signer response. */ approvalRequested: { signerPublicKey: string }; + /** Emitted when a signer submits an approval for the workflow. */ approvalReceived: { signerPublicKey: string }; + /** Emitted when the approval workflow reaches its required signer count. */ approvalWorkflowComplete: { signerCount: number }; } +/** Shared typed emitter for SDK lifecycle events. */ export const sdkEvents = new TypedEventEmitter(); +/** + * Emit a typed SDK lifecycle event to all registered listeners. + * + * @param event - The SDK event name to publish. + * @param payload - The strongly typed payload associated with the event name. + */ export function emitSdkEvent(event: K, payload: SDKEventMap[K]): void { sdkEvents.emit(event, payload); } diff --git a/src/feeComparator.ts b/src/feeComparator.ts index 0e956b8..d701d30 100644 --- a/src/feeComparator.ts +++ b/src/feeComparator.ts @@ -16,6 +16,21 @@ import { import type { Invoice } from "./types.js"; import { SimulationFailedError, NoReturnValueError } from "./errors.js"; +export interface ComparableFee { + amount: number; + asset: string; +} + +export type ExchangeRateProvider = (fromAsset: string, toAsset: string) => number; + +export class DivisionByZeroError extends Error { + constructor(fromAsset: string, toAsset: string) { + super(`Exchange rate from ${fromAsset} to ${toAsset} cannot be zero`); + this.name = "DivisionByZeroError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + export interface CostEstimate { resourceFee: bigint; swapSlippage: bigint; @@ -35,6 +50,37 @@ export interface FeeComparatorConfig { dexContractId?: string; } +export function compareFees( + left: ComparableFee, + right: ComparableFee, + getRate: ExchangeRateProvider, +): number { + const leftAmount = left.amount; + const rightAmount = + left.asset === right.asset + ? right.amount + : convertFeeAmount(right, left.asset, getRate); + + if (leftAmount === rightAmount) { + return 0; + } + + return leftAmount < rightAmount ? -1 : 1; +} + +function convertFeeAmount( + fee: ComparableFee, + targetAsset: string, + getRate: ExchangeRateProvider, +): number { + const rate = getRate(fee.asset, targetAsset); + if (rate === 0) { + throw new DivisionByZeroError(fee.asset, targetAsset); + } + + return fee.amount * rate; +} + async function simulateResourceFee( server: SorobanRpc.Server, contractId: string, @@ -187,4 +233,4 @@ export async function compareFundingPaths( } return { direct, swap, recommended }; -} \ No newline at end of file +} diff --git a/src/rateCache.ts b/src/rateCache.ts index a6e8771..888ac69 100644 --- a/src/rateCache.ts +++ b/src/rateCache.ts @@ -20,9 +20,12 @@ export type RateOracleFn = (from: string, to: string) => Promise; export interface RateCacheConfig { /** Time-to-live for a cached rate, in milliseconds. Default: 60_000 (60s). */ ttlMs?: number; + /** Maximum number of cached pairs retained before the oldest entry is evicted. */ + maxSize?: number; } const DEFAULT_TTL_MS = 60_000; +const DEFAULT_MAX_SIZE = Number.POSITIVE_INFINITY; function cacheKey(from: string, to: string): string { return `${from}:${to}`; @@ -41,12 +44,14 @@ export class RateCache { private readonly store = new Map>(); private readonly oracle: RateOracleFn; private readonly ttlMs: number; + private readonly maxSize: number; private refreshTimer: ReturnType | null = null; private _running = false; constructor(oracle: RateOracleFn, config: RateCacheConfig = {}) { this.oracle = oracle; this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS; + this.maxSize = config.maxSize ?? DEFAULT_MAX_SIZE; } /** Whether background refresh is currently running. */ @@ -54,6 +59,11 @@ export class RateCache { return this._running; } + /** Current number of cached asset pairs. */ + get size(): number { + return this.store.size; + } + /** * Get the exchange rate for `from -> to`, serving from cache when the * entry is younger than `ttlMs`. On a cache miss (or expired entry), calls @@ -68,6 +78,7 @@ export class RateCache { const rate = await this.oracle(from, to); this.store.set(key, { rate, fetchedAt: Date.now() }); + this.evictOverflow(); return rate; } @@ -110,9 +121,24 @@ export class RateCache { try { const rate = await this.oracle(from, to); this.store.set(key, { rate, fetchedAt: Date.now() }); + this.evictOverflow(); } catch { // Best-effort background refresh; keep the stale entry until the next attempt. } } } + + private evictOverflow(): void { + if (this.maxSize === Number.POSITIVE_INFINITY) { + return; + } + + while (this.store.size > this.maxSize) { + const oldestKey = this.store.keys().next().value; + if (oldestKey === undefined) { + break; + } + this.store.delete(oldestKey); + } + } } diff --git a/src/rateLimiter.ts b/src/rateLimiter.ts index 62b687e..0ec8674 100644 --- a/src/rateLimiter.ts +++ b/src/rateLimiter.ts @@ -1,17 +1,26 @@ export interface RateLimiterConfig { maxRequestsPerSecond: number; + perKeyLimit?: number; +} + +interface PendingAcquire { + key?: string; + resolve: () => void; } export class RateLimiter { private _tokens: number; private _maxTokens: number; + private _perKeyLimit?: number; private _refillIntervalMs: number; private _lastRefillTime: number; - private _queue: Array<() => void> = []; + private _queue: PendingAcquire[] = []; private _processing = false; + private _perKeyCounts = new Map(); constructor(config: RateLimiterConfig) { this._maxTokens = config.maxRequestsPerSecond; + this._perKeyLimit = config.perKeyLimit; this._tokens = this._maxTokens; this._refillIntervalMs = 1000; this._lastRefillTime = Date.now(); @@ -27,17 +36,18 @@ export class RateLimiter { this._tokens + periods * this._maxTokens ); this._lastRefillTime += periods * this._refillIntervalMs; + this._perKeyCounts.clear(); } } - acquire(): Promise { + acquire(key?: string): Promise { this._refill(); - if (this._tokens > 0) { - this._tokens--; + if (this._canAcquire(key)) { + this._consumeToken(key); return Promise.resolve(); } return new Promise((resolve) => { - this._queue.push(resolve); + this._queue.push({ key, resolve }); if (!this._processing) { this._processQueue(); } @@ -52,12 +62,20 @@ export class RateLimiter { ); setTimeout(() => { this._refill(); - while (this._tokens > 0 && this._queue.length > 0) { - const next = this._queue.shift(); - if (next) { - this._tokens--; - next(); + let index = 0; + while (this._tokens > 0 && index < this._queue.length) { + const next = this._queue[index]; + if (!next) { + index++; + continue; } + if (this._canAcquire(next.key)) { + this._queue.splice(index, 1); + this._consumeToken(next.key); + next.resolve(); + continue; + } + index++; } if (this._queue.length > 0) { this._processQueue(); @@ -66,4 +84,25 @@ export class RateLimiter { } }, msUntilRefill); } + + private _canAcquire(key?: string): boolean { + if (this._tokens <= 0) { + return false; + } + + if (!key || this._perKeyLimit === undefined) { + return true; + } + + return (this._perKeyCounts.get(key) ?? 0) < this._perKeyLimit; + } + + private _consumeToken(key?: string): void { + this._tokens--; + if (!key || this._perKeyLimit === undefined) { + return; + } + + this._perKeyCounts.set(key, (this._perKeyCounts.get(key) ?? 0) + 1); + } } diff --git a/test/feeComparator.mixed-assets.test.ts b/test/feeComparator.mixed-assets.test.ts new file mode 100644 index 0000000..9cc6664 --- /dev/null +++ b/test/feeComparator.mixed-assets.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { compareFees, DivisionByZeroError } from "../src/feeComparator.js"; + +describe("compareFees", () => { + it("orders mixed-asset fees using a mock exchange rate", () => { + const rateProvider = () => 2; + + expect( + compareFees( + { amount: 100, asset: "XLM" }, + { amount: 60, asset: "USDC" }, + rateProvider, + ), + ).toBe(-1); + }); + + it("throws when the exchange rate is zero", () => { + expect(() => + compareFees( + { amount: 100, asset: "XLM" }, + { amount: 60, asset: "USDC" }, + () => 0, + ), + ).toThrow(DivisionByZeroError); + }); + + it("returns zero for equal mixed-asset fees after conversion", () => { + expect( + compareFees( + { amount: 100, asset: "XLM" }, + { amount: 50, asset: "USDC" }, + () => 2, + ), + ).toBe(0); + }); +}); diff --git a/test/rateCache.test.ts b/test/rateCache.test.ts new file mode 100644 index 0000000..1e50e5f --- /dev/null +++ b/test/rateCache.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { RateCache } from "../src/rateCache.js"; + +describe("RateCache", () => { + it("evicts after inserting maxSize + 1 entries", async () => { + const cache = new RateCache(async (from, to) => `${from}:${to}`, { maxSize: 2 }); + + await cache.getRate("XLM", "USD"); + await cache.getRate("USDC", "USD"); + await cache.getRate("BTC", "USD"); + + expect(cache.size).toBe(2); + }); +}); diff --git a/test/rateLimiter.test.ts b/test/rateLimiter.test.ts new file mode 100644 index 0000000..53fbb87 --- /dev/null +++ b/test/rateLimiter.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import { RateLimiter } from "../src/rateLimiter.js"; + +describe("RateLimiter", () => { + it("enforces per-key limits while preserving the global limit", async () => { + vi.useFakeTimers(); + + const limiter = new RateLimiter({ maxRequestsPerSecond: 3, perKeyLimit: 2 }); + + await limiter.acquire("alice"); + await limiter.acquire("alice"); + await limiter.acquire("bob"); + + const blockedAlice = limiter.acquire("alice"); + const blockedBob = limiter.acquire("bob"); + + let aliceResolved = false; + let bobResolved = false; + void blockedAlice.then(() => { + aliceResolved = true; + }); + void blockedBob.then(() => { + bobResolved = true; + }); + + await vi.advanceTimersByTimeAsync(999); + expect(aliceResolved).toBe(false); + expect(bobResolved).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await blockedAlice; + await blockedBob; + + expect(aliceResolved).toBe(true); + expect(bobResolved).toBe(true); + + vi.useRealTimers(); + }); +});