From 691d3e726ef02feb7a21d3042a9a1eff3164c6a0 Mon Sep 17 00:00:00 2001 From: Mercy Duru Date: Mon, 29 Jun 2026 13:46:53 +0100 Subject: [PATCH] Implement Stellar Route Delivery and Reliability --- src/cache/routes/stellar/index.ts | 32 + src/cache/routes/stellar/routeCache.ts | 326 +++---- .../stellar-route-discovery-cache.spec.ts | 795 +++++++++++++++++ .../stellar/stellar-route-discovery-cache.ts | 558 ++++++++++++ src/scoring/reliability/stellar/index.ts | 23 + .../soroban-route-reliability-scorer.spec.ts | 798 ++++++++++++++++++ .../soroban-route-reliability-scorer.ts | 660 +++++++++++++++ .../soroban-route-reliability.types.ts | 177 ++++ 8 files changed, 3206 insertions(+), 163 deletions(-) create mode 100644 src/cache/routes/stellar/index.ts create mode 100644 src/cache/routes/stellar/stellar-route-discovery-cache.spec.ts create mode 100644 src/cache/routes/stellar/stellar-route-discovery-cache.ts create mode 100644 src/scoring/reliability/stellar/index.ts create mode 100644 src/scoring/reliability/stellar/soroban-route-reliability-scorer.spec.ts create mode 100644 src/scoring/reliability/stellar/soroban-route-reliability-scorer.ts create mode 100644 src/scoring/reliability/stellar/soroban-route-reliability.types.ts diff --git a/src/cache/routes/stellar/index.ts b/src/cache/routes/stellar/index.ts new file mode 100644 index 00000000..3c3d4ebc --- /dev/null +++ b/src/cache/routes/stellar/index.ts @@ -0,0 +1,32 @@ +/** + * Stellar Route Discovery Cache Module + * + * Provides caching for discovered Stellar bridge routes to reduce + * repeated route discovery latency. + * + * @module + */ + +export { + StellarRouteDiscoveryCache, + routeDiscoveryCache, + buildRouteDiscoveryKey, + buildRouteKey, +} from './stellar-route-discovery-cache'; + +export type { + RouteDiscoveryCacheEntry, + RouteDiscoveryCacheConfig, + RouteDiscoveryCacheStats, + RouteDiscoveryQuery, +} from './stellar-route-discovery-cache'; + +// Also export the original routeCache for backward compatibility +export { RouteCacheStore, routeCache } from './routeCache'; + +export type { + RouteQuery, + RouteResponse, + CacheEntry, + RouteCacheOptions, +} from './routeCache'; diff --git a/src/cache/routes/stellar/routeCache.ts b/src/cache/routes/stellar/routeCache.ts index 6b1e4fea..7f9df65e 100644 --- a/src/cache/routes/stellar/routeCache.ts +++ b/src/cache/routes/stellar/routeCache.ts @@ -6,181 +6,181 @@ */ export interface RouteQuery { - fromAsset: string; - toAsset: string; - fromNetwork: string; - toNetwork: string; - amount?: string; + fromAsset: string; + toAsset: string; + fromNetwork: string; + toNetwork: string; + amount?: string; +} + +export interface RouteResponse { + path: string[]; + estimatedFee: number; + estimatedTimeMs: number; + bridgeId: string; + metadata?: Record; +} + +export interface CacheEntry { + data: T; + cachedAt: number; // Unix timestamp (ms) + ttlMs: number; +} + +export interface RouteCacheOptions { + /** Default TTL for entries in milliseconds. Default: 30000 (30s) */ + defaultTtlMs?: number; + /** Maximum number of entries. Default: 500 */ + maxEntries?: number; +} + +function buildCacheKey(query: RouteQuery): string { + return [ + query.fromAsset, + query.toAsset, + query.fromNetwork, + query.toNetwork, + query.amount ?? 'any', + ] + .join(':') + .toLowerCase(); +} + +/** + * RouteCacheStore + * + * In-memory LRU-style cache for Stellar bridge route computations. + * Automatically evicts expired entries on read and enforces max capacity. + */ +export class RouteCacheStore { + private cache: Map> = new Map(); + private defaultTtlMs: number; + private maxEntries: number; + + constructor(options: RouteCacheOptions = {}) { + this.defaultTtlMs = options.defaultTtlMs ?? 30_000; + this.maxEntries = options.maxEntries ?? 500; } - - export interface RouteResponse { - path: string[]; - estimatedFee: number; - estimatedTimeMs: number; - bridgeId: string; - metadata?: Record; + + /** + * Store a route response in the cache. + */ + set(query: RouteQuery, response: RouteResponse, ttlMs?: number): void { + const key = buildCacheKey(query); + + // Evict oldest entry if at capacity + if (this.cache.size >= this.maxEntries && !this.cache.has(key)) { + const oldestKey = this.cache.keys().next().value; + if (oldestKey) this.cache.delete(oldestKey); + } + + this.cache.set(key, { + data: response, + cachedAt: Date.now(), + ttlMs: ttlMs ?? this.defaultTtlMs, + }); } - - export interface CacheEntry { - data: T; - cachedAt: number; // Unix timestamp (ms) - ttlMs: number; + + /** + * Retrieve a cached route response. + * Returns null if not found or expired. + */ + get(query: RouteQuery): RouteResponse | null { + const key = buildCacheKey(query); + const entry = this.cache.get(key); + + if (!entry) return null; + + if (this.isExpired(entry)) { + this.cache.delete(key); + return null; + } + + // Move to end to simulate LRU + this.cache.delete(key); + this.cache.set(key, entry); + + return entry.data; } - - export interface RouteCacheOptions { - /** Default TTL for entries in milliseconds. Default: 30000 (30s) */ - defaultTtlMs?: number; - /** Maximum number of entries. Default: 500 */ - maxEntries?: number; + + /** + * Check if a cache entry exists and is still valid. + */ + has(query: RouteQuery): boolean { + return this.get(query) !== null; } - - function buildCacheKey(query: RouteQuery): string { - return [ - query.fromAsset, - query.toAsset, - query.fromNetwork, - query.toNetwork, - query.amount ?? "any", - ] - .join(":") - .toLowerCase(); + + /** + * Invalidate a specific route cache entry. + */ + invalidate(query: RouteQuery): boolean { + const key = buildCacheKey(query); + return this.cache.delete(key); } - + /** - * RouteCacheStore - * - * In-memory LRU-style cache for Stellar bridge route computations. - * Automatically evicts expired entries on read and enforces max capacity. + * Invalidate all cache entries matching a bridge id. */ - export class RouteCacheStore { - private cache: Map> = new Map(); - private defaultTtlMs: number; - private maxEntries: number; - - constructor(options: RouteCacheOptions = {}) { - this.defaultTtlMs = options.defaultTtlMs ?? 30_000; - this.maxEntries = options.maxEntries ?? 500; - } - - /** - * Store a route response in the cache. - */ - set(query: RouteQuery, response: RouteResponse, ttlMs?: number): void { - const key = buildCacheKey(query); - - // Evict oldest entry if at capacity - if (this.cache.size >= this.maxEntries && !this.cache.has(key)) { - const oldestKey = this.cache.keys().next().value; - if (oldestKey) this.cache.delete(oldestKey); + invalidateByBridge(bridgeId: string): number { + let count = 0; + for (const [key, entry] of this.cache.entries()) { + if (entry.data.bridgeId === bridgeId) { + this.cache.delete(key); + count++; } - - this.cache.set(key, { - data: response, - cachedAt: Date.now(), - ttlMs: ttlMs ?? this.defaultTtlMs, - }); } - - /** - * Retrieve a cached route response. - * Returns null if not found or expired. - */ - get(query: RouteQuery): RouteResponse | null { - const key = buildCacheKey(query); - const entry = this.cache.get(key); - - if (!entry) return null; - + return count; + } + + /** + * Purge all expired entries from the cache. + */ + purgeExpired(): number { + let count = 0; + for (const [key, entry] of this.cache.entries()) { if (this.isExpired(entry)) { this.cache.delete(key); - return null; - } - - // Move to end to simulate LRU - this.cache.delete(key); - this.cache.set(key, entry); - - return entry.data; - } - - /** - * Check if a cache entry exists and is still valid. - */ - has(query: RouteQuery): boolean { - return this.get(query) !== null; - } - - /** - * Invalidate a specific route cache entry. - */ - invalidate(query: RouteQuery): boolean { - const key = buildCacheKey(query); - return this.cache.delete(key); - } - - /** - * Invalidate all cache entries matching a bridge id. - */ - invalidateByBridge(bridgeId: string): number { - let count = 0; - for (const [key, entry] of this.cache.entries()) { - if (entry.data.bridgeId === bridgeId) { - this.cache.delete(key); - count++; - } - } - return count; - } - - /** - * Purge all expired entries from the cache. - */ - purgeExpired(): number { - let count = 0; - for (const [key, entry] of this.cache.entries()) { - if (this.isExpired(entry)) { - this.cache.delete(key); - count++; - } - } - return count; - } - - /** - * Clear all cache entries. - */ - clear(): void { - this.cache.clear(); - } - - /** - * Current number of entries (including possibly expired). - */ - get size(): number { - return this.cache.size; - } - - /** - * Returns cache statistics. - */ - stats(): { total: number; expired: number; valid: number } { - let expired = 0; - for (const entry of this.cache.values()) { - if (this.isExpired(entry)) expired++; + count++; } - return { - total: this.cache.size, - expired, - valid: this.cache.size - expired, - }; } - - private isExpired(entry: CacheEntry): boolean { - return Date.now() - entry.cachedAt > entry.ttlMs; + return count; + } + + /** + * Clear all cache entries. + */ + clear(): void { + this.cache.clear(); + } + + /** + * Current number of entries (including possibly expired). + */ + get size(): number { + return this.cache.size; + } + + /** + * Returns cache statistics. + */ + stats(): { total: number; expired: number; valid: number } { + let expired = 0; + for (const entry of this.cache.values()) { + if (this.isExpired(entry)) expired++; } + return { + total: this.cache.size, + expired, + valid: this.cache.size - expired, + }; } - - // Default shared instance - export const routeCache = new RouteCacheStore(); - - export default routeCache; \ No newline at end of file + + private isExpired(entry: CacheEntry): boolean { + return Date.now() - entry.cachedAt > entry.ttlMs; + } +} + +// Default shared instance +export const routeCache = new RouteCacheStore(); + +export default routeCache; diff --git a/src/cache/routes/stellar/stellar-route-discovery-cache.spec.ts b/src/cache/routes/stellar/stellar-route-discovery-cache.spec.ts new file mode 100644 index 00000000..3eddb9f7 --- /dev/null +++ b/src/cache/routes/stellar/stellar-route-discovery-cache.spec.ts @@ -0,0 +1,795 @@ +/** + * Tests for StellarRouteDiscoveryCache + * + * Comprehensive test suite covering: + * - Basic cache operations (set, get, has) + * - TTL expiration and stale entry handling + * - LRU eviction + * - Background cleanup + * - Invalidation strategies + * - Cache statistics + * - Edge cases and error handling + */ + +import { + StellarRouteDiscoveryCache, + buildRouteDiscoveryKey, + buildRouteKey, + type RouteDiscoveryCacheEntry, +} from './stellar-route-discovery-cache'; +import type { + BridgeRoute, + AssetChain, +} from '../../../matrix/assets/routes/stellar/types'; + +// ─── Test Helpers ───────────────────────────────────────────────────────────── + +const createMockRoute = ( + overrides: Partial = {}, +): BridgeRoute => ({ + id: 'stellar-ethereum-allbridge-usdc', + fromChain: 'stellar', + toChain: 'ethereum', + bridgeProtocol: 'Allbridge', + provider: 'allbridge', + supportedAssets: ['USDC'], + estimatedTimeMinutes: 15, + status: 'active', + available: true, + ...overrides, +}); + +const createMockTime = () => { + let currentTime = 1000000; + return { + now: () => currentTime, + advance: (ms: number) => { + currentTime += ms; + }, + getTime: () => currentTime, + }; +}; + +describe('StellarRouteDiscoveryCache', () => { + let mockTime: ReturnType; + + beforeEach(() => { + mockTime = createMockTime(); + }); + + // ─── Basic Operations ───────────────────────────────────────────────────── + + describe('set and get', () => { + it('stores and retrieves a single route', async () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + const route = createMockRoute(); + + cache.set('test-key', route); + const retrieved = await cache.get('test-key'); + + expect(retrieved).toEqual(route); + }); + + it('stores and retrieves multiple routes', async () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + const routes = [createMockRoute(), createMockRoute({ id: 'route-2' })]; + + cache.set('test-key', routes); + const retrieved = await cache.get('test-key'); + + expect(retrieved).toEqual(routes); + expect(Array.isArray(retrieved)).toBe(true); + }); + + it('returns null for non-existent key', async () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + const result = await cache.get('non-existent'); + expect(result).toBeNull(); + }); + + it('overwrites existing entries', async () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + const route1 = createMockRoute(); + const route2 = createMockRoute({ id: 'updated-route' }); + + cache.set('test-key', route1); + cache.set('test-key', route2); + const retrieved = await cache.get('test-key'); + + expect(retrieved).toEqual(route2); + }); + + it('supports custom TTL per entry', async () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 60000, + }); + + cache.set('short-ttl', createMockRoute(), { ttlMs: 5000 }); + cache.set('long-ttl', createMockRoute(), { ttlMs: 120000 }); + + mockTime.advance(10000); + + // Short TTL should be expired + expect(await cache.get('short-ttl')).toBeNull(); + // Long TTL should still be valid + expect(await cache.get('long-ttl')).not.toBeNull(); + }); + + it('supports metadata storage', async () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + const route = createMockRoute(); + + cache.set('test-key', route, { + metadata: { + discoverySource: 'provider-api', + version: '1.0.0', + }, + }); + + const entry = cache.peek('test-key'); + expect(entry?.metadata?.discoverySource).toBe('provider-api'); + expect(entry?.metadata?.version).toBe('1.0.0'); + }); + }); + + describe('getSync', () => { + it('retrieves entries synchronously', () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + const route = createMockRoute(); + + cache.set('test-key', route); + const retrieved = cache.getSync('test-key'); + + expect(retrieved).toEqual(route); + }); + + it('does not trigger stale callbacks', () => { + const staleCallback = jest.fn(); + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 10000, + staleThresholdPercent: 0.5, + onStale: staleCallback, + }); + + cache.set('test-key', createMockRoute()); + mockTime.advance(6000); // Past stale threshold + + const result = cache.getSync('test-key'); + expect(result).not.toBeNull(); + expect(staleCallback).not.toHaveBeenCalled(); + }); + }); + + // ─── Expiration and Staleness ────────────────────────────────────────────── + + describe('expiration', () => { + it('expires entries after TTL', async () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 10000, + }); + + cache.set('test-key', createMockRoute()); + expect(await cache.get('test-key')).not.toBeNull(); + + mockTime.advance(11000); // Past TTL + expect(await cache.get('test-key')).toBeNull(); + }); + + it('removes expired entries on access', async () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 10000, + }); + + cache.set('test-key', createMockRoute()); + mockTime.advance(11000); + + await cache.get('test-key'); + expect(cache.size).toBe(0); + }); + + it('uses default TTL when not specified', async () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 5000, + }); + + cache.set('test-key', createMockRoute()); + mockTime.advance(6000); + + expect(await cache.get('test-key')).toBeNull(); + }); + }); + + describe('staleness', () => { + it('detects stale entries before expiration', async () => { + const staleCallback = jest.fn(); + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 10000, + staleThresholdPercent: 0.5, + onStale: staleCallback, + }); + + cache.set('test-key', createMockRoute()); + mockTime.advance(6000); // Past stale (5000ms) but not expired (10000ms) + + const result = await cache.get('test-key'); + expect(result).not.toBeNull(); + expect(staleCallback).toHaveBeenCalledWith('test-key', expect.anything()); + }); + + it('continues to return stale entries', async () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 10000, + staleThresholdPercent: 0.5, + }); + + cache.set('test-key', createMockRoute()); + mockTime.advance(6000); + + const result = await cache.get('test-key'); + expect(result).not.toBeNull(); + }); + + it('handles stale callback errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 10000, + staleThresholdPercent: 0.5, + onStale: async () => { + throw new Error('Stale callback failed'); + }, + }); + + cache.set('test-key', createMockRoute()); + mockTime.advance(6000); + + const result = await cache.get('test-key'); + expect(result).not.toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); + + // ─── LRU Eviction ────────────────────────────────────────────────────────── + + describe('LRU eviction', () => { + it('evicts oldest entry when cache is full', async () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + maxEntries: 3, + }); + + cache.set('key-1', createMockRoute({ id: 'route-1' })); + mockTime.advance(1000); + cache.set('key-2', createMockRoute({ id: 'route-2' })); + mockTime.advance(1000); + cache.set('key-3', createMockRoute({ id: 'route-3' })); + + // Cache is full, adding new entry should evict key-1 + cache.set('key-4', createMockRoute({ id: 'route-4' })); + + expect(await cache.get('key-1')).toBeNull(); + expect(await cache.get('key-2')).not.toBeNull(); + expect(await cache.get('key-3')).not.toBeNull(); + expect(await cache.get('key-4')).not.toBeNull(); + }); + + it('does not evict when updating existing key', async () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + maxEntries: 2, + }); + + cache.set('key-1', createMockRoute({ id: 'route-1' })); + cache.set('key-2', createMockRoute({ id: 'route-2' })); + + // Update key-1 (should not trigger eviction) + cache.set('key-1', createMockRoute({ id: 'route-1-updated' })); + + expect(await cache.get('key-1')).not.toBeNull(); + expect(await cache.get('key-2')).not.toBeNull(); + expect(cache.size).toBe(2); + }); + + it('evicts least recently used entry', async () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + maxEntries: 3, + }); + + cache.set('key-1', createMockRoute({ id: 'route-1' })); + mockTime.advance(1000); + cache.set('key-2', createMockRoute({ id: 'route-2' })); + mockTime.advance(1000); + cache.set('key-3', createMockRoute({ id: 'route-3' })); + + // Access key-1 to make it recently used + await cache.get('key-1'); + mockTime.advance(1000); + + // Add new entry - should evict key-2 (least recently used) + cache.set('key-4', createMockRoute({ id: 'route-4' })); + + expect(await cache.get('key-1')).not.toBeNull(); + expect(await cache.get('key-2')).toBeNull(); + expect(await cache.get('key-3')).not.toBeNull(); + }); + }); + + // ─── Cache Query Operations ──────────────────────────────────────────────── + + describe('has', () => { + it('returns true for valid entries', () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + cache.set('test-key', createMockRoute()); + expect(cache.has('test-key')).toBe(true); + }); + + it('returns false for expired entries', () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 10000, + }); + + cache.set('test-key', createMockRoute()); + mockTime.advance(11000); + + expect(cache.has('test-key')).toBe(false); + }); + + it('returns false for non-existent entries', () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + expect(cache.has('non-existent')).toBe(false); + }); + }); + + describe('peek', () => { + it('retrieves entry without updating access tracking', async () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + cache.set('test-key', createMockRoute()); + + const entry1 = cache.peek('test-key'); + const entry2 = cache.peek('test-key'); + + expect(entry1?.accessCount).toBe(0); + expect(entry2?.accessCount).toBe(0); + + // Get should update access count + await cache.get('test-key'); + const entry3 = cache.peek('test-key'); + expect(entry3?.accessCount).toBe(1); + }); + + it('returns null for expired entries', () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 10000, + }); + + cache.set('test-key', createMockRoute()); + mockTime.advance(11000); + + expect(cache.peek('test-key')).toBeNull(); + }); + }); + + // ─── Invalidation Strategies ─────────────────────────────────────────────── + + describe('invalidate', () => { + it('removes specific entry', async () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + cache.set('test-key', createMockRoute()); + + const removed = cache.invalidate('test-key'); + expect(removed).toBe(true); + expect(await cache.get('test-key')).toBeNull(); + }); + + it('returns false for non-existent entry', () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + const removed = cache.invalidate('non-existent'); + expect(removed).toBe(false); + }); + }); + + describe('invalidateBy', () => { + it('removes entries matching predicate', () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + cache.set('key-1', createMockRoute({ id: 'route-1' }), { + metadata: { provider: 'allbridge' }, + }); + cache.set('key-2', createMockRoute({ id: 'route-2' }), { + metadata: { provider: 'stargate' }, + }); + + const count = cache.invalidateBy( + (entry) => entry.metadata?.provider === 'allbridge', + ); + + expect(count).toBe(1); + expect(cache.peek('key-1')).toBeNull(); + expect(cache.peek('key-2')).not.toBeNull(); + }); + }); + + describe('invalidateByProvider', () => { + it('removes all routes from specific provider', () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + cache.set('key-1', createMockRoute({ provider: 'allbridge' })); + cache.set('key-2', createMockRoute({ provider: 'stargate' })); + cache.set( + 'key-3', + createMockRoute({ provider: 'allbridge', id: 'route-3' }), + ); + + const count = cache.invalidateByProvider('allbridge'); + + expect(count).toBe(2); + expect(cache.peek('key-1')).toBeNull(); + expect(cache.peek('key-2')).not.toBeNull(); + expect(cache.peek('key-3')).toBeNull(); + }); + }); + + describe('invalidateByChainPair', () => { + it('removes all routes for specific chain pair', () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + cache.set( + 'key-1', + createMockRoute({ fromChain: 'stellar', toChain: 'ethereum' }), + ); + cache.set( + 'key-2', + createMockRoute({ fromChain: 'stellar', toChain: 'polygon' }), + ); + cache.set( + 'key-3', + createMockRoute({ fromChain: 'ethereum', toChain: 'stellar' }), + ); + + const count = cache.invalidateByChainPair('stellar', 'ethereum'); + + expect(count).toBe(1); + expect(cache.peek('key-1')).toBeNull(); + expect(cache.peek('key-2')).not.toBeNull(); + expect(cache.peek('key-3')).not.toBeNull(); + }); + }); + + describe('invalidateByAsset', () => { + it('removes all routes for specific asset', () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + cache.set('key-1', createMockRoute({ supportedAssets: ['USDC'] })); + cache.set('key-2', createMockRoute({ supportedAssets: ['USDT'] })); + cache.set('key-3', createMockRoute({ supportedAssets: ['USDC', 'XLM'] })); + + const count = cache.invalidateByAsset('USDC'); + + expect(count).toBe(2); + expect(cache.peek('key-1')).toBeNull(); + expect(cache.peek('key-2')).not.toBeNull(); + expect(cache.peek('key-3')).toBeNull(); + }); + }); + + describe('purgeExpired', () => { + it('removes all expired entries', () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 10000, + }); + + cache.set('key-1', createMockRoute()); + mockTime.advance(5000); + cache.set('key-2', createMockRoute()); + mockTime.advance(6000); // key-1 expired, key-2 still valid + + const count = cache.purgeExpired(); + + expect(count).toBe(1); + expect(cache.size).toBe(1); + expect(cache.peek('key-1')).toBeNull(); + expect(cache.peek('key-2')).not.toBeNull(); + }); + }); + + describe('purgeStale', () => { + it('removes all stale entries', () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 10000, + staleThresholdPercent: 0.5, + }); + + cache.set('key-1', createMockRoute()); + mockTime.advance(6000); // Past stale threshold + cache.set('key-2', createMockRoute()); + mockTime.advance(1000); // key-1 stale, key-2 not stale + + const count = cache.purgeStale(); + + expect(count).toBe(1); + expect(cache.size).toBe(1); + expect(cache.peek('key-1')).toBeNull(); + expect(cache.peek('key-2')).not.toBeNull(); + }); + }); + + describe('clear', () => { + it('removes all entries', () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + cache.set('key-1', createMockRoute()); + cache.set('key-2', createMockRoute()); + + cache.clear(); + + expect(cache.size).toBe(0); + }); + }); + + // ─── Cache Statistics ────────────────────────────────────────────────────── + + describe('stats', () => { + it('returns accurate cache statistics', async () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 10000, + staleThresholdPercent: 0.5, + }); + + cache.set('key-1', createMockRoute()); + cache.set('key-2', createMockRoute()); + mockTime.advance(6000); // Make entries stale + cache.set('key-3', createMockRoute()); + + await cache.get('key-1'); // hit + await cache.get('key-4'); // miss + + const stats = cache.stats(); + + expect(stats.totalEntries).toBe(3); + expect(stats.validEntries).toBe(3); + expect(stats.expiredEntries).toBe(0); + expect(stats.staleEntries).toBe(2); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.hitRate).toBe(0.5); + }); + + it('calculates hit rate correctly', async () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + + await cache.get('miss-1'); + await cache.get('miss-2'); + cache.set('key-1', createMockRoute()); + await cache.get('key-1'); + + const stats = cache.stats(); + expect(stats.hitRate).toBeCloseTo(1 / 3, 2); + }); + + it('returns zero hit rate for no requests', () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + const stats = cache.stats(); + expect(stats.hitRate).toBe(0); + }); + }); + + describe('resetStats', () => { + it('resets hit and miss counters', async () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + + await cache.get('miss'); + cache.set('key', createMockRoute()); + await cache.get('key'); + + cache.resetStats(); + + const stats = cache.stats(); + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(0); + }); + }); + + // ─── Key Building Helpers ────────────────────────────────────────────────── + + describe('buildRouteDiscoveryKey', () => { + it('builds deterministic key from query', () => { + const key = buildRouteDiscoveryKey({ + fromChain: 'Stellar', + toChain: 'Ethereum', + asset: 'USDC', + }); + + expect(key).toBe('stellar:ethereum:usdc:*:*'); + }); + + it('handles optional fields with wildcards', () => { + const key = buildRouteDiscoveryKey({ + fromChain: 'Stellar', + toChain: 'Ethereum', + }); + + expect(key).toBe('stellar:ethereum:*:*:*'); + }); + + it('normalizes to lowercase', () => { + const key1 = buildRouteDiscoveryKey({ + fromChain: 'Stellar', + toChain: 'Ethereum', + }); + const key2 = buildRouteDiscoveryKey({ + fromChain: 'stellar', + toChain: 'ethereum', + }); + + expect(key1).toBe(key2); + }); + }); + + describe('buildRouteKey', () => { + it('builds prefixed route key', () => { + const key = buildRouteKey('stellar-ethereum-usdc'); + expect(key).toBe('route:stellar-ethereum-usdc'); + }); + }); + + // ─── Lifecycle ───────────────────────────────────────────────────────────── + + describe('destroy', () => { + it('stops cleanup timer', () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + enableCleanup: true, + cleanupIntervalMs: 1000, + }); + + cache.destroy(); + // Should not throw or cause issues + cache.set('test', createMockRoute()); + }); + }); + + // ─── Edge Cases ──────────────────────────────────────────────────────────── + + describe('edge cases', () => { + it('handles empty route arrays', async () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + cache.set('empty', []); + + const result = await cache.get('empty'); + expect(result).toEqual([]); + }); + + it('handles routes with minimal data', async () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + const minimalRoute: BridgeRoute = { + id: 'minimal', + fromChain: 'stellar' as AssetChain, + toChain: 'ethereum' as AssetChain, + bridgeProtocol: 'test', + provider: 'test', + supportedAssets: ['USDC'], + estimatedTimeMinutes: 10, + status: 'active' as const, + available: true, + }; + + cache.set('minimal', minimalRoute); + const result = await cache.get('minimal'); + expect(result).toEqual(minimalRoute); + }); + + it('supports very short TTL', async () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 1, + }); + + cache.set('short', createMockRoute()); + mockTime.advance(2); + + expect(await cache.get('short')).toBeNull(); + }); + + it('supports very large cache sizes', () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + maxEntries: 10000, + }); + + for (let i = 0; i < 10000; i++) { + cache.set(`key-${i}`, createMockRoute({ id: `route-${i}` })); + } + + expect(cache.size).toBe(10000); + }); + }); + + // ─── Integration Scenarios ───────────────────────────────────────────────── + + describe('integration scenarios', () => { + it('caches route discovery results effectively', async () => { + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 60000, + }); + + const discoveryQuery = { + fromChain: 'stellar', + toChain: 'ethereum', + asset: 'USDC', + }; + + const cacheKey = buildRouteDiscoveryKey(discoveryQuery); + const discoveredRoutes = [ + createMockRoute({ id: 'route-1' }), + createMockRoute({ id: 'route-2' }), + ]; + + // First discovery + cache.set(cacheKey, discoveredRoutes, { + metadata: { discoverySource: 'provider-api' }, + }); + + // Subsequent queries hit cache + const cached = await cache.get(cacheKey); + expect(cached).toEqual(discoveredRoutes); + + const stats = cache.stats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(0); + }); + + it('supports stale-while-revalidate pattern', async () => { + let refreshCount = 0; + const cache = new StellarRouteDiscoveryCache({ + now: mockTime.now, + defaultTtlMs: 10000, + staleThresholdPercent: 0.5, + onStale: async (key, entry) => { + refreshCount++; + // Simulate refreshing route data + cache.set(key, createMockRoute({ id: 'refreshed' }), { + ttlMs: 10000, + }); + }, + }); + + cache.set('route-key', createMockRoute({ id: 'original' })); + mockTime.advance(6000); // Past stale threshold + + const result = await cache.get('route-key'); + // Should return stale data first + expect(result).not.toBeNull(); + expect(refreshCount).toBe(1); + + // Next access should get refreshed data + const refreshed = await cache.get('route-key'); + expect((refreshed as BridgeRoute).id).toBe('refreshed'); + }); + + it('handles provider updates gracefully', async () => { + const cache = new StellarRouteDiscoveryCache({ now: mockTime.now }); + + cache.set('route-1', createMockRoute({ provider: 'allbridge' })); + cache.set('route-2', createMockRoute({ provider: 'stargate' })); + cache.set('route-3', createMockRoute({ provider: 'allbridge' })); + + // Provider updates routes - invalidate old ones + cache.invalidateByProvider('allbridge'); + + expect(cache.peek('route-1')).toBeNull(); + expect(cache.peek('route-2')).not.toBeNull(); + expect(cache.peek('route-3')).toBeNull(); + }); + }); +}); diff --git a/src/cache/routes/stellar/stellar-route-discovery-cache.ts b/src/cache/routes/stellar/stellar-route-discovery-cache.ts new file mode 100644 index 00000000..5eb32e9e --- /dev/null +++ b/src/cache/routes/stellar/stellar-route-discovery-cache.ts @@ -0,0 +1,558 @@ +/** + * Stellar Route Discovery Cache + * + * Caches discovered Stellar bridge routes to reduce repeated route discovery + * latency. Supports configurable expiration policies, stale entry refresh, + * and background cleanup. + * + * Features: + * - TTL-based cache expiration with configurable policies + * - Stale entry detection and refresh callbacks + * - LRU eviction when cache reaches capacity + * - Background cleanup of expired entries + * - Multiple invalidation strategies (by route, provider, network) + * - Cache statistics and monitoring + * + * Usage: + * const cache = new StellarRouteDiscoveryCache({ + * defaultTtlMs: 60_000, + * staleThresholdMs: 45_000, + * maxEntries: 1000, + * onStale: async (key, entry) => refreshRoute(key) + * }); + * + * // Cache a discovered route + * cache.set('stellar-ethereum-usdc', routeData); + * + * // Retrieve with automatic stale handling + * const route = await cache.get('stellar-ethereum-usdc'); + */ + +import type { BridgeRoute } from '../../../matrix/assets/routes/stellar/types'; + +/** Cache entry with metadata for expiration and staleness tracking */ +export interface RouteDiscoveryCacheEntry { + /** Unique cache key for the route */ + key: string; + /** The cached route data */ + route: BridgeRoute | BridgeRoute[]; + /** Timestamp when entry was cached (ms since epoch) */ + cachedAt: number; + /** Time-to-live in milliseconds */ + ttlMs: number; + /** Timestamp when entry becomes stale (before expiry) */ + staleAt: number; + /** Number of times this entry has been accessed */ + accessCount: number; + /** Last access timestamp */ + lastAccessedAt: number; + /** Optional metadata about the discovery source */ + metadata?: { + /** Source of the route discovery (e.g., 'provider-api', 'on-chain') */ + discoverySource?: string; + /** Version of the route data */ + version?: string; + /** Additional provider-specific metadata */ + [key: string]: string | undefined; + }; +} + +/** Configuration for the route discovery cache */ +export interface RouteDiscoveryCacheConfig { + /** Default TTL for cache entries in milliseconds. Default: 60000 (1 min) */ + defaultTtlMs?: number; + /** Threshold before TTL when entry is considered stale. Default: 75% of TTL */ + staleThresholdPercent?: number; + /** Maximum number of entries in cache. Default: 1000 */ + maxEntries?: number; + /** Background cleanup interval in milliseconds. Default: 120000 (2 min) */ + cleanupIntervalMs?: number; + /** Callback invoked when a stale entry is accessed */ + onStale?: (key: string, entry: RouteDiscoveryCacheEntry) => Promise; + /** Enable background cleanup timer. Default: true */ + enableCleanup?: boolean; + /** Injected clock for deterministic testing */ + now?: () => number; +} + +/** Statistics about cache state */ +export interface RouteDiscoveryCacheStats { + /** Total number of entries in cache */ + totalEntries: number; + /** Number of valid (non-expired) entries */ + validEntries: number; + /** Number of expired entries */ + expiredEntries: number; + /** Number of stale (not expired but past stale threshold) entries */ + staleEntries: number; + /** Cache hit count */ + hits: number; + /** Cache miss count */ + misses: number; + /** Hit rate (hits / (hits + misses)) */ + hitRate: number; + /** Size of largest entry in bytes (approximate) */ + estimatedMemoryBytes: number; +} + +/** Query parameters for route cache lookup */ +export interface RouteDiscoveryQuery { + /** Source chain identifier */ + fromChain: string; + /** Destination chain identifier */ + toChain: string; + /** Asset code being transferred */ + asset?: string; + /** Bridge provider identifier */ + provider?: string; + /** Bridge protocol name */ + protocol?: string; +} + +const DEFAULT_CONFIG: Required> = { + defaultTtlMs: 60_000, + staleThresholdPercent: 0.75, + maxEntries: 1000, + cleanupIntervalMs: 120_000, + enableCleanup: true, + now: () => Date.now(), +}; + +/** + * Build a deterministic cache key from route discovery query parameters. + * Exported for testing. + */ +export function buildRouteDiscoveryKey(query: RouteDiscoveryQuery): string { + const parts = [ + query.fromChain.toLowerCase(), + query.toChain.toLowerCase(), + query.asset?.toLowerCase() ?? '*', + query.provider?.toLowerCase() ?? '*', + query.protocol?.toLowerCase() ?? '*', + ]; + return parts.join(':'); +} + +/** + * Build a cache key from a route identifier or custom string. + */ +export function buildRouteKey(routeId: string): string { + return `route:${routeId}`; +} + +/** + * StellarRouteDiscoveryCache + * + * High-performance in-memory cache for discovered Stellar bridge routes. + * Implements LRU eviction, TTL-based expiration, stale entry detection, + * and background cleanup to minimize route discovery latency. + */ +export class StellarRouteDiscoveryCache { + private cache: Map = new Map(); + private config: Required; + private cleanupTimer: ReturnType | null = null; + + // Cache access tracking + private hits = 0; + private misses = 0; + + constructor(config: RouteDiscoveryCacheConfig = {}) { + this.config = { + ...DEFAULT_CONFIG, + ...config, + onStale: config.onStale, + }; + + if (this.config.enableCleanup) { + this.startCleanup(); + } + } + + // ─── Core Cache Operations ──────────────────────────────────────────────── + + /** + * Store a discovered route in the cache. + * + * @param key Cache key (use buildRouteDiscoveryKey or buildRouteKey) + * @param route Route data to cache (single route or array) + * @param options Optional TTL and metadata overrides + */ + set( + key: string, + route: BridgeRoute | BridgeRoute[], + options?: { + ttlMs?: number; + metadata?: RouteDiscoveryCacheEntry['metadata']; + }, + ): void { + const now = this.config.now(); + const ttlMs = options?.ttlMs ?? this.config.defaultTtlMs; + const staleAt = now + Math.floor(ttlMs * this.config.staleThresholdPercent); + + // Evict oldest entry if at capacity and key doesn't exist + if (!this.cache.has(key) && this.cache.size >= this.config.maxEntries) { + this.evictOldest(); + } + + const entry: RouteDiscoveryCacheEntry = { + key, + route, + cachedAt: now, + ttlMs, + staleAt, + accessCount: 0, + lastAccessedAt: now, + metadata: options?.metadata, + }; + + this.cache.set(key, entry); + } + + /** + * Retrieve a cached route with automatic expiration and stale detection. + * + * Returns null if: + * - Entry doesn't exist + * - Entry has expired + * + * If entry is stale (past stale threshold but not expired), triggers + * onStale callback if configured, but still returns the data. + * + * @param key Cache key + * @returns Cached route data or null + */ + async get(key: string): Promise { + const now = this.config.now(); + const entry = this.cache.get(key); + + // Cache miss + if (!entry) { + this.misses++; + return null; + } + + // Check expiration + if (this.isExpired(entry, now)) { + this.cache.delete(key); + this.misses++; + return null; + } + + // Cache hit - update access tracking + this.hits++; + entry.accessCount++; + entry.lastAccessedAt = now; + + // Move to end for LRU tracking + this.cache.delete(key); + this.cache.set(key, entry); + + // Check if stale and trigger callback + if (this.isStale(entry, now) && this.config.onStale) { + try { + await this.config.onStale(key, entry); + } catch (error) { + // Don't let stale callback failures affect cache retrieval + console.error('RouteDiscoveryCache: onStale callback failed:', error); + } + } + + return entry.route; + } + + /** + * Synchronous get without stale callback trigger. + * Use when you don't need async stale handling. + */ + getSync(key: string): BridgeRoute | BridgeRoute[] | null { + const now = this.config.now(); + const entry = this.cache.get(key); + + if (!entry) { + this.misses++; + return null; + } + + if (this.isExpired(entry, now)) { + this.cache.delete(key); + this.misses++; + return null; + } + + this.hits++; + entry.accessCount++; + entry.lastAccessedAt = now; + + // LRU update + this.cache.delete(key); + this.cache.set(key, entry); + + return entry.route; + } + + /** + * Check if a cache entry exists and is valid (non-expired). + */ + has(key: string): boolean { + const entry = this.cache.get(key); + if (!entry) return false; + return !this.isExpired(entry, this.config.now()); + } + + /** + * Peek at a cached entry without updating access tracking or triggering stale callbacks. + */ + peek(key: string): RouteDiscoveryCacheEntry | null { + const entry = this.cache.get(key); + if (!entry) return null; + if (this.isExpired(entry, this.config.now())) return null; + return entry; + } + + // ─── Invalidation Strategies ────────────────────────────────────────────── + + /** + * Invalidate a specific cache entry. + */ + invalidate(key: string): boolean { + return this.cache.delete(key); + } + + /** + * Invalidate all entries matching a predicate. + */ + invalidateBy( + predicate: (entry: RouteDiscoveryCacheEntry) => boolean, + ): number { + let count = 0; + for (const [key, entry] of this.cache) { + if (predicate(entry)) { + this.cache.delete(key); + count++; + } + } + return count; + } + + /** + * Invalidate all routes from a specific provider. + */ + invalidateByProvider(provider: string): number { + const providerLower = provider.toLowerCase(); + return this.invalidateBy( + (entry) => + entry.metadata?.provider?.toLowerCase() === providerLower || + this.routeContainsProvider(entry.route, providerLower), + ); + } + + /** + * Invalidate all routes for a specific chain pair. + */ + invalidateByChainPair(fromChain: string, toChain: string): number { + const fromLower = fromChain.toLowerCase(); + const toLower = toChain.toLowerCase(); + return this.invalidateBy( + (entry) => + entry.key.includes(`${fromLower}:${toLower}`) || + this.routeContainsChainPair(entry.route, fromLower, toLower), + ); + } + + /** + * Invalidate all routes for a specific asset. + */ + invalidateByAsset(asset: string): number { + const assetLower = asset.toLowerCase(); + return this.invalidateBy( + (entry) => + entry.key.includes(`:${assetLower}:`) || + this.routeContainsAsset(entry.route, assetLower), + ); + } + + /** + * Invalidate all expired entries. + */ + purgeExpired(): number { + const now = this.config.now(); + let count = 0; + for (const [key, entry] of this.cache) { + if (this.isExpired(entry, now)) { + this.cache.delete(key); + count++; + } + } + return count; + } + + /** + * Invalidate all stale entries (past stale threshold but not expired). + */ + purgeStale(): number { + const now = this.config.now(); + let count = 0; + for (const [key, entry] of this.cache) { + if (!this.isExpired(entry, now) && this.isStale(entry, now)) { + this.cache.delete(key); + count++; + } + } + return count; + } + + /** + * Clear all cache entries. + */ + clear(): void { + this.cache.clear(); + } + + // ─── Cache Statistics ───────────────────────────────────────────────────── + + /** + * Get comprehensive cache statistics. + */ + stats(): RouteDiscoveryCacheStats { + const now = this.config.now(); + let expired = 0; + let stale = 0; + + for (const entry of this.cache.values()) { + if (this.isExpired(entry, now)) { + expired++; + } else if (this.isStale(entry, now)) { + stale++; + } + } + + const totalRequests = this.hits + this.misses; + const hitRate = totalRequests > 0 ? this.hits / totalRequests : 0; + + return { + totalEntries: this.cache.size, + validEntries: this.cache.size - expired, + expiredEntries: expired, + staleEntries: stale, + hits: this.hits, + misses: this.misses, + hitRate, + estimatedMemoryBytes: this.estimateMemoryUsage(), + }; + } + + /** Reset cache hit/miss counters */ + resetStats(): void { + this.hits = 0; + this.misses = 0; + } + + /** Current number of cache entries */ + get size(): number { + return this.cache.size; + } + + // ─── Lifecycle ──────────────────────────────────────────────────────────── + + /** + * Stop background cleanup and release resources. + * Call this when shutting down the application. + */ + destroy(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + } + + // ─── Internal Methods ───────────────────────────────────────────────────── + + private isExpired(entry: RouteDiscoveryCacheEntry, now: number): boolean { + return now - entry.cachedAt > entry.ttlMs; + } + + private isStale(entry: RouteDiscoveryCacheEntry, now: number): boolean { + return now > entry.staleAt && !this.isExpired(entry, now); + } + + private evictOldest(): void { + let oldestKey: string | null = null; + let oldestAccessTime = Infinity; + + for (const [key, entry] of this.cache) { + // Use lastAccessedAt for LRU, fallback to cachedAt + const accessTime = entry.lastAccessedAt || entry.cachedAt; + if (accessTime < oldestAccessTime) { + oldestAccessTime = accessTime; + oldestKey = key; + } + } + + if (oldestKey) { + this.cache.delete(oldestKey); + } + } + + private startCleanup(): void { + this.cleanupTimer = setInterval(() => { + this.purgeExpired(); + }, this.config.cleanupIntervalMs); + + // Don't hold the event loop open for cleanup timer + if ( + typeof (this.cleanupTimer as { unref?: () => void }).unref === 'function' + ) { + (this.cleanupTimer as { unref: () => void }).unref(); + } + } + + private estimateMemoryUsage(): number { + let totalBytes = 0; + for (const entry of this.cache.values()) { + // Rough estimation: key length + route JSON size + metadata + const keySize = entry.key.length * 2; // UTF-16 + const routeSize = JSON.stringify(entry.route).length * 2; + const metadataSize = entry.metadata + ? JSON.stringify(entry.metadata).length * 2 + : 0; + totalBytes += keySize + routeSize + metadataSize + 256; // overhead + } + return totalBytes; + } + + private routeContainsProvider( + route: BridgeRoute | BridgeRoute[], + provider: string, + ): boolean { + const routes = Array.isArray(route) ? route : [route]; + return routes.some((r) => r.provider?.toLowerCase() === provider); + } + + private routeContainsChainPair( + route: BridgeRoute | BridgeRoute[], + fromChain: string, + toChain: string, + ): boolean { + const routes = Array.isArray(route) ? route : [route]; + return routes.some( + (r) => + r.fromChain?.toLowerCase() === fromChain && + r.toChain?.toLowerCase() === toChain, + ); + } + + private routeContainsAsset( + route: BridgeRoute | BridgeRoute[], + asset: string, + ): boolean { + const routes = Array.isArray(route) ? route : [route]; + return routes.some((r) => + r.supportedAssets?.some((a) => a.toLowerCase() === asset), + ); + } +} + +/** Default shared cache instance with sensible defaults */ +export const routeDiscoveryCache = new StellarRouteDiscoveryCache(); + +export default routeDiscoveryCache; diff --git a/src/scoring/reliability/stellar/index.ts b/src/scoring/reliability/stellar/index.ts new file mode 100644 index 00000000..c7abaafe --- /dev/null +++ b/src/scoring/reliability/stellar/index.ts @@ -0,0 +1,23 @@ +/** + * Soroban Route Reliability Scoring Module + * + * Provides comprehensive reliability scoring for Stellar bridge routes + * based on success rates, execution history, and performance metrics. + * + * @module + */ + +export { + SorobanRouteReliabilityScorer, + routeReliabilityScorer, +} from './soroban-route-reliability-scorer'; + +export type { + RouteExecutionRecord, + RouteReliabilityMetrics, + RouteReliabilityScore, + ReliabilityScoringConfig, + ExecutionQueryFilter, + ReliabilityScoringResult, + ReliabilityRankedRoute, +} from './soroban-route-reliability.types'; diff --git a/src/scoring/reliability/stellar/soroban-route-reliability-scorer.spec.ts b/src/scoring/reliability/stellar/soroban-route-reliability-scorer.spec.ts new file mode 100644 index 00000000..23f9bc29 --- /dev/null +++ b/src/scoring/reliability/stellar/soroban-route-reliability-scorer.spec.ts @@ -0,0 +1,798 @@ +/** + * Tests for SorobanRouteReliabilityScorer + * + * Comprehensive test suite covering: + * - Execution tracking and history management + * - Reliability metrics calculation + * - Multi-component reliability scoring + * - Confidence calculation + * - Route ranking integration + * - Time-decay weighting + * - Streak analysis + * - Edge cases and error handling + */ + +import { + SorobanRouteReliabilityScorer, + routeReliabilityScorer, +} from './soroban-route-reliability-scorer'; +import type { RouteExecutionRecord } from './soroban-route-reliability.types'; + +// ─── Test Helpers ───────────────────────────────────────────────────────────── + +const createMockTime = () => { + let currentTime = 1000000000000; // Start at a reasonable timestamp + return { + now: () => currentTime, + advance: (ms: number) => { + currentTime += ms; + }, + getTime: () => currentTime, + }; +}; + +const createExecution = ( + routeId: string, + success: boolean, + timestamp: number, + overrides: Partial = {}, +): RouteExecutionRecord => ({ + executionId: `exec-${routeId}-${timestamp}`, + routeId, + success, + timestamp, + durationMs: success ? 1000 + Math.random() * 2000 : undefined, + error: success ? undefined : 'Test error', + errorCategory: success ? undefined : 'timeout', + amountUsd: 1000, + ...overrides, +}); + +describe('SorobanRouteReliabilityScorer', () => { + let mockTime: ReturnType; + let scorer: SorobanRouteReliabilityScorer; + + beforeEach(() => { + mockTime = createMockTime(); + scorer = new SorobanRouteReliabilityScorer({ now: mockTime.now }); + }); + + // ─── Execution Tracking ─────────────────────────────────────────────────── + + describe('recordExecution', () => { + it('records a successful execution', () => { + const execution = createExecution('route-1', true, mockTime.getTime()); + scorer.recordExecution(execution); + + expect(scorer.totalExecutions).toBe(1); + expect(scorer.trackedRoutes).toContain('route-1'); + }); + + it('records a failed execution', () => { + const execution = createExecution('route-1', false, mockTime.getTime()); + scorer.recordExecution(execution); + + expect(scorer.totalExecutions).toBe(1); + const executions = scorer.getExecutions('route-1'); + expect(executions[0].success).toBe(false); + }); + + it('tracks multiple routes', () => { + scorer.recordExecution( + createExecution('route-1', true, mockTime.getTime()), + ); + mockTime.advance(1000); + scorer.recordExecution( + createExecution('route-2', true, mockTime.getTime()), + ); + + expect(scorer.totalExecutions).toBe(2); + expect(scorer.trackedRoutes).toHaveLength(2); + }); + + it('maintains execution order', () => { + const exec1 = createExecution('route-1', true, mockTime.getTime()); + mockTime.advance(1000); + const exec2 = createExecution('route-1', false, mockTime.getTime()); + + scorer.recordExecution(exec1); + scorer.recordExecution(exec2); + + const executions = scorer.getExecutions('route-1'); + expect(executions).toHaveLength(2); + expect(executions[0].success).toBe(true); + expect(executions[1].success).toBe(false); + }); + }); + + describe('recordExecutions', () => { + it('records multiple executions at once', () => { + const executions = [ + createExecution('route-1', true, mockTime.getTime()), + createExecution('route-1', false, mockTime.getTime() + 1000), + createExecution('route-2', true, mockTime.getTime() + 2000), + ]; + + scorer.recordExecutions(executions); + + expect(scorer.totalExecutions).toBe(3); + expect(scorer.getExecutions('route-1')).toHaveLength(2); + expect(scorer.getExecutions('route-2')).toHaveLength(1); + }); + }); + + describe('queryExecutions', () => { + beforeEach(() => { + scorer.recordExecutions([ + createExecution('route-1', true, mockTime.getTime()), + createExecution('route-1', false, mockTime.getTime() + 1000), + createExecution('route-2', true, mockTime.getTime() + 2000), + createExecution('route-2', true, mockTime.getTime() + 3000, { + amountUsd: 5000, + }), + ]); + }); + + it('filters by routeId', () => { + const results = scorer.queryExecutions({ routeId: 'route-1' }); + expect(results).toHaveLength(2); + results.forEach((r) => expect(r.routeId).toBe('route-1')); + }); + + it('filters by routeIds', () => { + const results = scorer.queryExecutions({ routeIds: ['route-1'] }); + expect(results).toHaveLength(2); + }); + + it('filters by success status', () => { + const results = scorer.queryExecutions({ success: false }); + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + }); + + it('filters by errorCategory', () => { + const results = scorer.queryExecutions({ errorCategory: 'timeout' }); + expect(results).toHaveLength(1); + expect(results[0].errorCategory).toBe('timeout'); + }); + + it('filters by amount range', () => { + const results = scorer.queryExecutions({ minAmountUsd: 2000 }); + expect(results).toHaveLength(1); + expect(results[0].amountUsd).toBe(5000); + }); + }); + + describe('clearHistory', () => { + it('clears all execution data', () => { + scorer.recordExecution( + createExecution('route-1', true, mockTime.getTime()), + ); + scorer.clearHistory(); + + expect(scorer.totalExecutions).toBe(0); + expect(scorer.trackedRoutes).toHaveLength(0); + }); + }); + + // ─── Metrics Calculation ────────────────────────────────────────────────── + + describe('calculateMetrics', () => { + it('returns null for route with no executions', () => { + const metrics = scorer.calculateMetrics('non-existent'); + expect(metrics).toBeNull(); + }); + + it('calculates basic success metrics', () => { + const baseTime = mockTime.getTime(); + scorer.recordExecutions([ + createExecution('route-1', true, baseTime), + createExecution('route-1', true, baseTime + 1000), + createExecution('route-1', false, baseTime + 2000), + ]); + + const metrics = scorer.calculateMetrics('route-1'); + + expect(metrics).not.toBeNull(); + expect(metrics!.totalExecutions).toBe(3); + expect(metrics!.successfulExecutions).toBe(2); + expect(metrics!.failedExecutions).toBe(1); + expect(metrics!.successRate).toBeCloseTo(2 / 3, 2); + }); + + it('calculates recent success rate', () => { + const baseTime = mockTime.getTime(); + const oneDayAgo = baseTime - 86_400_000; + const twoDaysAgo = baseTime - 172_800_000; + + // Old executions (outside recent window) + scorer.recordExecutions([ + createExecution('route-1', false, twoDaysAgo), + createExecution('route-1', false, twoDaysAgo + 1000), + ]); + + // Recent executions (within 24h window) + scorer.recordExecutions([ + createExecution('route-1', true, oneDayAgo + 1000), + createExecution('route-1', true, oneDayAgo + 2000), + ]); + + const metrics = scorer.calculateMetrics('route-1'); + + expect(metrics!.recentSuccessRate).toBe(1.0); // 100% in last 24h + expect(metrics!.successRate).toBe(0.5); // 50% overall + }); + + it('calculates duration statistics', () => { + const baseTime = mockTime.getTime(); + scorer.recordExecutions([ + createExecution('route-1', true, baseTime, { durationMs: 1000 }), + createExecution('route-1', true, baseTime + 1000, { durationMs: 2000 }), + createExecution('route-1', true, baseTime + 2000, { durationMs: 3000 }), + ]); + + const metrics = scorer.calculateMetrics('route-1'); + + expect(metrics!.avgDurationMs).toBe(2000); + expect(metrics!.medianDurationMs).toBe(2000); + expect(metrics!.p95DurationMs).toBe(3000); + }); + + it('calculates failure breakdown', () => { + const baseTime = mockTime.getTime(); + scorer.recordExecutions([ + createExecution('route-1', false, baseTime, { + errorCategory: 'timeout', + }), + createExecution('route-1', false, baseTime + 1000, { + errorCategory: 'timeout', + }), + createExecution('route-1', false, baseTime + 2000, { + errorCategory: 'slippage', + }), + ]); + + const metrics = scorer.calculateMetrics('route-1'); + + expect(metrics!.failureBreakdown).toEqual({ + timeout: 2, + slippage: 1, + }); + }); + + it('calculates streak analysis', () => { + const baseTime = mockTime.getTime(); + scorer.recordExecutions([ + createExecution('route-1', true, baseTime), + createExecution('route-1', true, baseTime + 1000), + createExecution('route-1', true, baseTime + 2000), + createExecution('route-1', false, baseTime + 3000), + createExecution('route-1', false, baseTime + 4000), + ]); + + const metrics = scorer.calculateMetrics('route-1'); + + expect(metrics!.consecutiveFailures).toBe(2); + expect(metrics!.consecutiveSuccesses).toBe(0); + expect(metrics!.longestSuccessStreak).toBe(3); + expect(metrics!.longestFailureStreak).toBe(2); + }); + }); + + // ─── Reliability Scoring ────────────────────────────────────────────────── + + describe('getReliabilityScore', () => { + it('returns null for route with no data', () => { + const score = scorer.getReliabilityScore('non-existent'); + expect(score).toBeNull(); + }); + + it('calculates reliability score for successful route', () => { + const baseTime = mockTime.getTime(); + for (let i = 0; i < 150; i++) { + scorer.recordExecution( + createExecution('route-1', true, baseTime + i * 1000), + ); + } + + const score = scorer.getReliabilityScore('route-1'); + + expect(score).not.toBeNull(); + expect(score!.score).toBeGreaterThan(0.8); + expect(score!.confidenceTier).toBe('high'); + }); + + it('calculates lower score for unreliable route', () => { + const baseTime = mockTime.getTime(); + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', i % 3 === 0, baseTime + i * 1000), // 33% success + ); + } + + const score = scorer.getReliabilityScore('route-1'); + + expect(score).not.toBeNull(); + expect(score!.score).toBeLessThan(0.5); + }); + + it('includes score breakdown', () => { + const baseTime = mockTime.getTime(); + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', true, baseTime + i * 1000), + ); + } + + const score = scorer.getReliabilityScore('route-1'); + + expect(score!.breakdown.successRateScore).toBeDefined(); + expect(score!.breakdown.recentPerformanceScore).toBeDefined(); + expect(score!.breakdown.consistencyScore).toBeDefined(); + expect(score!.breakdown.trendScore).toBeDefined(); + }); + + it('calculates volume score when amount data available', () => { + const baseTime = mockTime.getTime(); + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', true, baseTime + i * 1000, { + amountUsd: 1000 + i * 100, + }), + ); + } + + const score = scorer.getReliabilityScore('route-1'); + + expect(score!.breakdown.volumeScore).toBeDefined(); + }); + + it('returns undefined volume score without amount data', () => { + const baseTime = mockTime.getTime(); + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', true, baseTime + i * 1000, { + amountUsd: undefined, + }), + ); + } + + const score = scorer.getReliabilityScore('route-1'); + + expect(score!.breakdown.volumeScore).toBeUndefined(); + }); + }); + + describe('scoreRoutes', () => { + it('scores and ranks multiple routes', () => { + const baseTime = mockTime.getTime(); + + // Route 1: 90% success rate + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', i < 45, baseTime + i * 1000), + ); + } + + // Route 2: 70% success rate + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-2', i < 35, baseTime + i * 1000), + ); + } + + // Route 3: 50% success rate + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-3', i < 25, baseTime + i * 1000), + ); + } + + const result = scorer.scoreRoutes(['route-1', 'route-2', 'route-3']); + + expect(result.totalRoutes).toBe(3); + expect(result.scoredRoutes[0].routeId).toBe('route-1'); + expect(result.scoredRoutes[1].routeId).toBe('route-2'); + expect(result.scoredRoutes[2].routeId).toBe('route-3'); + expect(result.scoredRoutes[0].score).toBeGreaterThan( + result.scoredRoutes[1].score, + ); + }); + + it('skips routes with no data', () => { + const baseTime = mockTime.getTime(); + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', true, baseTime + i * 1000), + ); + } + + const result = scorer.scoreRoutes(['route-1', 'route-no-data']); + + expect(result.totalRoutes).toBe(1); + expect(result.scoredRoutes[0].routeId).toBe('route-1'); + }); + }); + + // ─── Route Ranking Integration ──────────────────────────────────────────── + + describe('rankRoutesByReliability', () => { + it('ranks routes by reliability score', () => { + const baseTime = mockTime.getTime(); + + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-good', true, baseTime + i * 1000), + ); + scorer.recordExecution( + createExecution('route-bad', i % 2 === 0, baseTime + i * 1000), + ); + } + + const routes = [ + { id: 'route-good', name: 'Good Route' }, + { id: 'route-bad', name: 'Bad Route' }, + ]; + + const ranked = scorer.rankRoutesByReliability(routes); + + expect(ranked).toHaveLength(2); + expect(ranked[0].route.id).toBe('route-good'); + expect(ranked[0].rank).toBe(1); + expect(ranked[1].route.id).toBe('route-bad'); + expect(ranked[1].rank).toBe(2); + }); + + it('includes reliability score in ranking', () => { + const baseTime = mockTime.getTime(); + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', true, baseTime + i * 1000), + ); + } + + const routes = [{ id: 'route-1', name: 'Test Route' }]; + const ranked = scorer.rankRoutesByReliability(routes); + + expect(ranked[0].reliabilityScore).toBeDefined(); + expect(ranked[0].reliabilityScore.score).toBeGreaterThan(0.8); + }); + }); + + describe('getReliabilityScoresForRanking', () => { + it('returns score map for route IDs', () => { + const baseTime = mockTime.getTime(); + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', true, baseTime + i * 1000), + ); + scorer.recordExecution( + createExecution('route-2', i < 35, baseTime + i * 1000), + ); + } + + const scores = scorer.getReliabilityScoresForRanking([ + 'route-1', + 'route-2', + ]); + + expect(scores.has('route-1')).toBe(true); + expect(scores.has('route-2')).toBe(true); + const score1 = scores.get('route-1')!; + const score2 = scores.get('route-2')!; + expect(score1).toBeGreaterThan(score2); + }); + }); + + // ─── Data Management ────────────────────────────────────────────────────── + + describe('pruneOldExecutions', () => { + it('removes executions beyond max age', () => { + const baseTime = mockTime.getTime(); + const oldTime = baseTime - 3_000_000_000; // Older than 30 days + + scorer.recordExecution(createExecution('route-1', true, oldTime)); + scorer.recordExecution(createExecution('route-1', true, baseTime)); + + const pruned = scorer.pruneOldExecutions(); + + expect(pruned).toBe(1); + expect(scorer.totalExecutions).toBe(1); + }); + }); + + // ─── Confidence Calculation ─────────────────────────────────────────────── + + describe('confidence', () => { + it('assigns high confidence for large sample size', () => { + const baseTime = mockTime.getTime(); + for (let i = 0; i < 150; i++) { + scorer.recordExecution( + createExecution('route-1', true, baseTime + i * 1000), + ); + } + + const score = scorer.getReliabilityScore('route-1'); + expect(score!.confidenceTier).toBe('high'); + }); + + it('assigns medium confidence for moderate sample size', () => { + const baseTime = mockTime.getTime(); + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', true, baseTime + i * 1000), + ); + } + + const score = scorer.getReliabilityScore('route-1'); + expect(score!.confidenceTier).toBe('medium'); + }); + + it('assigns low confidence for small sample size', () => { + const baseTime = mockTime.getTime(); + for (let i = 0; i < 10; i++) { + scorer.recordExecution( + createExecution('route-1', true, baseTime + i * 1000), + ); + } + + const score = scorer.getReliabilityScore('route-1'); + expect(score!.confidenceTier).toBe('low'); + }); + + it('reduces confidence for stale data', () => { + const baseTime = mockTime.getTime(); + const oldTime = baseTime - 2_000_000_000; // Very old data + + for (let i = 0; i < 150; i++) { + scorer.recordExecution( + createExecution('route-1', true, oldTime + i * 1000), + ); + } + + mockTime.advance(2_000_000_000); // Advance time to make data old + + const score = scorer.getReliabilityScore('route-1'); + // All data is now beyond maxDataAge (30 days = 2.592B ms), so metrics return null + expect(score).toBeNull(); + }); + }); + + // ─── Time-Decay Weighting ───────────────────────────────────────────────── + + describe('time-decay weighting', () => { + it('applies time decay to volume score', () => { + const baseTime = mockTime.getTime(); + const oneWeekAgo = baseTime - 604_800_000; + const twoWeeksAgo = baseTime - 1_209_600_000; + + // Recent high-value success + scorer.recordExecution( + createExecution('route-1', true, oneWeekAgo, { amountUsd: 10000 }), + ); + + // Old low-value failure + scorer.recordExecution( + createExecution('route-1', false, twoWeeksAgo, { amountUsd: 1000 }), + ); + + const score = scorer.getReliabilityScore('route-1'); + + // Recent success should weigh more than old failure + expect(score!.score).toBeGreaterThan(0.5); + }); + + it('can disable time decay', () => { + const scorerNoDecay = new SorobanRouteReliabilityScorer({ + now: mockTime.now, + enableTimeDecay: false, + }); + + const baseTime = mockTime.getTime(); + scorerNoDecay.recordExecution( + createExecution('route-1', true, baseTime - 604_800_000, { + amountUsd: 1000, + }), + ); + scorerNoDecay.recordExecution( + createExecution('route-1', false, baseTime, { amountUsd: 10000 }), + ); + + const score = scorerNoDecay.getReliabilityScore('route-1'); + // Without decay, recent failure has same weight as old success + expect(score).not.toBeNull(); + }); + }); + + // ─── Trend Detection ────────────────────────────────────────────────────── + + describe('trend detection', () => { + it('detects improving trend', () => { + const baseTime = mockTime.getTime(); + + // Week ago: mix of successes and failures (50% success rate for week) + const sevenDaysAgo = baseTime - 604_800_000; + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', i % 2 === 0, sevenDaysAgo + i * 1000), + ); + } + + // Recent (last 12 hours): all successes (100% success rate) + const twelveHoursAgo = baseTime - 43_200_000; + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', true, twelveHoursAgo + i * 1000), + ); + } + + const score = scorer.getReliabilityScore('route-1'); + + // Should detect improving trend (recent 100% > weekly ~75%) + // Trend will be positive but may not be extreme due to overlapping windows + expect(score).not.toBeNull(); + expect(score!.breakdown.trendScore).toBeGreaterThanOrEqual(0.5); + }); + + it('detects degrading trend', () => { + const baseTime = mockTime.getTime(); + + // Week ago: all successes (100% success rate for week) + const sevenDaysAgo = baseTime - 604_800_000; + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', true, sevenDaysAgo + i * 1000), + ); + } + + // Recent (last 12 hours): all failures (0% success rate) + const twelveHoursAgo = baseTime - 43_200_000; + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', false, twelveHoursAgo + i * 1000), + ); + } + + const score = scorer.getReliabilityScore('route-1'); + + // Should detect degrading trend (recent 0% < weekly ~50%) + expect(score).not.toBeNull(); + expect(score!.breakdown.trendScore).toBeLessThanOrEqual(0.5); + }); + }); + + // ─── Edge Cases ─────────────────────────────────────────────────────────── + + describe('edge cases', () => { + it('handles single execution', () => { + scorer.recordExecution( + createExecution('route-1', true, mockTime.getTime()), + ); + const score = scorer.getReliabilityScore('route-1'); + + expect(score).not.toBeNull(); + expect(score!.score).toBeGreaterThan(0.9); + expect(score!.confidenceTier).toBe('low'); + }); + + it('handles all failures', () => { + const baseTime = mockTime.getTime(); + for (let i = 0; i < 50; i++) { + scorer.recordExecution( + createExecution('route-1', false, baseTime + i * 1000), + ); + } + + const score = scorer.getReliabilityScore('route-1'); + expect(score!.score).toBeLessThan(0.1); + }); + + it('handles mixed success and failures evenly', () => { + const baseTime = mockTime.getTime(); + for (let i = 0; i < 100; i++) { + scorer.recordExecution( + createExecution('route-1', i % 2 === 0, baseTime + i * 1000), + ); + } + + const score = scorer.getReliabilityScore('route-1'); + expect(score!.score).toBeCloseTo(0.5, 0); + }); + + it('filters expired data from metrics', () => { + const baseTime = mockTime.getTime(); + const thirtyOneDaysAgo = baseTime - 2_678_400_000; // 31 days ago + + scorer.recordExecution( + createExecution('route-1', true, thirtyOneDaysAgo), + ); + mockTime.advance(2_678_400_000); + + const metrics = scorer.calculateMetrics('route-1'); + expect(metrics).toBeNull(); // All data expired + }); + }); + + // ─── Integration Scenarios ──────────────────────────────────────────────── + + describe('integration scenarios', () => { + it('supports reliability-influenced route ranking', () => { + const baseTime = mockTime.getTime(); + + // Simulate real-world route performance + const routes = [ + { + id: 'fast-but-unreliable', + name: 'Fast Route', + provider: 'ProviderA', + }, + { id: 'slow-but-reliable', name: 'Slow Route', provider: 'ProviderB' }, + { id: 'balanced-route', name: 'Balanced Route', provider: 'ProviderC' }, + ]; + + // Fast route: 60% success rate + for (let i = 0; i < 100; i++) { + scorer.recordExecution( + createExecution( + 'fast-but-unreliable', + i % 10 < 6, + baseTime + i * 1000, + ), + ); + } + + // Slow route: 95% success rate + for (let i = 0; i < 100; i++) { + scorer.recordExecution( + createExecution( + 'slow-but-reliable', + i % 20 !== 0, + baseTime + i * 1000, + ), + ); + } + + // Balanced route: 80% success rate + for (let i = 0; i < 100; i++) { + scorer.recordExecution( + createExecution('balanced-route', i % 10 < 8, baseTime + i * 1000), + ); + } + + const ranked = scorer.rankRoutesByReliability(routes); + + // Reliability ranking should prefer reliable routes over fast ones + expect(ranked[0].route.id).toBe('slow-but-reliable'); + expect(ranked[1].route.id).toBe('balanced-route'); + expect(ranked[2].route.id).toBe('fast-but-unreliable'); + + // Verify scores reflect reliability differences + expect(ranked[0].reliabilityScore.score).toBeGreaterThan(0.9); + expect(ranked[2].reliabilityScore.score).toBeLessThan(0.7); + }); + + it('updates scores as new executions are recorded', () => { + const baseTime = mockTime.getTime(); + + // Initial: 50% success rate + for (let i = 0; i < 20; i++) { + scorer.recordExecution( + createExecution('route-1', i % 2 === 0, baseTime + i * 1000), + ); + } + + const score1 = scorer.getReliabilityScore('route-1'); + expect(score1!.score).toBeCloseTo(0.5, 0); + + // Add more successful executions + for (let i = 0; i < 30; i++) { + scorer.recordExecution( + createExecution('route-1', true, baseTime + 20000 + i * 1000), + ); + } + + const score2 = scorer.getReliabilityScore('route-1'); + expect(score2!.score).toBeGreaterThan(score1!.score); + }); + }); +}); diff --git a/src/scoring/reliability/stellar/soroban-route-reliability-scorer.ts b/src/scoring/reliability/stellar/soroban-route-reliability-scorer.ts new file mode 100644 index 00000000..c47cd5be --- /dev/null +++ b/src/scoring/reliability/stellar/soroban-route-reliability-scorer.ts @@ -0,0 +1,660 @@ +/** + * Soroban Route Reliability Scorer + * + * Tracks bridge route execution history, calculates reliability metrics, + * and generates reliability scores that influence route ranking. + * + * Features: + * - Success rate tracking with time-decay weighting + * - Multi-component reliability scoring (success rate, recency, consistency, trend) + * - Confidence calculation based on sample size and data freshness + * - Streak analysis (consecutive successes/failures) + * - Error pattern tracking and categorization + * - Volume-weighted scoring for amount-aware reliability + * - Integration support for route ranking systems + * + * Usage: + * const scorer = new SorobanRouteReliabilityScorer({ + * successRateWeight: 0.35, + * recentPerformanceWeight: 0.25, + * }); + * + * // Track execution + * scorer.recordExecution({ + * executionId: 'exec-123', + * routeId: 'stellar-ethereum-usdc', + * success: true, + * timestamp: Date.now(), + * }); + * + * // Get reliability score + * const score = scorer.getReliabilityScore('stellar-ethereum-usdc'); + * + * // Rank routes by reliability + * const ranked = scorer.rankRoutesByReliability(routes); + */ + +import type { + RouteExecutionRecord, + RouteReliabilityMetrics, + RouteReliabilityScore, + ReliabilityScoringConfig, + ExecutionQueryFilter, + ReliabilityScoringResult, + ReliabilityRankedRoute, +} from './soroban-route-reliability.types'; + +const DEFAULT_CONFIG: Required> = { + successRateWeight: 0.35, + recentPerformanceWeight: 0.25, + consistencyWeight: 0.2, + volumeWeight: 0.1, + trendWeight: 0.1, + recentWindowMs: 86_400_000, // 24 hours + weeklyWindowMs: 604_800_000, // 7 days + highConfidenceMinSamples: 100, + mediumConfidenceMinSamples: 30, + maxDataAgeMs: 2_592_000_000, // 30 days + enableTimeDecay: true, + timeDecayHalfLifeMs: 604_800_000, // 7 days +}; + +/** + * SorobanRouteReliabilityScorer + * + * Manages route execution history and calculates comprehensive reliability + * scores based on multiple performance dimensions. + */ +export class SorobanRouteReliabilityScorer { + private executions: RouteExecutionRecord[] = []; + private config: Required; + private routeIndex: Map = new Map(); // routeId -> execution indices + + constructor(config: ReliabilityScoringConfig = {}) { + this.config = { + ...DEFAULT_CONFIG, + now: config.now ?? (() => Date.now()), + }; + } + + // ─── Execution Tracking ─────────────────────────────────────────────────── + + /** + * Record a route execution event. + */ + recordExecution(execution: RouteExecutionRecord): void { + const index = this.executions.length; + this.executions.push(execution); + + // Update route index + const indices = this.routeIndex.get(execution.routeId) ?? []; + indices.push(index); + this.routeIndex.set(execution.routeId, indices); + } + + /** + * Record multiple executions at once. + */ + recordExecutions(executions: RouteExecutionRecord[]): void { + for (const execution of executions) { + this.recordExecution(execution); + } + } + + /** + * Clear all execution history. + */ + clearHistory(): void { + this.executions = []; + this.routeIndex.clear(); + } + + /** + * Get execution history for a route. + */ + getExecutions(routeId: string): RouteExecutionRecord[] { + const indices = this.routeIndex.get(routeId); + if (!indices) return []; + return indices.map((i) => this.executions[i]); + } + + /** + * Query executions with filters. + */ + queryExecutions(filter: ExecutionQueryFilter = {}): RouteExecutionRecord[] { + let results = this.executions; + + if (filter.routeId) { + results = results.filter((e) => e.routeId === filter.routeId); + } + + if (filter.routeIds?.length) { + const routeIdSet = new Set(filter.routeIds); + results = results.filter((e) => routeIdSet.has(e.routeId)); + } + + if (filter.success !== undefined) { + results = results.filter((e) => e.success === filter.success); + } + + if (filter.errorCategory) { + results = results.filter((e) => e.errorCategory === filter.errorCategory); + } + + if (filter.fromTimestamp) { + results = results.filter((e) => e.timestamp >= filter.fromTimestamp); + } + + if (filter.toTimestamp) { + results = results.filter((e) => e.timestamp <= filter.toTimestamp); + } + + if (filter.minAmountUsd !== undefined) { + results = results.filter( + (e) => (e.amountUsd ?? 0) >= filter.minAmountUsd, + ); + } + + if (filter.maxAmountUsd !== undefined) { + results = results.filter( + (e) => (e.amountUsd ?? 0) <= filter.maxAmountUsd, + ); + } + + return results; + } + + // ─── Metrics Calculation ────────────────────────────────────────────────── + + /** + * Calculate comprehensive reliability metrics for a route. + */ + calculateMetrics(routeId: string): RouteReliabilityMetrics | null { + const executions = this.getExecutionsForRoute(routeId); + if (executions.length === 0) return null; + + const now = this.config.now(); + const recentWindow = now - this.config.recentWindowMs; + const weeklyWindow = now - this.config.weeklyWindowMs; + const maxAge = now - this.config.maxDataAgeMs; + + // Filter by max age + const validExecutions = executions.filter((e) => e.timestamp >= maxAge); + if (validExecutions.length === 0) return null; + + // Sort by timestamp + const sorted = [...validExecutions].sort( + (a, b) => a.timestamp - b.timestamp, + ); + + // Basic counts + const totalExecutions = sorted.length; + const successfulExecutions = sorted.filter((e) => e.success).length; + const failedExecutions = totalExecutions - successfulExecutions; + const successRate = successfulExecutions / totalExecutions; + + // Recent success rate (last 24h) + const recentExecutions = sorted.filter((e) => e.timestamp >= recentWindow); + const recentSuccessRate = + recentExecutions.length > 0 + ? recentExecutions.filter((e) => e.success).length / + recentExecutions.length + : undefined; + + // Weekly success rate (last 7d) + const weeklyExecutions = sorted.filter((e) => e.timestamp >= weeklyWindow); + const weeklySuccessRate = + weeklyExecutions.length > 0 + ? weeklyExecutions.filter((e) => e.success).length / + weeklyExecutions.length + : undefined; + + // Duration statistics + const durations = sorted + .filter((e) => e.durationMs) + .map((e) => e.durationMs); + const avgDurationMs = + durations.length > 0 + ? durations.reduce((a, b) => a + b, 0) / durations.length + : undefined; + const medianDurationMs = + durations.length > 0 ? this.calculateMedian(durations) : undefined; + const p95DurationMs = + durations.length > 0 + ? this.calculatePercentile(durations, 0.95) + : undefined; + + // Failure breakdown + const failures = sorted.filter((e) => !e.success); + const failureBreakdown: Record = {}; + for (const failure of failures) { + const category = failure.errorCategory ?? 'unknown'; + failureBreakdown[category] = (failureBreakdown[category] ?? 0) + 1; + } + + // Streak analysis + const { + consecutiveSuccesses, + consecutiveFailures, + longestSuccessStreak, + longestFailureStreak, + } = this.analyzeStreaks(sorted); + + return { + routeId, + totalExecutions, + successfulExecutions, + failedExecutions, + successRate, + recentSuccessRate, + weeklySuccessRate, + avgDurationMs, + medianDurationMs, + p95DurationMs, + failureBreakdown: + Object.keys(failureBreakdown).length > 0 ? failureBreakdown : undefined, + consecutiveSuccesses, + consecutiveFailures, + longestSuccessStreak, + longestFailureStreak, + lastExecutionAt: sorted[sorted.length - 1].timestamp, + calculatedAt: now, + sampleSize: totalExecutions, + }; + } + + // ─── Reliability Scoring ────────────────────────────────────────────────── + + /** + * Calculate comprehensive reliability score for a route. + */ + getReliabilityScore(routeId: string): RouteReliabilityScore | null { + const metrics = this.calculateMetrics(routeId); + if (!metrics) return null; + + const now = this.config.now(); + + // Calculate score components + const successRateScore = this.calculateSuccessRateScore(metrics); + const recentPerformanceScore = + this.calculateRecentPerformanceScore(metrics); + const consistencyScore = this.calculateConsistencyScore(metrics); + const volumeScore = this.calculateVolumeScore(routeId); + const trendScore = this.calculateTrendScore(metrics); + + // Calculate weighted total score + let totalScore = 0; + let totalWeight = 0; + + totalScore += successRateScore * this.config.successRateWeight; + totalWeight += this.config.successRateWeight; + + totalScore += recentPerformanceScore * this.config.recentPerformanceWeight; + totalWeight += this.config.recentPerformanceWeight; + + totalScore += consistencyScore * this.config.consistencyWeight; + totalWeight += this.config.consistencyWeight; + + if (volumeScore !== undefined) { + totalScore += volumeScore * this.config.volumeWeight; + totalWeight += this.config.volumeWeight; + } + + totalScore += trendScore * this.config.trendWeight; + totalWeight += this.config.trendWeight; + + const finalScore = totalWeight > 0 ? totalScore / totalWeight : 0.5; + + // Calculate confidence + const confidence = this.calculateConfidence(metrics); + const confidenceTier = this.getConfidenceTier( + confidence, + metrics.sampleSize, + ); + + return { + routeId, + score: Math.round(finalScore * 10000) / 10000, + confidence: Math.round(confidence * 10000) / 10000, + confidenceTier, + breakdown: { + successRateScore: Math.round(successRateScore * 10000) / 10000, + recentPerformanceScore: + Math.round(recentPerformanceScore * 10000) / 10000, + consistencyScore: Math.round(consistencyScore * 10000) / 10000, + volumeScore: + volumeScore !== undefined + ? Math.round(volumeScore * 10000) / 10000 + : undefined, + trendScore: Math.round(trendScore * 10000) / 10000, + }, + metrics, + scoredAt: now, + }; + } + + /** + * Score multiple routes and return ranked results. + */ + scoreRoutes(routeIds: string[]): ReliabilityScoringResult { + const scoredRoutes: RouteReliabilityScore[] = []; + + for (const routeId of routeIds) { + const score = this.getReliabilityScore(routeId); + if (score) { + scoredRoutes.push(score); + } + } + + // Sort by score descending + scoredRoutes.sort((a, b) => b.score - a.score); + + return { + scoredRoutes, + totalRoutes: scoredRoutes.length, + scoredAt: this.config.now(), + config: this.config, + }; + } + + // ─── Route Ranking Integration ──────────────────────────────────────────── + + /** + * Rank routes by reliability score. + * Can be used standalone or integrated with existing ranking systems. + */ + rankRoutesByReliability( + routes: Array, + ): ReliabilityRankedRoute[] { + const routeIds = routes.map((r) => r.id); + const scoringResult = this.scoreRoutes(routeIds); + + const routeMap = new Map(routes.map((r) => [r.id, r])); + + return scoringResult.scoredRoutes.map((score, index) => { + const route = routeMap.get(score.routeId); + if (!route) { + throw new Error(`Route not found: ${score.routeId}`); + } + return { + route, + reliabilityScore: score, + combinedScore: score.score, + rank: index + 1, + }; + }); + } + + /** + * Get reliability-adjusted scores for route ranking integration. + * Returns a map of routeId -> reliability score that can be merged + * with other scoring systems. + */ + getReliabilityScoresForRanking(routeIds: string[]): Map { + const result = this.scoreRoutes(routeIds); + const scores = new Map(); + + for (const scored of result.scoredRoutes) { + scores.set(scored.routeId, scored.score); + } + + return scores; + } + + // ─── Data Management ────────────────────────────────────────────────────── + + /** + * Get total number of tracked executions. + */ + get totalExecutions(): number { + return this.executions.length; + } + + /** + * Get all tracked route IDs. + */ + get trackedRoutes(): string[] { + return Array.from(this.routeIndex.keys()); + } + + /** + * Remove old executions beyond max age. + */ + pruneOldExecutions(): number { + const maxAge = this.config.now() - this.config.maxDataAgeMs; + const originalCount = this.executions.length; + + this.executions = this.executions.filter((e) => e.timestamp >= maxAge); + + // Rebuild index + this.rebuildIndex(); + + return originalCount - this.executions.length; + } + + // ─── Internal Scoring Components ────────────────────────────────────────── + + private calculateSuccessRateScore(metrics: RouteReliabilityMetrics): number { + return metrics.successRate; + } + + private calculateRecentPerformanceScore( + metrics: RouteReliabilityMetrics, + ): number { + // Prefer recent success rate, fallback to overall + if (metrics.recentSuccessRate !== undefined) { + return metrics.recentSuccessRate; + } + return metrics.successRate; + } + + private calculateConsistencyScore(metrics: RouteReliabilityMetrics): number { + // Consistency based on current streak and longest streaks + const { + consecutiveSuccesses, + consecutiveFailures, + longestSuccessStreak, + longestFailureStreak, + } = metrics; + + // Calculate streak ratio (successes vs failures) + const currentStreak = + consecutiveSuccesses > 0 ? consecutiveSuccesses : -consecutiveFailures; + const maxPossibleStreak = Math.max( + longestSuccessStreak, + longestFailureStreak, + 1, + ); + + // Normalize to 0-1 range + const streakScore = 0.5 + (currentStreak / maxPossibleStreak) * 0.5; + + // Also factor in overall success rate variance + const successRateVariance = metrics.successRate * (1 - metrics.successRate); + const variancePenalty = successRateVariance * 0.3; // Lower variance = more consistent + + return Math.max(0, Math.min(1, streakScore - variancePenalty)); + } + + private calculateVolumeScore(routeId: string): number | undefined { + const executions = this.getExecutionsForRoute(routeId); + const withAmount = executions.filter( + (e) => e.amountUsd !== undefined && e.amountUsd > 0, + ); + + if (withAmount.length === 0) return undefined; + + // Calculate volume-weighted success rate + let totalVolume = 0; + let successfulVolume = 0; + + for (const exec of withAmount) { + const weight = this.config.enableTimeDecay + ? this.calculateTimeWeight(exec.timestamp) + : 1; + const amount = exec.amountUsd * weight; + totalVolume += amount; + if (exec.success) { + successfulVolume += amount; + } + } + + return totalVolume > 0 ? successfulVolume / totalVolume : 0.5; + } + + private calculateTrendScore(metrics: RouteReliabilityMetrics): number { + // Compare recent vs older performance to detect trend + if ( + metrics.recentSuccessRate === undefined || + metrics.weeklySuccessRate === undefined + ) { + return 0.5; // No trend data + } + + const improvement = metrics.recentSuccessRate - metrics.weeklySuccessRate; + + // Normalize to 0-1 range + // improvement of +0.2 or more = 1.0, -0.2 or less = 0.0 + return Math.max(0, Math.min(1, 0.5 + improvement * 2.5)); + } + + private calculateConfidence(metrics: RouteReliabilityMetrics): number { + const { sampleSize, lastExecutionAt } = metrics; + const now = this.config.now(); + + // Sample size component (0-0.6) + let sampleScore = 0; + if (sampleSize >= this.config.highConfidenceMinSamples) { + sampleScore = 0.6; + } else if (sampleSize >= this.config.mediumConfidenceMinSamples) { + sampleScore = 0.4; + } else if (sampleSize > 0) { + sampleScore = (sampleSize / this.config.mediumConfidenceMinSamples) * 0.4; + } + + // Data freshness component (0-0.4) + const ageMs = now - lastExecutionAt; + const maxAge = this.config.maxDataAgeMs; + let freshnessScore = 0; + if (ageMs < maxAge * 0.1) { + freshnessScore = 0.4; // Very fresh (within 10% of max age) + } else if (ageMs < maxAge * 0.5) { + freshnessScore = 0.3; // Moderately fresh + } else if (ageMs < maxAge) { + freshnessScore = 0.2; // Getting old + } else { + freshnessScore = 0; // Too old + } + + return sampleScore + freshnessScore; + } + + private getConfidenceTier( + confidence: number, + sampleSize: number, + ): 'high' | 'medium' | 'low' { + if ( + confidence >= 0.7 && + sampleSize >= this.config.highConfidenceMinSamples + ) { + return 'high'; + } + if ( + confidence >= 0.4 && + sampleSize >= this.config.mediumConfidenceMinSamples + ) { + return 'medium'; + } + return 'low'; + } + + // ─── Helper Methods ─────────────────────────────────────────────────────── + + private getExecutionsForRoute(routeId: string): RouteExecutionRecord[] { + const indices = this.routeIndex.get(routeId); + if (!indices) return []; + return indices.map((i) => this.executions[i]); + } + + private analyzeStreaks(executions: RouteExecutionRecord[]): { + consecutiveSuccesses: number; + consecutiveFailures: number; + longestSuccessStreak: number; + longestFailureStreak: number; + } { + let consecutiveSuccesses = 0; + let consecutiveFailures = 0; + let longestSuccessStreak = 0; + let longestFailureStreak = 0; + let currentSuccessStreak = 0; + let currentFailureStreak = 0; + + for (const exec of executions) { + if (exec.success) { + currentSuccessStreak++; + currentFailureStreak = 0; + consecutiveSuccesses = currentSuccessStreak; + consecutiveFailures = 0; + longestSuccessStreak = Math.max( + longestSuccessStreak, + currentSuccessStreak, + ); + } else { + currentFailureStreak++; + currentSuccessStreak = 0; + consecutiveFailures = currentFailureStreak; + consecutiveSuccesses = 0; + longestFailureStreak = Math.max( + longestFailureStreak, + currentFailureStreak, + ); + } + } + + return { + consecutiveSuccesses, + consecutiveFailures, + longestSuccessStreak, + longestFailureStreak, + }; + } + + private calculateTimeWeight(timestamp: number): number { + if (!this.config.enableTimeDecay) return 1; + + const age = this.config.now() - timestamp; + const halfLife = this.config.timeDecayHalfLifeMs; + + // Exponential decay: weight = 0.5^(age / halfLife) + return Math.pow(0.5, age / halfLife); + } + + private calculateMedian(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; + } + + private calculatePercentile(values: number[], percentile: number): number { + const sorted = [...values].sort((a, b) => a - b); + const index = Math.ceil(percentile * sorted.length) - 1; + return sorted[Math.max(0, index)]; + } + + private rebuildIndex(): void { + this.routeIndex.clear(); + for (let i = 0; i < this.executions.length; i++) { + const routeId = this.executions[i].routeId; + const indices = this.routeIndex.get(routeId) ?? []; + indices.push(i); + this.routeIndex.set(routeId, indices); + } + } +} + +/** Default shared scorer instance */ +export const routeReliabilityScorer = new SorobanRouteReliabilityScorer(); + +export default routeReliabilityScorer; diff --git a/src/scoring/reliability/stellar/soroban-route-reliability.types.ts b/src/scoring/reliability/stellar/soroban-route-reliability.types.ts new file mode 100644 index 00000000..96c3644a --- /dev/null +++ b/src/scoring/reliability/stellar/soroban-route-reliability.types.ts @@ -0,0 +1,177 @@ +/** + * Soroban Route Reliability Scoring Types + * + * Type definitions for tracking and scoring bridge route reliability + * based on success rates, failure patterns, and historical performance. + * + * @module + */ + +/** Individual route execution record */ +export interface RouteExecutionRecord { + /** Unique execution identifier */ + executionId: string; + /** Route identifier */ + routeId: string; + /** Whether the execution was successful */ + success: boolean; + /** Execution timestamp (ms since epoch) */ + timestamp: number; + /** Execution duration in milliseconds */ + durationMs?: number; + /** Error message if failed */ + error?: string; + /** Error category for pattern analysis */ + errorCategory?: + | 'timeout' + | 'insufficient_liquidity' + | 'slippage' + | 'network' + | 'contract' + | 'unknown'; + /** Transfer amount in USD (optional, for volume-weighted scoring) */ + amountUsd?: number; +} + +/** Aggregated reliability metrics for a route */ +export interface RouteReliabilityMetrics { + /** Route identifier */ + routeId: string; + /** Total number of executions */ + totalExecutions: number; + /** Number of successful executions */ + successfulExecutions: number; + /** Number of failed executions */ + failedExecutions: number; + /** Overall success rate (0-1) */ + successRate: number; + /** Success rate over last 24 hours (0-1) */ + recentSuccessRate?: number; + /** Success rate over last 7 days (0-1) */ + weeklySuccessRate?: number; + /** Average execution duration in milliseconds */ + avgDurationMs?: number; + /** Median execution duration in milliseconds */ + medianDurationMs?: number; + /** P95 execution duration in milliseconds */ + p95DurationMs?: number; + /** Failure rate breakdown by error category */ + failureBreakdown?: Record; + /** Consecutive successes (current streak) */ + consecutiveSuccesses: number; + /** Consecutive failures (current streak) */ + consecutiveFailures: number; + /** Longest success streak ever */ + longestSuccessStreak: number; + /** Longest failure streak ever */ + longestFailureStreak: number; + /** Timestamp of last execution */ + lastExecutionAt: number; + /** Timestamp when metrics were calculated */ + calculatedAt: number; + /** Number of data points used (for confidence calculation) */ + sampleSize: number; +} + +/** Reliability score with breakdown */ +export interface RouteReliabilityScore { + /** Route identifier */ + routeId: string; + /** Overall reliability score (0-1, where 1 is most reliable) */ + score: number; + /** Confidence in the score (0-1, based on sample size and data freshness) */ + confidence: number; + /** Confidence tier */ + confidenceTier: 'high' | 'medium' | 'low'; + /** Score breakdown by component */ + breakdown: { + /** Base success rate component (0-1) */ + successRateScore: number; + /** Recency-weighted success rate (0-1) */ + recentPerformanceScore: number; + /** Consistency score based on streaks (0-1) */ + consistencyScore: number; + /** Volume-weighted score (0-1) */ + volumeScore?: number; + /** Trend score (improving or degrading) (0-1) */ + trendScore: number; + }; + /** Underlying metrics used for scoring */ + metrics: RouteReliabilityMetrics; + /** Score calculation timestamp */ + scoredAt: number; +} + +/** Configuration for reliability scoring */ +export interface ReliabilityScoringConfig { + /** Weight for base success rate. Default: 0.35 */ + successRateWeight?: number; + /** Weight for recent performance. Default: 0.25 */ + recentPerformanceWeight?: number; + /** Weight for consistency. Default: 0.20 */ + consistencyWeight?: number; + /** Weight for volume (if amount data available). Default: 0.10 */ + volumeWeight?: number; + /** Weight for trend. Default: 0.10 */ + trendWeight?: number; + /** Time window for "recent" calculations in ms. Default: 86400000 (24h) */ + recentWindowMs?: number; + /** Time window for weekly calculations in ms. Default: 604800000 (7d) */ + weeklyWindowMs?: number; + /** Minimum sample size for high confidence. Default: 100 */ + highConfidenceMinSamples?: number; + /** Minimum sample size for medium confidence. Default: 30 */ + mediumConfidenceMinSamples?: number; + /** Maximum age of data to consider in ms. Default: 2592000000 (30d) */ + maxDataAgeMs?: number; + /** Enable time-decay weighting. Default: true */ + enableTimeDecay?: boolean; + /** Half-life for time decay in ms. Default: 604800000 (7d) */ + timeDecayHalfLifeMs?: number; + /** Injected clock for testing */ + now?: () => number; +} + +/** Filter options for querying route executions */ +export interface ExecutionQueryFilter { + /** Filter by route ID */ + routeId?: string; + /** Filter by route IDs (multiple) */ + routeIds?: string[]; + /** Filter by success status */ + success?: boolean; + /** Filter by error category */ + errorCategory?: string; + /** Filter by timestamp range (start) */ + fromTimestamp?: number; + /** Filter by timestamp range (end) */ + toTimestamp?: number; + /** Filter by amount range (min USD) */ + minAmountUsd?: number; + /** Filter by amount range (max USD) */ + maxAmountUsd?: number; +} + +/** Result of reliability scoring for multiple routes */ +export interface ReliabilityScoringResult { + /** Scored routes, sorted by score descending */ + scoredRoutes: RouteReliabilityScore[]; + /** Total routes scored */ + totalRoutes: number; + /** Scoring timestamp */ + scoredAt: number; + /** Configuration used for scoring */ + config: Required; +} + +/** Route ranking with reliability influence */ +export interface ReliabilityRankedRoute { + /** Original route data */ + route: T; + /** Reliability score */ + reliabilityScore: RouteReliabilityScore; + /** Combined score (includes other factors if provided) */ + combinedScore: number; + /** Rank position */ + rank: number; +}