diff --git a/backend/monitoring/rpcMetrics.ts b/backend/monitoring/rpcMetrics.ts new file mode 100644 index 00000000..b6a33106 --- /dev/null +++ b/backend/monitoring/rpcMetrics.ts @@ -0,0 +1,50 @@ +export interface RpcMetricsData { + endpoint: string; + latencyMs: number; + errorCount: number; + successCount: number; + circuitState: 'CLOSED' | 'OPEN' | 'HALF_OPEN'; + lastFailureTime: number | null; +} + +class RpcMetricsRegistry { + private metrics: Map = new Map(); + + private getOrInit(endpoint: string): RpcMetricsData { + if (!this.metrics.has(endpoint)) { + this.metrics.set(endpoint, { + endpoint, + latencyMs: 0, + errorCount: 0, + successCount: 0, + circuitState: 'CLOSED', + lastFailureTime: null, + }); + } + return this.metrics.get(endpoint)!; + } + + recordLatency(endpoint: string, latencyMs: number) { + const data = this.getOrInit(endpoint); + // Simple Exponential Moving Average + data.latencyMs = data.latencyMs === 0 ? latencyMs : data.latencyMs * 0.8 + latencyMs * 0.2; + data.successCount++; + } + + recordError(endpoint: string) { + const data = this.getOrInit(endpoint); + data.errorCount++; + data.lastFailureTime = Date.now(); + } + + updateCircuitState(endpoint: string, state: 'CLOSED' | 'OPEN' | 'HALF_OPEN') { + const data = this.getOrInit(endpoint); + data.circuitState = state; + } + + getHealthStatus(): RpcMetricsData[] { + return Array.from(this.metrics.values()); + } +} + +export const rpcMetrics = new RpcMetricsRegistry(); diff --git a/backend/services/shared/MonitoringJsonRpcProvider.ts b/backend/services/shared/MonitoringJsonRpcProvider.ts new file mode 100644 index 00000000..036922a1 --- /dev/null +++ b/backend/services/shared/MonitoringJsonRpcProvider.ts @@ -0,0 +1,66 @@ +import { ethers } from 'ethers'; +import { CircuitBreaker, executeWithTimeout } from './rpcResilience'; +import { rpcMetrics } from '../../monitoring/rpcMetrics'; + +export interface MonitoringJsonRpcProviderOptions { + timeoutMs?: number; + failureThreshold?: number; + resetTimeoutMs?: number; +} + +export class MonitoringJsonRpcProvider extends ethers.providers.JsonRpcProvider { + private fallbackUrls: string[]; + private currentUrlIndex = 0; + private circuitBreakers: Map = new Map(); + private timeoutMs: number; + + constructor(urls: string | string[], network?: ethers.providers.Networkish, options?: MonitoringJsonRpcProviderOptions) { + const urlArray = Array.isArray(urls) ? urls : [urls]; + super(urlArray[0], network); + this.fallbackUrls = urlArray; + + this.timeoutMs = options?.timeoutMs ?? 5000; + const failureThreshold = options?.failureThreshold ?? 3; + const resetTimeoutMs = options?.resetTimeoutMs ?? 30000; + + for (const url of this.fallbackUrls) { + this.circuitBreakers.set(url, new CircuitBreaker(url, failureThreshold, resetTimeoutMs)); + } + } + + // Override connection to always use the current active URL + get connection() { + const conn = super.connection; + conn.url = this.fallbackUrls[this.currentUrlIndex]; + return conn; + } + + async send(method: string, params: Array): Promise { + const originalIndex = this.currentUrlIndex; + let attemptCount = 0; + + while (attemptCount < this.fallbackUrls.length) { + const url = this.fallbackUrls[this.currentUrlIndex]; + const cb = this.circuitBreakers.get(url)!; + + const startTime = Date.now(); + try { + const result = await cb.execute(() => + executeWithTimeout(super.send(method, params), this.timeoutMs) + ); + const latency = Date.now() - startTime; + rpcMetrics.recordLatency(url, latency); + return result; + } catch (error: any) { + // If CircuitBreaker is OPEN or request failed/timed out, try next fallback + attemptCount++; + this.currentUrlIndex = (this.currentUrlIndex + 1) % this.fallbackUrls.length; + + // If we exhausted all fallbacks, throw the error + if (attemptCount === this.fallbackUrls.length) { + throw new Error(`All RPC endpoints failed. Last error: ${error.message}`); + } + } + } + } +} diff --git a/backend/services/shared/rpcResilience.ts b/backend/services/shared/rpcResilience.ts new file mode 100644 index 00000000..4dcfedb1 --- /dev/null +++ b/backend/services/shared/rpcResilience.ts @@ -0,0 +1,78 @@ +import { rpcMetrics } from '../../monitoring/rpcMetrics'; + +export class CircuitBreaker { + private endpoint: string; + private failureThreshold: number; + private resetTimeoutMs: number; + + private failures = 0; + private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED'; + private nextAttemptTime = 0; + + constructor(endpoint: string, failureThreshold = 5, resetTimeoutMs = 30000) { + this.endpoint = endpoint; + this.failureThreshold = failureThreshold; + this.resetTimeoutMs = resetTimeoutMs; + } + + async execute(action: () => Promise): Promise { + if (this.state === 'OPEN') { + if (Date.now() > this.nextAttemptTime) { + this.state = 'HALF_OPEN'; + rpcMetrics.updateCircuitState(this.endpoint, 'HALF_OPEN'); + } else { + throw new Error(`CircuitBreaker OPEN for endpoint: ${this.endpoint}`); + } + } + + try { + const result = await action(); + this.onSuccess(); + return result; + } catch (error) { + this.onFailure(); + throw error; + } + } + + private onSuccess() { + this.failures = 0; + if (this.state === 'HALF_OPEN') { + this.state = 'CLOSED'; + rpcMetrics.updateCircuitState(this.endpoint, 'CLOSED'); + } + } + + private onFailure() { + this.failures++; + rpcMetrics.recordError(this.endpoint); + if (this.failures >= this.failureThreshold) { + this.state = 'OPEN'; + this.nextAttemptTime = Date.now() + this.resetTimeoutMs; + rpcMetrics.updateCircuitState(this.endpoint, 'OPEN'); + } else if (this.state === 'HALF_OPEN') { + // If it fails during half-open, immediately re-open + this.state = 'OPEN'; + this.nextAttemptTime = Date.now() + this.resetTimeoutMs; + rpcMetrics.updateCircuitState(this.endpoint, 'OPEN'); + } + } +} + +export function executeWithTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Operation timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); +} diff --git a/developer-portal/components/RpcHealthDashboard.tsx b/developer-portal/components/RpcHealthDashboard.tsx new file mode 100644 index 00000000..8def6b10 --- /dev/null +++ b/developer-portal/components/RpcHealthDashboard.tsx @@ -0,0 +1,171 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, StyleSheet, ScrollView } from 'react-native'; + +// Mocking backend response for demonstration in frontend +interface RpcMetricsData { + endpoint: string; + latencyMs: number; + errorCount: number; + successCount: number; + circuitState: 'CLOSED' | 'OPEN' | 'HALF_OPEN'; + lastFailureTime: number | null; +} + +export const RpcHealthDashboard: React.FC = () => { + const [metrics, setMetrics] = useState([]); + + useEffect(() => { + // In a real implementation, this would fetch from an API like /api/v1/monitoring/rpc + // For now, we mock the data to demonstrate the UI + const mockData: RpcMetricsData[] = [ + { + endpoint: 'https://cloudflare-eth.com', + latencyMs: 145, + errorCount: 2, + successCount: 15430, + circuitState: 'CLOSED', + lastFailureTime: null, + }, + { + endpoint: 'https://rpc.ankr.com/eth', + latencyMs: 0, + errorCount: 0, + successCount: 0, + circuitState: 'CLOSED', + lastFailureTime: null, + }, + { + endpoint: 'https://polygon-rpc.com', + latencyMs: 1205, + errorCount: 5, + successCount: 432, + circuitState: 'OPEN', + lastFailureTime: Date.now() - 5000, + }, + { + endpoint: 'https://rpc.ankr.com/polygon', + latencyMs: 85, + errorCount: 0, + successCount: 12, + circuitState: 'CLOSED', + lastFailureTime: null, + } + ]; + setMetrics(mockData); + + const interval = setInterval(() => { + // Simulate real-time updates + setMetrics(prev => prev.map(m => { + if (m.circuitState === 'CLOSED' && m.successCount > 0) { + return { ...m, latencyMs: m.latencyMs + (Math.random() * 10 - 5), successCount: m.successCount + 1 }; + } + return m; + })); + }, 2000); + + return () => clearInterval(interval); + }, []); + + const getStatusColor = (state: string) => { + switch (state) { + case 'CLOSED': return '#22C55E'; + case 'OPEN': return '#EF4444'; + case 'HALF_OPEN': return '#F59E0B'; + default: return '#6B7280'; + } + }; + + return ( + + + RPC Health Monitor + Real-time circuit breaker status and latency + + + + + Endpoint + Latency + Errors + Status + + {metrics.map((item, index) => ( + + {item.endpoint} + {Math.round(item.latencyMs)} ms + {item.errorCount} + + + + {item.circuitState} + + + + ))} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#FFFFFF', + marginTop: 16, + paddingVertical: 16, + borderTopWidth: 1, + borderBottomWidth: 1, + borderColor: '#E5E7EB', + }, + header: { + paddingHorizontal: 16, + marginBottom: 12, + }, + title: { + fontSize: 18, + fontWeight: '600', + color: '#111827', + }, + subtitle: { + fontSize: 14, + color: '#6B7280', + marginTop: 4, + }, + table: { + minWidth: 600, + paddingHorizontal: 16, + }, + tableHeader: { + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: '#E5E7EB', + paddingBottom: 8, + marginBottom: 8, + }, + tableRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: '#F9FAFB', + }, + cell: { + flex: 1, + fontSize: 14, + color: '#374151', + }, + headerCell: { + fontWeight: '600', + color: '#6B7280', + }, + statusContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 6, + }, +}); diff --git a/developer-portal/pages/DashboardPage.tsx b/developer-portal/pages/DashboardPage.tsx index 86b5126a..58fad45b 100644 --- a/developer-portal/pages/DashboardPage.tsx +++ b/developer-portal/pages/DashboardPage.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, RefreshControl } from 'react-native'; +import { RpcHealthDashboard } from '../components/RpcHealthDashboard'; interface DashboardStats { totalRequests: number; @@ -225,6 +226,8 @@ export const DashboardPage: React.FC = ({ onNavigate }) => { ))} + + ); }; diff --git a/src/config/evm.ts b/src/config/evm.ts index 4c9bd67d..1d0394cb 100644 --- a/src/config/evm.ts +++ b/src/config/evm.ts @@ -2,18 +2,18 @@ * Public RPC endpoints for read-only calls and Superfluid Framework initialization. * Keep aligned with `App.tsx` chain definitions. */ -export const EVM_RPC_URLS: Record = { - 1: 'https://cloudflare-eth.com', - 137: 'https://polygon-rpc.com', - 42161: 'https://arb1.arbitrum.io/rpc', - 10: 'https://mainnet.optimism.io', - 8453: 'https://mainnet.base.org', +export const EVM_RPC_URLS: Record = { + 1: ['https://cloudflare-eth.com', 'https://rpc.ankr.com/eth', 'https://eth.llamarpc.com'], + 137: ['https://polygon-rpc.com', 'https://rpc.ankr.com/polygon'], + 42161: ['https://arb1.arbitrum.io/rpc', 'https://rpc.ankr.com/arbitrum'], + 10: ['https://mainnet.optimism.io', 'https://rpc.ankr.com/optimism'], + 8453: ['https://mainnet.base.org', 'https://developer-access-mainnet.base.org'], }; -export function getEvmRpcUrl(chainId: number): string { - const url = EVM_RPC_URLS[chainId]; - if (!url) { +export function getEvmRpcUrls(chainId: number): string[] { + const urls = EVM_RPC_URLS[chainId]; + if (!urls || urls.length === 0) { throw new Error(`No RPC configured for chain ${chainId}`); } - return url; + return urls; } diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 9634ae74..2d14d699 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -3,7 +3,8 @@ import { ethers } from 'ethers'; import { GasEstimate } from '../types/wallet'; import { NetworkError, NetworkErrorCode, ContractError, ContractErrorCode } from '../errors'; -import { getEvmRpcUrl } from '../config/evm'; +import { getEvmRpcUrls } from '../config/evm'; +import { MonitoringJsonRpcProvider } from '../../backend/services/shared/MonitoringJsonRpcProvider'; import { TokenService } from './tokenService'; import { GasService } from './gasService'; import { StreamService } from './streamService'; @@ -285,7 +286,7 @@ export class WalletServiceManager implements WalletServiceContext { } getProvider(chainId: number): ethers.providers.JsonRpcProvider { - return new ethers.providers.JsonRpcProvider(getEvmRpcUrl(chainId)); + return new MonitoringJsonRpcProvider(getEvmRpcUrls(chainId), chainId); } isConnected(): boolean {