From 8a378e87975b2340a5c97adbad7732b851a00587 Mon Sep 17 00:00:00 2001 From: SheyeJdev Date: Mon, 1 Jun 2026 02:17:17 +0100 Subject: [PATCH] feat:Implement Stellar Bridge Route Caching Layer --- .../stellar/stellar/FeeEstimationWidget.tsx | 241 ++++++++++++++++++ src/cache/routes/stellar/routeCache.ts | 186 ++++++++++++++ .../stellar/compatibilityScanner.ts | 145 +++++++++++ .../bridges/stellar/bridgeConfigRegistry.ts | 141 ++++++++++ 4 files changed, 713 insertions(+) create mode 100644 libs/ui-components/stellar/stellar/FeeEstimationWidget.tsx create mode 100644 src/cache/routes/stellar/routeCache.ts create mode 100644 src/compatibility/stellar/compatibilityScanner.ts create mode 100644 src/config/bridges/stellar/bridgeConfigRegistry.ts diff --git a/libs/ui-components/stellar/stellar/FeeEstimationWidget.tsx b/libs/ui-components/stellar/stellar/FeeEstimationWidget.tsx new file mode 100644 index 0000000..080708e --- /dev/null +++ b/libs/ui-components/stellar/stellar/FeeEstimationWidget.tsx @@ -0,0 +1,241 @@ +/** + * File: libs/ui-components/stellar/FeeEstimationWidget.tsx + * + * Embeddable fee estimation widget for Stellar transfers. + * Renders fee estimates and supports dynamic updates. + */ + +import React, { useState, useEffect, useCallback } from "react"; + +export interface FeeEstimate { + baseFee: number; // in stroops (1 XLM = 10,000,000 stroops) + surcharge?: number; + totalFee: number; + currency: string; // e.g. "XLM" + estimatedAt: Date; + ttlSeconds?: number; // how long this estimate is valid +} + +export interface FeeEstimationWidgetProps { + /** Called to fetch a fresh fee estimate */ + fetchEstimate: () => Promise; + /** Auto-refresh interval in ms. Default: 15000 (15s). Set to 0 to disable. */ + refreshIntervalMs?: number; + /** Optional className for container styling */ + className?: string; + /** Optional label override */ + label?: string; + /** Called when a new estimate is received */ + onEstimateUpdate?: (estimate: FeeEstimate) => void; +} + +function formatFee(stroops: number, currency: string): string { + const xlm = stroops / 10_000_000; + return `${xlm.toFixed(7)} ${currency}`; +} + +function getExpirySeconds(estimate: FeeEstimate): number | null { + if (!estimate.ttlSeconds) return null; + const elapsed = (Date.now() - estimate.estimatedAt.getTime()) / 1000; + return Math.max(0, Math.floor(estimate.ttlSeconds - elapsed)); +} + +export const FeeEstimationWidget: React.FC = ({ + fetchEstimate, + refreshIntervalMs = 15000, + className = "", + label = "Estimated Transfer Fee", + onEstimateUpdate, +}) => { + const [estimate, setEstimate] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [secondsLeft, setSecondsLeft] = useState(null); + + const loadEstimate = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await fetchEstimate(); + setEstimate(result); + onEstimateUpdate?.(result); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch fee estimate."); + } finally { + setLoading(false); + } + }, [fetchEstimate, onEstimateUpdate]); + + // Initial load + useEffect(() => { + loadEstimate(); + }, [loadEstimate]); + + // Auto-refresh + useEffect(() => { + if (!refreshIntervalMs) return; + const interval = setInterval(loadEstimate, refreshIntervalMs); + return () => clearInterval(interval); + }, [refreshIntervalMs, loadEstimate]); + + // TTL countdown + useEffect(() => { + if (!estimate?.ttlSeconds) return; + const tick = setInterval(() => { + setSecondsLeft(getExpirySeconds(estimate)); + }, 1000); + return () => clearInterval(tick); + }, [estimate]); + + return ( +
+
+ {label} + +
+ + {error && ( +
+ ⚠️ {error} +
+ )} + + {!error && !estimate && loading && ( +
+ Fetching estimate… +
+ )} + + {estimate && ( +
+
+ Base Fee + + {formatFee(estimate.baseFee, estimate.currency)} + +
+ + {estimate.surcharge !== undefined && ( +
+ Surcharge + + {formatFee(estimate.surcharge, estimate.currency)} + +
+ )} + +
+ Total Fee + + {formatFee(estimate.totalFee, estimate.currency)} + +
+ +
+ + Updated: {estimate.estimatedAt.toLocaleTimeString()} + + {secondsLeft !== null && ( + + {" "}· Valid for {secondsLeft}s + + )} +
+
+ )} +
+ ); +}; + +const styles: Record = { + container: { + border: "1px solid #e2e8f0", + borderRadius: "8px", + padding: "16px", + maxWidth: "360px", + fontFamily: "sans-serif", + background: "#fff", + boxShadow: "0 1px 4px rgba(0,0,0,0.08)", + }, + header: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "12px", + }, + label: { + fontWeight: 600, + fontSize: "14px", + color: "#1a202c", + }, + refreshBtn: { + background: "none", + border: "none", + cursor: "pointer", + fontSize: "16px", + padding: "2px 6px", + borderRadius: "4px", + }, + error: { + color: "#c53030", + fontSize: "13px", + padding: "8px", + background: "#fff5f5", + borderRadius: "4px", + }, + skeleton: { + color: "#a0aec0", + fontSize: "13px", + fontStyle: "italic", + }, + body: { + display: "flex", + flexDirection: "column", + gap: "8px", + }, + row: { + display: "flex", + justifyContent: "space-between", + fontSize: "13px", + color: "#4a5568", + }, + rowLabel: {}, + rowValue: { fontVariantNumeric: "tabular-nums" }, + totalRow: { + borderTop: "1px solid #e2e8f0", + paddingTop: "8px", + marginTop: "4px", + }, + totalLabel: { fontWeight: 700, color: "#1a202c", fontSize: "14px" }, + totalValue: { + fontWeight: 700, + color: "#2b6cb0", + fontSize: "14px", + fontVariantNumeric: "tabular-nums", + }, + meta: { + fontSize: "11px", + color: "#a0aec0", + marginTop: "4px", + }, + expiringSoon: { + color: "#dd6b20", + fontWeight: 600, + }, +}; + +export default FeeEstimationWidget; \ No newline at end of file diff --git a/src/cache/routes/stellar/routeCache.ts b/src/cache/routes/stellar/routeCache.ts new file mode 100644 index 0000000..6b1e4fe --- /dev/null +++ b/src/cache/routes/stellar/routeCache.ts @@ -0,0 +1,186 @@ +/** + * File: src/cache/routes/stellar/routeCache.ts + * + * Caches Stellar bridge route computations for faster queries. + * Supports TTL-based expiry and manual cache invalidation. + */ + +export interface RouteQuery { + 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; + } + + /** + * 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, + }); + } + + /** + * 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; + } + + /** + * 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++; + } + return { + total: this.cache.size, + expired, + valid: this.cache.size - expired, + }; + } + + private isExpired(entry: CacheEntry): boolean { + return Date.now() - entry.cachedAt > entry.ttlMs; + } + } + + // Default shared instance + export const routeCache = new RouteCacheStore(); + + export default routeCache; \ No newline at end of file diff --git a/src/compatibility/stellar/compatibilityScanner.ts b/src/compatibility/stellar/compatibilityScanner.ts new file mode 100644 index 0000000..02a567a --- /dev/null +++ b/src/compatibility/stellar/compatibilityScanner.ts @@ -0,0 +1,145 @@ +/** + * File: src/compatibility/stellar/compatibilityScanner.ts + * + * Scans compatibility between Soroban contracts and bridge providers. + * Validates bridge support and detects unsupported contract features. + */ + +export interface SorobanContractFeature { + name: string; + version?: string; + required: boolean; + } + + export interface BridgeProvider { + id: string; + name: string; + supportedFeatures: string[]; + supportedNetworks: string[]; + } + + export interface CompatibilityReport { + compatible: boolean; + bridgeProviderId: string; + supportedFeatures: string[]; + unsupportedFeatures: string[]; + warnings: string[]; + errors: string[]; + scannedAt: Date; + } + + export interface ScanOptions { + strictMode?: boolean; // Fail on any warning + ignoreOptional?: boolean; // Skip non-required features + } + + const KNOWN_UNSUPPORTED_FEATURES: Record = { + "bridge-provider-alpha": ["auth_invoke", "custom_types_v2"], + "bridge-provider-beta": ["upload_contract_wasm"], + }; + + /** + * Validates whether a bridge provider supports a given set of Soroban contract features. + */ + export function validateBridgeSupport( + provider: BridgeProvider, + contractFeatures: SorobanContractFeature[], + options: ScanOptions = {} + ): CompatibilityReport { + const { strictMode = false, ignoreOptional = false } = options; + + const supportedFeatures: string[] = []; + const unsupportedFeatures: string[] = []; + const warnings: string[] = []; + const errors: string[] = []; + + const featuresToCheck = ignoreOptional + ? contractFeatures.filter((f) => f.required) + : contractFeatures; + + for (const feature of featuresToCheck) { + const isSupported = provider.supportedFeatures.includes(feature.name); + + if (isSupported) { + supportedFeatures.push(feature.name); + } else { + if (feature.required) { + errors.push( + `Bridge provider "${provider.name}" does not support required feature: "${feature.name}".` + ); + unsupportedFeatures.push(feature.name); + } else { + warnings.push( + `Bridge provider "${provider.name}" does not support optional feature: "${feature.name}".` + ); + unsupportedFeatures.push(feature.name); + } + } + } + + if (strictMode && warnings.length > 0) { + errors.push(...warnings.map((w) => `[Strict] ${w}`)); + } + + const compatible = errors.length === 0; + + return { + compatible, + bridgeProviderId: provider.id, + supportedFeatures, + unsupportedFeatures, + warnings: strictMode ? [] : warnings, + errors, + scannedAt: new Date(), + }; + } + + /** + * Detects unsupported Soroban contract features for a specific bridge provider. + */ + export function detectUnsupportedFeatures( + providerId: string, + contractFeatures: SorobanContractFeature[] + ): string[] { + const knownUnsupported = KNOWN_UNSUPPORTED_FEATURES[providerId] ?? []; + + return contractFeatures + .filter((f) => knownUnsupported.includes(f.name)) + .map((f) => f.name); + } + + /** + * Main compatibility scanner — runs full scan for a contract against a bridge provider. + */ + export class CompatibilityScanner { + private provider: BridgeProvider; + + constructor(provider: BridgeProvider) { + this.provider = provider; + } + + scan( + contractFeatures: SorobanContractFeature[], + options?: ScanOptions + ): CompatibilityReport { + const knownUnsupported = detectUnsupportedFeatures( + this.provider.id, + contractFeatures + ); + + if (knownUnsupported.length > 0) { + console.warn( + `[CompatibilityScanner] Known unsupported features detected for provider "${this.provider.id}":`, + knownUnsupported + ); + } + + return validateBridgeSupport(this.provider, contractFeatures, options); + } + + getProvider(): BridgeProvider { + return this.provider; + } + } + + export default CompatibilityScanner; \ No newline at end of file diff --git a/src/config/bridges/stellar/bridgeConfigRegistry.ts b/src/config/bridges/stellar/bridgeConfigRegistry.ts new file mode 100644 index 0000000..42c8b75 --- /dev/null +++ b/src/config/bridges/stellar/bridgeConfigRegistry.ts @@ -0,0 +1,141 @@ +/** + * File: src/config/bridges/stellar/bridgeConfigRegistry.ts + * + * Centralized configuration management for Soroban bridges. + * Stores bridge configurations and supports runtime updates. + */ + +export interface BridgeConfig { + id: string; + name: string; + rpcUrl: string; + contractAddress: string; + networkPassphrase: string; + timeoutMs: number; + retryAttempts: number; + feeBumpEnabled: boolean; + metadata?: Record; + } + + export type ConfigUpdatePayload = Partial>; + + export interface ConfigChangeEvent { + bridgeId: string; + previous: BridgeConfig | null; + current: BridgeConfig; + updatedAt: Date; + } + + type ChangeListener = (event: ConfigChangeEvent) => void; + + /** + * BridgeConfigRegistry + * + * Singleton registry for managing Soroban bridge configurations at runtime. + * Supports adding, updating, removing, and watching bridge configurations. + */ + export class BridgeConfigRegistry { + private static instance: BridgeConfigRegistry; + private configs: Map = new Map(); + private listeners: Set = new Set(); + + private constructor() {} + + static getInstance(): BridgeConfigRegistry { + if (!BridgeConfigRegistry.instance) { + BridgeConfigRegistry.instance = new BridgeConfigRegistry(); + } + return BridgeConfigRegistry.instance; + } + + /** + * Register a new bridge configuration. + * Throws if a config with the same id already exists. + */ + register(config: BridgeConfig): void { + if (this.configs.has(config.id)) { + throw new Error( + `[BridgeConfigRegistry] Config with id "${config.id}" already registered. Use update() to modify it.` + ); + } + this.configs.set(config.id, { ...config }); + this.emit({ bridgeId: config.id, previous: null, current: config, updatedAt: new Date() }); + } + + /** + * Retrieve a bridge configuration by id. + */ + get(id: string): BridgeConfig | undefined { + return this.configs.get(id); + } + + /** + * Get all registered configurations. + */ + getAll(): BridgeConfig[] { + return Array.from(this.configs.values()); + } + + /** + * Update an existing bridge configuration at runtime. + * Merges provided fields with the existing config. + */ + update(id: string, updates: ConfigUpdatePayload): BridgeConfig { + const existing = this.configs.get(id); + if (!existing) { + throw new Error( + `[BridgeConfigRegistry] No config found for id "${id}". Use register() first.` + ); + } + const updated: BridgeConfig = { ...existing, ...updates }; + this.configs.set(id, updated); + this.emit({ bridgeId: id, previous: existing, current: updated, updatedAt: new Date() }); + return updated; + } + + /** + * Remove a bridge configuration. + */ + remove(id: string): boolean { + return this.configs.delete(id); + } + + /** + * Check if a config exists. + */ + has(id: string): boolean { + return this.configs.has(id); + } + + /** + * Subscribe to config change events. + * Returns an unsubscribe function. + */ + onChange(listener: ChangeListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * Reset the registry (useful for testing). + */ + clear(): void { + this.configs.clear(); + this.listeners.clear(); + } + + private emit(event: ConfigChangeEvent): void { + this.listeners.forEach((listener) => { + try { + listener(event); + } catch (err) { + console.error("[BridgeConfigRegistry] Listener error:", err); + } + }); + } + } + + // Export a default singleton instance + export const bridgeConfigRegistry = BridgeConfigRegistry.getInstance(); + + export default bridgeConfigRegistry; \ No newline at end of file