Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
/** 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<SDKEventMap>();

/**
* 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<K extends keyof SDKEventMap>(event: K, payload: SDKEventMap[K]): void {
sdkEvents.emit(event, payload);
}
Expand Down
48 changes: 47 additions & 1 deletion src/feeComparator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -187,4 +233,4 @@ export async function compareFundingPaths(
}

return { direct, swap, recommended };
}
}
26 changes: 26 additions & 0 deletions src/rateCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ export type RateOracleFn<TRate> = (from: string, to: string) => Promise<TRate>;
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}`;
Expand All @@ -41,19 +44,26 @@ export class RateCache<TRate = number> {
private readonly store = new Map<string, RateCacheEntry<TRate>>();
private readonly oracle: RateOracleFn<TRate>;
private readonly ttlMs: number;
private readonly maxSize: number;
private refreshTimer: ReturnType<typeof setInterval> | null = null;
private _running = false;

constructor(oracle: RateOracleFn<TRate>, 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. */
get running(): boolean {
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
Expand All @@ -68,6 +78,7 @@ export class RateCache<TRate = number> {

const rate = await this.oracle(from, to);
this.store.set(key, { rate, fetchedAt: Date.now() });
this.evictOverflow();
return rate;
}

Expand Down Expand Up @@ -110,9 +121,24 @@ export class RateCache<TRate = number> {
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);
}
}
}
59 changes: 49 additions & 10 deletions src/rateLimiter.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();

constructor(config: RateLimiterConfig) {
this._maxTokens = config.maxRequestsPerSecond;
this._perKeyLimit = config.perKeyLimit;
this._tokens = this._maxTokens;
this._refillIntervalMs = 1000;
this._lastRefillTime = Date.now();
Expand All @@ -27,17 +36,18 @@ export class RateLimiter {
this._tokens + periods * this._maxTokens
);
this._lastRefillTime += periods * this._refillIntervalMs;
this._perKeyCounts.clear();
}
}

acquire(): Promise<void> {
acquire(key?: string): Promise<void> {
this._refill();
if (this._tokens > 0) {
this._tokens--;
if (this._canAcquire(key)) {
this._consumeToken(key);
return Promise.resolve();
}
return new Promise<void>((resolve) => {
this._queue.push(resolve);
this._queue.push({ key, resolve });
if (!this._processing) {
this._processQueue();
}
Expand All @@ -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();
Expand All @@ -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);
}
}
36 changes: 36 additions & 0 deletions test/feeComparator.mixed-assets.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 14 additions & 0 deletions test/rateCache.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
39 changes: 39 additions & 0 deletions test/rateLimiter.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});