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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions backend/monitoring/rpcMetrics.ts
Original file line number Diff line number Diff line change
@@ -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<string, RpcMetricsData> = 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();
66 changes: 66 additions & 0 deletions backend/services/shared/MonitoringJsonRpcProvider.ts
Original file line number Diff line number Diff line change
@@ -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<string, CircuitBreaker> = 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<any>): Promise<any> {
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}`);
}
}
}
}
}
78 changes: 78 additions & 0 deletions backend/services/shared/rpcResilience.ts
Original file line number Diff line number Diff line change
@@ -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<T>(action: () => Promise<T>): Promise<T> {
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<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
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);
});
});
}
171 changes: 171 additions & 0 deletions developer-portal/components/RpcHealthDashboard.tsx
Original file line number Diff line number Diff line change
@@ -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<RpcMetricsData[]>([]);

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 (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>RPC Health Monitor</Text>
<Text style={styles.subtitle}>Real-time circuit breaker status and latency</Text>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.cell, styles.headerCell, { flex: 2 }]}>Endpoint</Text>
<Text style={[styles.cell, styles.headerCell]}>Latency</Text>
<Text style={[styles.cell, styles.headerCell]}>Errors</Text>
<Text style={[styles.cell, styles.headerCell]}>Status</Text>
</View>
{metrics.map((item, index) => (
<View key={index} style={styles.tableRow}>
<Text style={[styles.cell, { flex: 2 }]} numberOfLines={1}>{item.endpoint}</Text>
<Text style={styles.cell}>{Math.round(item.latencyMs)} ms</Text>
<Text style={styles.cell}>{item.errorCount}</Text>
<View style={[styles.cell, styles.statusContainer]}>
<View style={[styles.statusDot, { backgroundColor: getStatusColor(item.circuitState) }]} />
<Text style={{ color: getStatusColor(item.circuitState), fontWeight: '600' }}>
{item.circuitState}
</Text>
</View>
</View>
))}
</View>
</ScrollView>
</View>
);
};

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,
},
});
20 changes: 10 additions & 10 deletions src/config/evm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,20 @@ import { ChainType } from '../types/wallet';
* Public RPC endpoints for read-only calls and Superfluid Framework initialization.
* Keep aligned with `App.tsx` chain definitions.
*/
export const EVM_RPC_URLS: Record<number, string> = {
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<number, string[]> = {
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;
}

/**
Expand Down
Loading