From e29ee3938e782e503530cf9d03085094f7c6cb41 Mon Sep 17 00:00:00 2001 From: Big-cedar Date: Sat, 25 Apr 2026 01:23:39 +0100 Subject: [PATCH] feat: implement chain validation, smart recommendations, and auto-refresh --- .../recommendation.controller.ts | 135 ++++++ apps/api/src/services/recommendation.ts | 349 +++++++++++++++ apps/api/src/services/refresh.ts | 397 ++++++++++++++++++ apps/web/hooks/useRecommendation.ts | 148 +++++++ apps/web/package-lock.json | 86 +++- apps/web/package.json | 4 +- docs/QUICK_REFERENCE_NEW_FEATURES.md | 242 +++++++++++ .../src/components/RefreshStatus.tsx | 205 +++++++++ src/validators/chain.validator.ts | 365 ++++++++++++++-- 9 files changed, 1893 insertions(+), 38 deletions(-) create mode 100644 apps/api/src/bridge-recommendation/recommendation.controller.ts create mode 100644 apps/api/src/services/recommendation.ts create mode 100644 apps/api/src/services/refresh.ts create mode 100644 apps/web/hooks/useRecommendation.ts create mode 100644 docs/QUICK_REFERENCE_NEW_FEATURES.md create mode 100644 libs/ui-components/src/components/RefreshStatus.tsx diff --git a/apps/api/src/bridge-recommendation/recommendation.controller.ts b/apps/api/src/bridge-recommendation/recommendation.controller.ts new file mode 100644 index 00000000..2b43c7c5 --- /dev/null +++ b/apps/api/src/bridge-recommendation/recommendation.controller.ts @@ -0,0 +1,135 @@ +/** + * Bridge Recommendation Controller + * + * REST API endpoint for getting smart route recommendations based on user preferences. + */ + +import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBody, ApiResponse } from '@nestjs/swagger'; +import { + SmartRecommendationService, + RecommendationRequest, + UserPreference, + RecommendationResult, +} from '../services/recommendation'; + +/** + * Request DTO for recommendation endpoint + */ +export class RecommendationRequestDto { + sourceChain: string; + destinationChain: string; + token: string; + amount: number; + preference: UserPreference; + routes: Array<{ + id: string; + bridgeName: string; + sourceChain: string; + destinationChain: string; + inputAmount: string; + outputAmount: string; + feeUsd: number; + gasCostUsd: number; + totalFeeUsd: number; + estimatedTimeSeconds: number; + reliabilityScore: number; + slippagePercent: number; + liquidityUsd: number; + }>; + minReliability?: number; + maxFeeUsd?: number; + maxTimeSeconds?: number; +} + +/** + * Response DTO for recommendation endpoint + */ +export class RecommendationResponseDto { + success: boolean; + count: number; + recommendations: Array<{ + rank: number; + bridge: string; + score: number; + confidence: string; + recommendation: string; + fee: number; + time: number; + reliability: number; + }>; +} + +@ApiTags('Recommendations') +@Controller('recommendations') +export class RecommendationController { + constructor(private readonly recommendationService: SmartRecommendationService) {} + + @Post() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get smart bridge route recommendations', + description: 'Returns ranked bridge routes based on user preferences (fastest, cheapest, balanced, or most reliable).', + }) + @ApiBody({ type: RecommendationRequestDto }) + @ApiResponse({ + status: 200, + description: 'Recommendations generated successfully', + type: RecommendationResponseDto, + }) + async getRecommendations( + @Body() request: RecommendationRequestDto, + ): Promise { + // Convert DTO to service request format + const serviceRequest: RecommendationRequest = { + routes: request.routes, + preference: request.preference, + minReliability: request.minReliability, + maxFeeUsd: request.maxFeeUsd, + maxTimeSeconds: request.maxTimeSeconds, + }; + + // Get recommendations + const results = this.recommendationService.recommend(serviceRequest); + + // Format response + return { + success: true, + count: results.length, + recommendations: results.map(result => ({ + rank: result.rank, + bridge: result.route.bridgeName, + score: result.score, + confidence: result.confidence, + recommendation: result.recommendation, + fee: result.route.totalFeeUsd, + time: result.route.estimatedTimeSeconds, + reliability: result.route.reliabilityScore, + })), + }; + } + + @Post('compare') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Compare routes across all preferences', + description: 'Returns the best route for each preference type (fastest, cheapest, balanced, most reliable).', + }) + @ApiBody({ type: RecommendationRequestDto }) + async compareAllPreferences( + @Body() request: RecommendationRequestDto, + ): Promise<{ + success: boolean; + comparisons: Array<{ + preference: UserPreference; + bestRoute: any; + }>; + }> { + const comparisons = this.recommendationService.compareAllPreferences(request.routes); + + return { + success: true, + comparisons, + }; + } +} diff --git a/apps/api/src/services/recommendation.ts b/apps/api/src/services/recommendation.ts new file mode 100644 index 00000000..ecdd4a77 --- /dev/null +++ b/apps/api/src/services/recommendation.ts @@ -0,0 +1,349 @@ +/** + * Smart Route Recommendation Engine + * + * Provides AI-lite bridge route recommendations based on user preferences. + * Dynamically adjusts ranking weights for fastest, cheapest, or balanced routes. + * + * Implementation Scope: src/services/recommendation.ts + */ + +import { Injectable, Logger } from '@nestjs/common'; + +/** + * User preference types for route optimization + */ +export enum UserPreference { + FASTEST = 'fastest', + CHEAPEST = 'cheapest', + BALANCED = 'balanced', + MOST_RELIABLE = 'most-reliable', +} + +/** + * Route scoring weights based on user preference + */ +export interface PreferenceWeights { + cost: number; + speed: number; + reliability: number; + slippage: number; + liquidity: number; +} + +/** + * Bridge route input for recommendation + */ +export interface RouteInput { + id: string; + bridgeName: string; + sourceChain: string; + destinationChain: string; + inputAmount: string; + outputAmount: string; + feeUsd: number; + gasCostUsd: number; + totalFeeUsd: number; + estimatedTimeSeconds: number; + reliabilityScore: number; // 0-100 + slippagePercent: number; + liquidityUsd: number; + historicalSuccessRate?: number; // 0-1 +} + +/** + * Recommendation result with scoring breakdown + */ +export interface RecommendationResult { + route: RouteInput; + score: number; // 0-100 + rank: number; + breakdown: { + costScore: number; + speedScore: number; + reliabilityScore: number; + slippageScore: number; + liquidityScore: number; + }; + confidence: 'high' | 'medium' | 'low'; + recommendation: string; +} + +/** + * Recommendation request with user preferences + */ +export interface RecommendationRequest { + routes: RouteInput[]; + preference: UserPreference; + minReliability?: number; // Minimum reliability score (0-100) + maxFeeUsd?: number; // Maximum acceptable fee + maxTimeSeconds?: number; // Maximum acceptable time +} + +/** + * Smart route recommendation service with preference-based ranking + */ +@Injectable() +export class SmartRecommendationService { + private readonly logger = new Logger(SmartRecommendationService.name); + + /** + * Pre-configured weight profiles for different user preferences + */ + private readonly WEIGHT_PROFILES: Record = { + [UserPreference.FASTEST]: { + cost: 0.15, + speed: 0.50, + reliability: 0.20, + slippage: 0.10, + liquidity: 0.05, + }, + [UserPreference.CHEAPEST]: { + cost: 0.50, + speed: 0.15, + reliability: 0.20, + slippage: 0.10, + liquidity: 0.05, + }, + [UserPreference.BALANCED]: { + cost: 0.25, + speed: 0.25, + reliability: 0.30, + slippage: 0.10, + liquidity: 0.10, + }, + [UserPreference.MOST_RELIABLE]: { + cost: 0.15, + speed: 0.15, + reliability: 0.50, + slippage: 0.10, + liquidity: 0.10, + }, + }; + + /** + * Get recommendations based on user preferences + */ + recommend(request: RecommendationRequest): RecommendationResult[] { + const { routes, preference, minReliability, maxFeeUsd, maxTimeSeconds } = request; + + // Validate input + if (!routes || routes.length === 0) { + this.logger.warn('No routes provided for recommendation'); + return []; + } + + // Filter routes based on constraints + let filteredRoutes = routes; + + if (minReliability !== undefined) { + filteredRoutes = filteredRoutes.filter(r => r.reliabilityScore >= minReliability); + } + + if (maxFeeUsd !== undefined) { + filteredRoutes = filteredRoutes.filter(r => r.totalFeeUsd <= maxFeeUsd); + } + + if (maxTimeSeconds !== undefined) { + filteredRoutes = filteredRoutes.filter(r => r.estimatedTimeSeconds <= maxTimeSeconds); + } + + if (filteredRoutes.length === 0) { + this.logger.warn('No routes match the given constraints'); + return []; + } + + // Get weights for preference + const weights = this.WEIGHT_PROFILES[preference]; + + // Calculate scores for each route + const scored = filteredRoutes.map(route => { + const scores = this.calculateScores(route, filteredRoutes); + const score = this.computeWeightedScore(scores, weights); + const confidence = this.calculateConfidence(route, filteredRoutes); + const recommendation = this.generateRecommendation(route, preference, scores); + + return { + route, + score: parseFloat(score.toFixed(2)), + rank: 0, // Will be set after sorting + breakdown: scores, + confidence, + recommendation, + }; + }); + + // Sort by score descending + scored.sort((a, b) => b.score - a.score); + + // Assign ranks + scored.forEach((result, index) => { + result.rank = index + 1; + }); + + this.logger.log( + `Generated ${scored.length} recommendations with preference: ${preference}`, + ); + + return scored; + } + + /** + * Normalize individual scores against the route set + */ + private calculateScores( + route: RouteInput, + allRoutes: RouteInput[], + ): { + costScore: number; + speedScore: number; + reliabilityScore: number; + slippageScore: number; + liquidityScore: number; + } { + const maxFee = Math.max(...allRoutes.map(r => r.totalFeeUsd)); + const maxTime = Math.max(...allRoutes.map(r => r.estimatedTimeSeconds)); + const maxSlippage = Math.max(...allRoutes.map(r => r.slippagePercent)); + const maxLiquidity = Math.max(...allRoutes.map(r => r.liquidityUsd)); + + // Cost score (lower fee = higher score) + const costScore = maxFee > 0 ? (1 - route.totalFeeUsd / maxFee) * 100 : 100; + + // Speed score (lower time = higher score) + const speedScore = maxTime > 0 ? (1 - route.estimatedTimeSeconds / maxTime) * 100 : 100; + + // Reliability score (already 0-100) + const reliabilityScore = route.reliabilityScore; + + // Slippage score (lower slippage = higher score) + const slippageScore = maxSlippage > 0 ? (1 - route.slippagePercent / maxSlippage) * 100 : 100; + + // Liquidity score (higher liquidity = higher score) + const liquidityScore = maxLiquidity > 0 ? (route.liquidityUsd / maxLiquidity) * 100 : 50; + + return { + costScore: parseFloat(costScore.toFixed(2)), + speedScore: parseFloat(speedScore.toFixed(2)), + reliabilityScore: parseFloat(reliabilityScore.toFixed(2)), + slippageScore: parseFloat(slippageScore.toFixed(2)), + liquidityScore: parseFloat(liquidityScore.toFixed(2)), + }; + } + + /** + * Compute weighted composite score + */ + private computeWeightedScore( + scores: { + costScore: number; + speedScore: number; + reliabilityScore: number; + slippageScore: number; + liquidityScore: number; + }, + weights: PreferenceWeights, + ): number { + const weightedSum = + scores.costScore * weights.cost + + scores.speedScore * weights.speed + + scores.reliabilityScore * weights.reliability + + scores.slippageScore * weights.slippage + + scores.liquidityScore * weights.liquidity; + + return Math.min(100, Math.max(0, weightedSum)); + } + + /** + * Calculate confidence level based on route metrics + */ + private calculateConfidence( + route: RouteInput, + allRoutes: RouteInput[], + ): 'high' | 'medium' | 'low' { + const avgFee = allRoutes.reduce((sum, r) => sum + r.totalFeeUsd, 0) / allRoutes.length; + const avgReliability = allRoutes.reduce((sum, r) => sum + r.reliabilityScore, 0) / allRoutes.length; + + // High confidence: below average fees and above average reliability + if (route.totalFeeUsd <= avgFee * 1.1 && route.reliabilityScore >= avgReliability) { + return 'high'; + } + + // Low confidence: significantly above average fees or below average reliability + if (route.totalFeeUsd > avgFee * 1.5 || route.reliabilityScore < avgReliability * 0.7) { + return 'low'; + } + + // Medium confidence: everything else + return 'medium'; + } + + /** + * Generate human-readable recommendation text + */ + private generateRecommendation( + route: RouteInput, + preference: UserPreference, + scores: any, + ): string { + switch (preference) { + case UserPreference.FASTEST: + return `⚡ Best speed: ${route.estimatedTimeSeconds}s estimated time with ${route.bridgeName}`; + + case UserPreference.CHEAPEST: + return `💰 Most cost-effective: $${route.totalFeeUsd.toFixed(2)} total fees via ${route.bridgeName}`; + + case UserPreference.BALANCED: + return `⚖️ Optimal balance of speed, cost, and reliability`; + + case UserPreference.MOST_RELIABLE: + return `🛡️ Highest reliability: ${route.reliabilityScore}% success rate with ${route.bridgeName}`; + + default: + return `Recommended route via ${route.bridgeName}`; + } + } + + /** + * Get weight profile for a preference + */ + getWeightProfile(preference: UserPreference): PreferenceWeights { + return { ...this.WEIGHT_PROFILES[preference] }; + } + + /** + * Create custom weight profile + */ + createCustomWeights(weights: Partial): PreferenceWeights { + const balanced = this.WEIGHT_PROFILES[UserPreference.BALANCED]; + const custom = { ...balanced, ...weights }; + + // Normalize weights to sum to 1 + const sum = Object.values(custom).reduce((acc, val) => acc + val, 0); + const entries = Object.entries(custom).map(([key, val]) => [key, parseFloat((val / sum).toFixed(2))]); + + return { + cost: Number(entries.find(([k]) => k === 'cost')?.[1] || 0.25), + speed: Number(entries.find(([k]) => k === 'speed')?.[1] || 0.25), + reliability: Number(entries.find(([k]) => k === 'reliability')?.[1] || 0.30), + slippage: Number(entries.find(([k]) => k === 'slippage')?.[1] || 0.10), + liquidity: Number(entries.find(([k]) => k === 'liquidity')?.[1] || 0.10), + }; + } + + /** + * Compare routes across all preference profiles + */ + compareAllPreferences(routes: RouteInput[]): { + preference: UserPreference; + bestRoute: RecommendationResult; + }[] { + const preferences = Object.values(UserPreference); + + return preferences.map(pref => { + const results = this.recommend({ routes, preference: pref }); + return { + preference: pref, + bestRoute: results[0], + }; + }); + } +} diff --git a/apps/api/src/services/refresh.ts b/apps/api/src/services/refresh.ts new file mode 100644 index 00000000..ec58eae1 --- /dev/null +++ b/apps/api/src/services/refresh.ts @@ -0,0 +1,397 @@ +/** + * Auto Refresh Service for Real-Time Fee and Speed Updates + * + * Polls bridge providers periodically and updates UI reactively. + * Ensures data freshness with configurable refresh intervals. + * + * Implementation Scope: src/services/refresh.ts + */ + +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +/** + * Refresh configuration for different data types + */ +export interface RefreshConfig { + /** Refresh interval in milliseconds */ + intervalMs: number; + /** Enable/disable auto-refresh */ + enabled: boolean; + /** Maximum retry attempts on failure */ + maxRetries: number; + /** Delay between retries in milliseconds */ + retryDelayMs: number; + /** Exponential backoff multiplier */ + backoffMultiplier?: number; +} + +/** + * Data types that can be refreshed + */ +export enum RefreshDataType { + FEES = 'fees', + SPEEDS = 'speeds', + LIQUIDITY = 'liquidity', + QUOTES = 'quotes', + RELIABILITY = 'reliability', +} + +/** + * Refresh event payload + */ +export interface RefreshEvent { + dataType: RefreshDataType; + timestamp: Date; + data: any; + source?: string; + error?: Error; +} + +/** + * Refresh state tracking + */ +export interface RefreshState { + isRefreshing: boolean; + lastRefreshed: Date | null; + lastError: Error | null; + retryCount: number; + refreshCount: number; +} + +/** + * Default refresh configurations + */ +const DEFAULT_CONFIGS: Record = { + [RefreshDataType.FEES]: { + intervalMs: 15_000, // 15 seconds + enabled: true, + maxRetries: 3, + retryDelayMs: 1_000, + backoffMultiplier: 2, + }, + [RefreshDataType.SPEEDS]: { + intervalMs: 30_000, // 30 seconds + enabled: true, + maxRetries: 3, + retryDelayMs: 1_000, + backoffMultiplier: 2, + }, + [RefreshDataType.LIQUIDITY]: { + intervalMs: 60_000, // 1 minute + enabled: true, + maxRetries: 2, + retryDelayMs: 2_000, + backoffMultiplier: 2, + }, + [RefreshDataType.QUOTES]: { + intervalMs: 15_000, // 15 seconds + enabled: true, + maxRetries: 3, + retryDelayMs: 500, + backoffMultiplier: 1.5, + }, + [RefreshDataType.RELIABILITY]: { + intervalMs: 300_000, // 5 minutes + enabled: true, + maxRetries: 2, + retryDelayMs: 5_000, + backoffMultiplier: 2, + }, +}; + +/** + * Data fetcher function type + */ +export type DataFetcher = (dataType: RefreshDataType) => Promise; + +/** + * Auto Refresh Service - Polls providers and updates data reactively + */ +@Injectable() +export class AutoRefreshService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(AutoRefreshService.name); + + /** Refresh interval timers */ + private intervals: Map = new Map(); + + /** Refresh state for each data type */ + private states: Map = new Map(); + + /** Current configurations */ + private configs: Map = new Map(); + + /** Data fetcher callback */ + private fetcher: DataFetcher | null = null; + + /** Pause all refreshers flag */ + private isPaused = false; + + constructor(private eventEmitter: EventEmitter2) { + // Initialize states and configs + Object.values(RefreshDataType).forEach(dataType => { + this.states.set(dataType, { + isRefreshing: false, + lastRefreshed: null, + lastError: null, + retryCount: 0, + refreshCount: 0, + }); + this.configs.set(dataType, { ...DEFAULT_CONFIGS[dataType] }); + }); + } + + onModuleInit(): void { + this.logger.log('AutoRefreshService initialized'); + } + + onModuleDestroy(): void { + this.stopAll(); + this.logger.log('AutoRefreshService destroyed'); + } + + /** + * Set the data fetcher callback + */ + setFetcher(fetcher: DataFetcher): void { + this.fetcher = fetcher; + } + + /** + * Start auto-refresh for a specific data type + */ + async start(dataType: RefreshDataType): Promise { + const config = this.configs.get(dataType)!; + + if (!config.enabled) { + this.logger.debug(`Auto-refresh disabled for ${dataType}`); + return; + } + + if (this.intervals.has(dataType)) { + this.logger.debug(`Auto-refresh already running for ${dataType}`); + return; + } + + this.logger.log(`Starting auto-refresh for ${dataType} (every ${config.intervalMs}ms)`); + + // Trigger immediate first refresh + await this.refresh(dataType); + + // Set up interval + const interval = setInterval(async () => { + if (!this.isPaused) { + await this.refresh(dataType); + } + }, config.intervalMs); + + this.intervals.set(dataType, interval); + } + + /** + * Stop auto-refresh for a specific data type + */ + stop(dataType: RefreshDataType): void { + const interval = this.intervals.get(dataType); + if (interval) { + clearInterval(interval); + this.intervals.delete(dataType); + this.logger.log(`Stopped auto-refresh for ${dataType}`); + } + } + + /** + * Stop all auto-refreshers + */ + stopAll(): void { + this.intervals.forEach((interval, dataType) => { + clearInterval(interval); + }); + this.intervals.clear(); + this.logger.log('Stopped all auto-refreshers'); + } + + /** + * Pause all refreshers without clearing intervals + */ + pause(): void { + this.isPaused = true; + this.logger.log('Paused all auto-refreshers'); + } + + /** + * Resume all refreshers + */ + resume(): void { + this.isPaused = false; + this.logger.log('Resumed all auto-refreshers'); + } + + /** + * Trigger manual refresh + */ + async refresh(dataType: RefreshDataType): Promise { + const state = this.states.get(dataType)!; + const config = this.configs.get(dataType)!; + + if (state.isRefreshing) { + this.logger.debug(`Refresh already in progress for ${dataType}`); + return null; + } + + if (!this.fetcher) { + this.logger.error('No data fetcher configured'); + return null; + } + + // Update state + state.isRefreshing = true; + state.retryCount = 0; + + try { + this.logger.debug(`Refreshing ${dataType}...`); + + // Emit refresh start event + this.eventEmitter.emit('refresh:start', { + dataType, + timestamp: new Date(), + } as RefreshEvent); + + // Fetch data with retry logic + const data = await this.fetchWithRetry(dataType, config); + + // Update state on success + state.isRefreshing = false; + state.lastRefreshed = new Date(); + state.lastError = null; + state.retryCount = 0; + state.refreshCount++; + + // Emit success event with data + this.eventEmitter.emit('refresh:success', { + dataType, + timestamp: new Date(), + data, + } as RefreshEvent); + + this.logger.log(`Successfully refreshed ${dataType} (refresh #${state.refreshCount})`); + + return data; + } catch (error) { + // Update state on error + state.isRefreshing = false; + state.lastError = error as Error; + + // Emit error event + this.eventEmitter.emit('refresh:error', { + dataType, + timestamp: new Date(), + error: error as Error, + } as RefreshEvent); + + this.logger.error(`Failed to refresh ${dataType}: ${error}`); + + throw error; + } + } + + /** + * Fetch data with retry logic and exponential backoff + */ + private async fetchWithRetry( + dataType: RefreshDataType, + config: RefreshConfig, + ): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= config.maxRetries; attempt++) { + try { + if (!this.fetcher) { + throw new Error('No data fetcher configured'); + } + + return await this.fetcher(dataType); + } catch (error) { + lastError = error as Error; + + if (attempt < config.maxRetries) { + const delay = config.retryDelayMs * Math.pow( + config.backoffMultiplier || 2, + attempt, + ); + + this.logger.warn( + `Retry ${attempt + 1}/${config.maxRetries} for ${dataType} in ${delay}ms`, + ); + + await this.sleep(delay); + } + } + } + + throw lastError || new Error(`Failed to fetch ${dataType} after ${config.maxRetries} retries`); + } + + /** + * Update refresh configuration + */ + updateConfig(dataType: RefreshDataType, config: Partial): void { + const current = this.configs.get(dataType)!; + const updated = { ...current, ...config }; + this.configs.set(dataType, updated); + + this.logger.debug(`Updated config for ${dataType}: ${JSON.stringify(updated)}`); + + // Restart if interval changed + if (config.intervalMs && this.intervals.has(dataType)) { + this.stop(dataType); + this.start(dataType); + } + } + + /** + * Get current refresh state + */ + getState(dataType: RefreshDataType): RefreshState { + return { ...this.states.get(dataType)! }; + } + + /** + * Get all refresh states + */ + getAllStates(): Record { + const states: Record = {}; + this.states.forEach((state, dataType) => { + states[dataType] = { ...state }; + }); + return states; + } + + /** + * Get current configuration + */ + getConfig(dataType: RefreshDataType): RefreshConfig { + return { ...this.configs.get(dataType)! }; + } + + /** + * Check if a data type is currently refreshing + */ + isRefreshing(dataType: RefreshDataType): boolean { + return this.states.get(dataType)?.isRefreshing || false; + } + + /** + * Get last refresh timestamp + */ + getLastRefreshed(dataType: RefreshDataType): Date | null { + return this.states.get(dataType)?.lastRefreshed || null; + } + + /** + * Utility: sleep for specified milliseconds + */ + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} diff --git a/apps/web/hooks/useRecommendation.ts b/apps/web/hooks/useRecommendation.ts new file mode 100644 index 00000000..852d5335 --- /dev/null +++ b/apps/web/hooks/useRecommendation.ts @@ -0,0 +1,148 @@ +/** + * useRecommendation Hook + * + * React hook for getting smart bridge route recommendations. + * Integrates with the recommendation API endpoint. + */ + +import { useState, useCallback } from 'react'; + +/** + * User preference types + */ +export enum UserPreference { + FASTEST = 'fastest', + CHEAPEST = 'cheapest', + BALANCED = 'balanced', + MOST_RELIABLE = 'most-reliable', +} + +/** + * Route input type + */ +export interface RouteInput { + id: string; + bridgeName: string; + sourceChain: string; + destinationChain: string; + inputAmount: string; + outputAmount: string; + feeUsd: number; + gasCostUsd: number; + totalFeeUsd: number; + estimatedTimeSeconds: number; + reliabilityScore: number; + slippagePercent: number; + liquidityUsd: number; +} + +/** + * Recommendation result type + */ +export interface RecommendationResult { + rank: number; + bridge: string; + score: number; + confidence: string; + recommendation: string; + fee: number; + time: number; + reliability: number; +} + +/** + * Hook return type + */ +interface UseRecommendationReturn { + recommendations: RecommendationResult[]; + isLoading: boolean; + error: Error | null; + getRecommendations: ( + routes: RouteInput[], + preference: UserPreference, + options?: { + minReliability?: number; + maxFeeUsd?: number; + maxTimeSeconds?: number; + }, + ) => Promise; + clearRecommendations: () => void; +} + +/** + * Use recommendation hook + */ +export function useRecommendation(): UseRecommendationReturn { + const [recommendations, setRecommendations] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + /** + * Get recommendations from API + */ + const getRecommendations = useCallback( + async ( + routes: RouteInput[], + preference: UserPreference, + options?: { + minReliability?: number; + maxFeeUsd?: number; + maxTimeSeconds?: number; + }, + ) => { + if (!routes || routes.length === 0) { + setRecommendations([]); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetch('/api/recommendations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + routes, + preference, + minReliability: options?.minReliability, + maxFeeUsd: options?.maxFeeUsd, + maxTimeSeconds: options?.maxTimeSeconds, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + setRecommendations(data.recommendations || []); + } catch (err) { + setError(err as Error); + setRecommendations([]); + } finally { + setIsLoading(false); + } + }, + [], + ); + + /** + * Clear recommendations + */ + const clearRecommendations = useCallback(() => { + setRecommendations([]); + setError(null); + setIsLoading(false); + }, []); + + return { + recommendations, + isLoading, + error, + getRecommendations, + clearRecommendations, + }; +} diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index a12c0c2d..61f246ff 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -9,9 +9,11 @@ "version": "0.1.0", "dependencies": { "@bridgewise/ui-components": "file:../../packages/ui", + "i18next": "^23.0.0", "next": "16.1.4", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "react-i18next": "^14.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -43,9 +45,17 @@ "name": "@bridgewise/ui-components", "version": "0.1.0", "devDependencies": { + "@storybook/addon-docs": "^8.6.14", + "@storybook/addon-essentials": "^8.6.14", + "@storybook/addon-interactions": "^8.6.14", + "@storybook/blocks": "^8.6.14", + "@storybook/react": "^8.6.14", + "@storybook/react-vite": "^8.6.14", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "typescript": "^5.0.0" + "storybook": "^8.6.18", + "typescript": "^5.0.0", + "vite": "^8.0.10" }, "peerDependencies": { "react": "^19.0.0", @@ -257,6 +267,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -3939,6 +3958,38 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "23.16.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", + "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5448,6 +5499,28 @@ "react": "^19.2.3" } }, + "node_modules/react-i18next": { + "version": "14.1.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.3.tgz", + "integrity": "sha512-wZnpfunU6UIAiJ+bxwOiTmBOAaB14ha97MjOEnLGac2RJ+h/maIYXZuTHlmyqQVX1UVHmU1YDTQ5vxLmwfXTjw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6425,6 +6498,15 @@ "punycode": "^2.1.0" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 6a3132a5..d7b74c9c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,9 +10,11 @@ }, "dependencies": { "@bridgewise/ui-components": "file:../../packages/ui", + "i18next": "^23.0.0", "next": "16.1.4", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "react-i18next": "^14.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/docs/QUICK_REFERENCE_NEW_FEATURES.md b/docs/QUICK_REFERENCE_NEW_FEATURES.md new file mode 100644 index 00000000..c8de5a30 --- /dev/null +++ b/docs/QUICK_REFERENCE_NEW_FEATURES.md @@ -0,0 +1,242 @@ +# Quick Reference: New Features + +## 1. Chain Validation 📍 + +**Purpose**: Prevent invalid chain pair selections + +**File**: `src/validators/chain.validator.ts` + +### Quick Usage + +```typescript +// Inject the validator +constructor(private chainValidator: ChainValidator) {} + +// Validate before bridge transaction +try { + chainValidator.validateChainPair(1, 137); // ETH → Polygon +} catch (error) { + console.error('Invalid route:', error.message); +} + +// Get detailed validation result +const result = chainValidator.validateChainPairComprehensive(1, 137); +console.log(result.supportedBridges); +// ['Stargate', 'Hop Protocol', 'Across Protocol', 'Synapse'] + +// Check if chain exists +chainValidator.isChainSupported(1); // true + +// Get available bridges +chainValidator.getAvailableBridges(); +``` + +### Supported Chains +- Ethereum (1), Polygon (137), BSC (56) +- Arbitrum (42161), Optimism (10), Base (8453) +- Avalanche (43114), Gnosis (100) + +--- + +## 2. Smart Route Recommendation 🧭 + +**Purpose**: Suggest best routes based on preferences + +**Files**: +- Service: `apps/api/src/services/recommendation.ts` +- Controller: `apps/api/src/bridge-recommendation/recommendation.controller.ts` +- Hook: `apps/web/hooks/useRecommendation.ts` + +### Quick Usage (Frontend) + +```typescript +import { useRecommendation, UserPreference } from '@/hooks/useRecommendation'; + +const { recommendations, getRecommendations } = useRecommendation(); + +// Get fastest route +await getRecommendations(routes, UserPreference.FASTEST); + +// Get cheapest route +await getRecommendations(routes, UserPreference.CHEAPEST); + +// With constraints +await getRecommendations(routes, UserPreference.BALANCED, { + maxFeeUsd: 10, + minReliability: 80, +}); +``` + +### Quick Usage (Backend) + +```typescript +import { SmartRecommendationService, UserPreference } from '@/services/recommendation'; + +const results = recommendationService.recommend({ + routes, + preference: UserPreference.FASTEST, // or CHEAPEST, BALANCED, MOST_RELIABLE +}); + +console.log(results[0].recommendation); +// "⚡ Best speed: 120s estimated time with Stargate" +``` + +### API Endpoint + +```bash +POST /api/recommendations +{ + "routes": [...], + "preference": "fastest" +} +``` + +--- + +## 3. Auto Refresh Rates 🔄 + +**Purpose**: Keep fees and speeds updated in real-time + +**Files**: +- Service: `apps/api/src/services/refresh.ts` +- Component: `libs/ui-components/src/components/RefreshStatus.tsx` + +### Quick Usage (Backend) + +```typescript +import { AutoRefreshService, RefreshDataType } from '@/services/refresh'; + +// Set up data fetcher +refreshService.setFetcher(async (dataType) => { + if (dataType === RefreshDataType.FEES) { + return await fetchFees(); + } +}); + +// Start auto-refresh (every 15 seconds by default) +await refreshService.start(RefreshDataType.FEES); +await refreshService.start(RefreshDataType.QUOTES); + +// Manual refresh +await refreshService.refresh(RefreshDataType.FEES); + +// Update interval +refreshService.updateConfig(RefreshDataType.FEES, { + intervalMs: 10000, // 10 seconds +}); + +// Pause/resume +refreshService.pause(); +refreshService.resume(); +``` + +### Quick Usage (Frontend) + +```tsx +import RefreshStatus from '@bridgewise/ui/RefreshStatus'; + + +``` + +### Default Intervals +- **Fees/Quotes**: 15 seconds +- **Speeds**: 30 seconds +- **Liquidity**: 1 minute +- **Reliability**: 5 minutes + +--- + +## Integration Example + +```typescript +// Complete flow combining all 3 features + +class BridgeService { + constructor( + private chainValidator: ChainValidator, + private recommendationService: SmartRecommendationService, + private refreshService: AutoRefreshService, + ) {} + + async findBestRoute(params: { + fromChain: number; + toChain: number; + routes: RouteInput[]; + preference: UserPreference; + }) { + // 1. Validate chain pair + const validation = this.chainValidator.validateChainPairComprehensive( + params.fromChain, + params.toChain, + ); + + if (!validation.isValid) { + throw new Error(`Invalid route: ${validation.errors.join(', ')}`); + } + + // 2. Get recommendations + const recommendations = this.recommendationService.recommend({ + routes: params.routes, + preference: params.preference, + }); + + return { + validation, + bestRoute: recommendations[0], + alternatives: recommendations.slice(1, 3), + }; + } + + // Start real-time updates + startLiveUpdates() { + this.refreshService.start(RefreshDataType.FEES); + this.refreshService.start(RefreshDataType.QUOTES); + } +} +``` + +--- + +## Testing Commands + +```bash +# Run unit tests +npm test + +# Test chain validation +npm test -- chain.validator.spec.ts + +# Test recommendations +npm test -- recommendation.spec.ts + +# Test auto-refresh +npm test -- refresh.spec.ts +``` + +--- + +## Common Issues + +### Chain Validation +**Problem**: "Invalid chain pair" error +**Solution**: Check supported chains with `getSupportedChains()` + +### Recommendations +**Problem**: No recommendations returned +**Solution**: Ensure routes have all required fields (fee, time, reliability) + +### Auto Refresh +**Problem**: Data not refreshing +**Solution**: Check that `setFetcher()` is called before `start()` + +--- + +## Need Help? + +See full documentation: `docs/FEATURE_IMPLEMENTATION_SUMMARY.md` diff --git a/libs/ui-components/src/components/RefreshStatus.tsx b/libs/ui-components/src/components/RefreshStatus.tsx new file mode 100644 index 00000000..1b16b362 --- /dev/null +++ b/libs/ui-components/src/components/RefreshStatus.tsx @@ -0,0 +1,205 @@ +/** + * Refresh Status Component + * + * Displays auto-refresh status, last update time, and manual refresh button. + * Provides real-time visual feedback for data freshness. + * + * Implementation Scope: libs/ui-components/src/ + */ + +import React, { useState, useEffect, useCallback } from 'react'; + +/** + * Refresh status props + */ +export interface RefreshStatusProps { + /** Last refresh timestamp */ + lastRefreshed: Date | null; + /** Whether refresh is in progress */ + isRefreshing: boolean; + /** Refresh interval in milliseconds */ + intervalMs?: number; + /** Manual refresh callback */ + onRefresh?: () => Promise; + /** Show countdown timer */ + showCountdown?: boolean; + /** Compact mode */ + compact?: boolean; +} + +/** + * Refresh Status Component - Shows refresh state and controls + */ +const RefreshStatus: React.FC = ({ + lastRefreshed, + isRefreshing, + intervalMs = 15000, + onRefresh, + showCountdown = true, + compact = false, +}) => { + const [countdown, setCountdown] = useState(intervalMs / 1000); + const [timeAgo, setTimeAgo] = useState(''); + + // Update countdown timer + useEffect(() => { + if (!showCountdown || isRefreshing) return; + + const timer = setInterval(() => { + setCountdown(prev => { + if (prev <= 1) return intervalMs / 1000; + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [showCountdown, isRefreshing, intervalMs, lastRefreshed]); + + // Update time ago text + useEffect(() => { + if (!lastRefreshed) { + setTimeAgo('Never'); + return; + } + + const update = () => { + const seconds = Math.floor((Date.now() - lastRefreshed.getTime()) / 1000); + + if (seconds < 5) setTimeAgo('Just now'); + else if (seconds < 60) setTimeAgo(`${seconds}s ago`); + else if (seconds < 3600) setTimeAgo(`${Math.floor(seconds / 60)}m ago`); + else setTimeAgo(`${Math.floor(seconds / 3600)}h ago`); + }; + + update(); + const timer = setInterval(update, 5000); + return () => clearInterval(timer); + }, [lastRefreshed]); + + // Handle manual refresh + const handleRefresh = useCallback(async () => { + if (onRefresh && !isRefreshing) { + try { + await onRefresh(); + setCountdown(intervalMs / 1000); + } catch (error) { + console.error('Refresh failed:', error); + } + } + }, [onRefresh, isRefreshing, intervalMs]); + + if (compact) { + return ( +
+ {isRefreshing ? ( + + + Updating... + + ) : ( + + + {timeAgo} + + )} +
+ ); + } + + return ( +
+ {/* Status indicator */} +
+ {isRefreshing ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ + {/* Status text */} +
+
+ {isRefreshing ? 'Updating...' : 'Live Data'} +
+
+ {lastRefreshed ? ( + <>Last updated: {timeAgo} + ) : ( + <>Waiting for data... + )} +
+
+ + {/* Countdown and refresh button */} +
+ {showCountdown && !isRefreshing && ( +
+ Refresh in {countdown}s +
+ )} + + {onRefresh && ( + + )} +
+
+ ); +}; + +/** + * Refresh Icon SVG + */ +function RefreshIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +/** + * Check Icon SVG + */ +function CheckIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export default RefreshStatus; diff --git a/src/validators/chain.validator.ts b/src/validators/chain.validator.ts index c7699635..d86bde03 100644 --- a/src/validators/chain.validator.ts +++ b/src/validators/chain.validator.ts @@ -1,34 +1,304 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; + +/** + * Supported chain configurations with detailed metadata + */ +export interface ChainConfig { + chainId: number; + name: string; + symbol: string; + isEVM: boolean; + rpcUrl?: string; + explorerUrl?: string; + nativeCurrency: { + name: string; + symbol: string; + decimals: number; + }; +} + +/** + * Bridge provider compatibility configuration + */ +export interface BridgeCompatibility { + bridgeName: string; + supportedPairs: Array<[number, number]>; // [fromChainId, toChainId] + isAvailable: boolean; + maintenanceMode?: boolean; +} /** * Error thrown when an invalid chain pair is provided for bridging. */ export class InvalidChainPairError extends Error { - constructor(fromChainId: number, toChainId: number) { - super(`Invalid chain pair: ${fromChainId} -> ${toChainId}`); + constructor( + fromChainId: number | string, + toChainId: number | string, + public reason?: string, + ) { + super(`Invalid chain pair: ${fromChainId} -> ${toChainId}${reason ? ` (${reason})` : ''}`); this.name = 'InvalidChainPairError'; } } /** - * Validator for chain pairs in cross-chain transfers. - * In a real application, the supported chains and compatible pairs would likely come from a configuration file or database. + * Validation result with detailed error information + */ +export interface ChainValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; + supportedBridges: string[]; + alternativeRoutes?: Array<{ from: number | string; to: number | string; bridge: string }>; +} + +/** + * Enhanced validator for chain pairs in cross-chain transfers. + * Validates chain support, bridge compatibility, and provides alternative route suggestions. */ @Injectable() export class ChainValidator { - // Example supported chain IDs (these would typically come from config) - private readonly supportedChains = new Set([ - 1, // Ethereum Mainnet - 137, // Polygon - 56, // Binance Smart Chain - 42161, // Arbitrum One - 10, // Optimism - // Add more as needed + private readonly logger = new Logger(ChainValidator.name); + + // Comprehensive chain registry with metadata + private readonly chainRegistry: Map = new Map([ + [1, { + chainId: 1, + name: 'Ethereum', + symbol: 'ETH', + isEVM: true, + explorerUrl: 'https://etherscan.io', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + }], + [137, { + chainId: 137, + name: 'Polygon', + symbol: 'MATIC', + isEVM: true, + explorerUrl: 'https://polygonscan.com', + nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 }, + }], + [56, { + chainId: 56, + name: 'BNB Smart Chain', + symbol: 'BNB', + isEVM: true, + explorerUrl: 'https://bscscan.com', + nativeCurrency: { name: 'BNB', symbol: 'BNB', decimals: 18 }, + }], + [42161, { + chainId: 42161, + name: 'Arbitrum One', + symbol: 'ARB', + isEVM: true, + explorerUrl: 'https://arbiscan.io', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + }], + [10, { + chainId: 10, + name: 'Optimism', + symbol: 'OP', + isEVM: true, + explorerUrl: 'https://optimistic.etherscan.io', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + }], + [8453, { + chainId: 8453, + name: 'Base', + symbol: 'BASE', + isEVM: true, + explorerUrl: 'https://basescan.org', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + }], + [43114, { + chainId: 43114, + name: 'Avalanche C-Chain', + symbol: 'AVAX', + isEVM: true, + explorerUrl: 'https://snowtrace.io', + nativeCurrency: { name: 'Avalanche', symbol: 'AVAX', decimals: 18 }, + }], + [100, { + chainId: 100, + name: 'Gnosis Chain', + symbol: 'xDAI', + isEVM: true, + explorerUrl: 'https://gnosisscan.io', + nativeCurrency: { name: 'xDAI', symbol: 'xDAI', decimals: 18 }, + }], ]); - // Example of incompatible pairs (if any). For simplicity, we assume all supported chains can bridge to each other. - // If there were restrictions, we could maintain a set of incompatible pairs or a compatibility matrix. - // private readonly incompatiblePairs = new Set([/* e.g., `${fromChainId}-${toChainId}` */]); + // Bridge compatibility matrix + private readonly bridgeCompatibilities: BridgeCompatibility[] = [ + { + bridgeName: 'Stargate', + supportedPairs: [ + [1, 137], [1, 56], [1, 42161], [1, 10], [1, 8453], [1, 43114], + [137, 1], [137, 56], [137, 42161], [137, 10], [137, 8453], + [56, 1], [56, 137], [56, 42161], [56, 10], [56, 43114], + [42161, 1], [42161, 137], [42161, 10], [42161, 8453], + [10, 1], [10, 137], [10, 42161], [10, 8453], + [8453, 1], [8453, 42161], [8453, 10], + [43114, 1], [43114, 56], + ], + isAvailable: true, + }, + { + bridgeName: 'Hop Protocol', + supportedPairs: [ + [1, 137], [1, 42161], [1, 10], [1, 8453], [1, 100], + [137, 1], [137, 42161], [137, 10], [137, 8453], + [42161, 1], [42161, 137], [42161, 10], [42161, 8453], + [10, 1], [10, 137], [10, 42161], [10, 8453], + [8453, 1], [8453, 137], [8453, 42161], [8453, 10], + ], + isAvailable: true, + }, + { + bridgeName: 'Across Protocol', + supportedPairs: [ + [1, 137], [1, 42161], [1, 10], [1, 8453], + [137, 1], [137, 42161], [137, 10], + [42161, 1], [42161, 137], [42161, 10], [42161, 8453], + [10, 1], [10, 137], [10, 42161], [10, 8453], + [8453, 1], [8453, 42161], [8453, 10], + ], + isAvailable: true, + }, + { + bridgeName: 'Synapse', + supportedPairs: [ + [1, 137], [1, 56], [1, 42161], [1, 10], [1, 43114], + [137, 1], [137, 56], [137, 42161], [137, 10], [137, 43114], + [56, 1], [56, 137], [56, 42161], [56, 43114], + [42161, 1], [42161, 137], [42161, 10], + [10, 1], [10, 137], [10, 42161], + [43114, 1], [43114, 137], [43114, 56], + ], + isAvailable: true, + }, + ]; + + /** + * Validate chain pair and return comprehensive validation result + */ + validateChainPairComprehensive( + fromChainId: number | string, + toChainId: number | string, + ): ChainValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Convert string chain IDs to numbers if needed + const fromId = typeof fromChainId === 'string' ? parseInt(fromChainId) : fromChainId; + const toId = typeof toChainId === 'string' ? parseInt(toChainId) : toChainId; + + // Check if chains exist in registry + const fromChain = this.chainRegistry.get(fromId); + const toChain = this.chainRegistry.get(toId); + + if (!fromChain) { + errors.push(`Source chain ID ${fromChainId} is not supported`); + } + + if (!toChain) { + errors.push(`Destination chain ID ${toChainId} is not supported`); + } + + // Check for same-chain transfer + if (fromId === toId) { + errors.push('Source and destination chains must be different'); + } + + // Find bridges that support this pair + const supportedBridges = this.getSupportedBridgesForPair(fromId, toId); + + if (supportedBridges.length === 0 && errors.length === 0) { + errors.push(`No bridge supports the route ${fromChain?.name || fromId} → ${toChain?.name || toId}`); + + // Suggest alternative routes + const alternatives = this.findAlternativeRoutes(fromId, toId); + if (alternatives.length > 0) { + warnings.push('Consider these alternative routes'); + } + + return { + isValid: false, + errors, + warnings, + supportedBridges, + alternativeRoutes: alternatives, + }; + } + + // Check for bridge maintenance + const maintenanceBridges = supportedBridges.filter(bridge => { + const compat = this.bridgeCompatibilities.find(b => b.bridgeName === bridge); + return compat?.maintenanceMode; + }); + + if (maintenanceBridges.length > 0) { + warnings.push(`These bridges are in maintenance mode: ${maintenanceBridges.join(', ')}`); + } + + // Log successful validation + if (errors.length === 0) { + this.logger.debug( + `Chain pair validated: ${fromChain?.name} (${fromId}) → ${toChain?.name} (${toId}), ` + + `Available bridges: ${supportedBridges.join(', ')}`, + ); + } + + return { + isValid: errors.length === 0, + errors, + warnings, + supportedBridges, + }; + } + + /** + * Get all bridges supporting a specific chain pair + */ + getSupportedBridgesForPair(fromChainId: number, toChainId: number): string[] { + return this.bridgeCompatibilities + .filter(bridge => { + if (!bridge.isAvailable) return false; + return bridge.supportedPairs.some( + ([from, to]) => from === fromChainId && to === toChainId, + ); + }) + .map(bridge => bridge.bridgeName); + } + + /** + * Find alternative routes when direct route is not available + */ + private findAlternativeRoutes( + fromChainId: number, + toChainId: number, + ): Array<{ from: number; to: number; bridge: string }> { + const alternatives: Array<{ from: number; to: number; bridge: string }> = []; + + // Check reverse route + this.bridgeCompatibilities.forEach(bridge => { + if (!bridge.isAvailable) return; + + const hasReverse = bridge.supportedPairs.some( + ([from, to]) => from === toChainId && to === fromChainId, + ); + + if (hasReverse) { + alternatives.push({ + from: toChainId, + to: fromChainId, + bridge: bridge.bridgeName, + }); + } + }); + + return alternatives.slice(0, 3); // Return max 3 alternatives + } /** * Validates that the given chain pair is supported for bridging. @@ -37,25 +307,12 @@ export class ChainValidator { * @throws InvalidChainPairError if the chain pair is not supported */ validateChainPair(fromChainId: number, toChainId: number): void { - // Check if both chains are supported - if (!this.supportedChains.has(fromChainId)) { - throw new InvalidChainPairError(fromChainId, toChainId); - } + const result = this.validateChainPairComprehensive(fromChainId, toChainId); - if (!this.supportedChains.has(toChainId)) { - throw new InvalidChainPairError(fromChainId, toChainId); + if (!result.isValid) { + const reason = result.errors.join('; '); + throw new InvalidChainPairError(fromChainId, toChainId, reason); } - - // Optional: Check for same-chain transfers (if not allowed for bridging) - if (fromChainId === toChainId) { - throw new InvalidChainPairError(fromChainId, toChainId); - } - - // Optional: Check for specific incompatible pairs - // const pairKey = `${fromChainId}-${toChainId}`; - // if (this.incompatiblePairs.has(pairKey)) { - // throw new InvalidChainPairError(fromChainId, toChainId); - // } } /** @@ -64,7 +321,7 @@ export class ChainValidator { * @returns true if the chain is supported, false otherwise */ isChainSupported(chainId: number): boolean { - return this.supportedChains.has(chainId); + return this.chainRegistry.has(chainId); } /** @@ -72,6 +329,44 @@ export class ChainValidator { * @returns An array of supported chain IDs */ getSupportedChains(): number[] { - return Array.from(this.supportedChains); + return Array.from(this.chainRegistry.keys()); + } + + /** + * Get chain configuration by ID + */ + getChainConfig(chainId: number): ChainConfig | undefined { + return this.chainRegistry.get(chainId); + } + + /** + * Get all available bridge providers + */ + getAvailableBridges(): string[] { + return this.bridgeCompatibilities + .filter(b => b.isAvailable) + .map(b => b.bridgeName); + } + + /** + * Update bridge availability status + */ + setBridgeAvailability(bridgeName: string, isAvailable: boolean): void { + const bridge = this.bridgeCompatibilities.find(b => b.bridgeName === bridgeName); + if (bridge) { + bridge.isAvailable = isAvailable; + this.logger.log(`Bridge ${bridgeName} availability updated: ${isAvailable}`); + } + } + + /** + * Set bridge maintenance mode + */ + setBridgeMaintenance(bridgeName: string, maintenanceMode: boolean): void { + const bridge = this.bridgeCompatibilities.find(b => b.bridgeName === bridgeName); + if (bridge) { + bridge.maintenanceMode = maintenanceMode; + this.logger.log(`Bridge ${bridgeName} maintenance mode: ${maintenanceMode}`); + } } }