Skip to content
Closed
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
8 changes: 7 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { DashboardPage } from "./components/dashboard-page";
import { RpcDiagnostics } from "./components/rpc-diagnostics";

function App() {
return <DashboardPage />;
return (
<>
<DashboardPage />
<RpcDiagnostics />
</>
);
}

export default App;
72 changes: 72 additions & 0 deletions src/components/rpc-diagnostics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import { isLocalNode, RPC_URL, resolveRpcUrl } from "../constants/config";
import { rpcHealthCheck, type RpcHealthResult } from "../utils/rpc-health";

export function RpcDiagnostics() {
const [health, setHealth] = useState<RpcHealthResult | null>(null);
const [resolvedUrl, setResolvedUrl] = useState<string>(RPC_URL);
const [loading, setLoading] = useState(true);

useEffect(() => {
let cancelled = false;

async function check() {
const [healthResult, url] = await Promise.all([
rpcHealthCheck(RPC_URL, 2000),
resolveRpcUrl(2000),
]);
if (!cancelled) {
setHealth(healthResult);
setResolvedUrl(url);
setLoading(false);
Comment on lines +13 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Show health for the same URL you render.

Right now the panel displays resolvedUrl, but health comes from the original RPC_URL. If fallback kicks in, the panel can show the fallback URL with the failed primary’s unhealthy status.

🛠️ Suggested fix
-import { isLocalNode, RPC_URL, resolveRpcUrl } from "../constants/config";
-import { rpcHealthCheck, type RpcHealthResult } from "../utils/rpc-health";
+import { DEV_FALLBACK_RPCS, getHealthyRpcUrl, isLocalNode, RPC_URL } from "../constants/config";
+import type { RpcHealthResult } from "../utils/rpc-health";
@@
     async function check() {
-      const [healthResult, url] = await Promise.all([
-        rpcHealthCheck(RPC_URL, 2000),
-        resolveRpcUrl(2000),
-      ]);
+      const { url, health } = await getHealthyRpcUrl(RPC_URL, DEV_FALLBACK_RPCS, 2000);
       if (!cancelled) {
-        setHealth(healthResult);
+        setHealth(health);
         setResolvedUrl(url);
         setLoading(false);
       }
     }

}
}

check();
return () => {
cancelled = true;
};
}, []);

if (import.meta.env.PROD) return null;

return (
<div
style={{
position: "fixed",
bottom: 8,
right: 8,
padding: "12px 16px",
background: "#1a1a2e",
color: "#eee",
borderRadius: 8,
fontSize: 12,
fontFamily: "monospace",
zIndex: 9999,
maxWidth: 320,
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
}}
>
<div style={{ fontWeight: "bold", marginBottom: 6 }}>RPC Diagnostics</div>
{loading ? (
<div>Checking…</div>
) : (
<>
<div>Mode: {isLocalNode ? "local-node" : import.meta.env.MODE}</div>
<div>URL: {resolvedUrl}</div>
<div>
Status:{" "}
{health?.healthy ? (
<span style={{ color: "#4ade80" }}>Healthy</span>
) : (
<span style={{ color: "#f87171" }}>Unhealthy</span>
)}
</div>
{health?.chainId !== null && health?.chainId !== undefined && <div>Chain ID: {health.chainId.toString()}</div>}
{health?.latencyMs !== undefined && <div>Latency: {health.latencyMs}ms</div>}
{health?.error && <div style={{ color: "#f87171" }}>Error: {health.error}</div>}
</>
)}
</div>
);
}
100 changes: 97 additions & 3 deletions src/constants/config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,104 @@
import { rpcHealthCheck, type RpcHealthResult } from "../utils/rpc-health";

/**
* RPC endpoint for blockchain calls.
* - In development (including Deno Deploy preview links), it uses https://rpc.ubq.fi
* - In production, it uses /rpc for performance.
* - Supports VITE_RPC_URL override with validation and fallback to known-good endpoints.
*/
export const isLocalNode = import.meta.env.MODE === "local-node";

export const isDenoDeployHost = (hostname: string) => hostname.endsWith(".deno.dev") || hostname.endsWith(".deno.net");
const currentLocation = globalThis.location;
export const RPC_URL =
import.meta.env.VITE_RPC_URL || (isDenoDeployHost(currentLocation?.hostname ?? "") ? "https://rpc.ubq.fi" : `${currentLocation?.origin ?? ""}/rpc`);

const PUBLIC_FALLBACK_RPC = "https://rpc.ubq.fi";

export function isValidHttpUrl(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}

function getRawRpcUrl(): string {
const override = import.meta.env.VITE_RPC_URL;
const currentLocation = globalThis.location;

if (override) {
if (!isValidHttpUrl(override)) {
console.warn(
`[RPC Config] VITE_RPC_URL="${override}" is not a valid HTTP(S) URL. ` + `Falling back to default RPC.`
);
return PUBLIC_FALLBACK_RPC;
}
return override;
}

if (isDenoDeployHost(currentLocation?.hostname ?? "")) {
return PUBLIC_FALLBACK_RPC;
}

return `${currentLocation?.origin ?? ""}/rpc`;
Comment on lines +13 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore the local-node default RPC.

getRawRpcUrl() never special-cases local-node, so local builds resolve to ${location.origin}/rpc or the public fallback instead of Anvil on localhost:8545.

🛠️ Suggested fix
 const PUBLIC_FALLBACK_RPC = "https://rpc.ubq.fi";
+const LOCAL_NODE_RPC = "http://localhost:8545";
@@
   if (override) {
     if (!isValidHttpUrl(override)) {
       console.warn(
         `[RPC Config] VITE_RPC_URL="${override}" is not a valid HTTP(S) URL. ` + `Falling back to default RPC.`
       );
-      return PUBLIC_FALLBACK_RPC;
+      return isLocalNode ? LOCAL_NODE_RPC : PUBLIC_FALLBACK_RPC;
     }
     return override;
   }
+
+  if (isLocalNode) {
+    return LOCAL_NODE_RPC;
+  }
 
   if (isDenoDeployHost(currentLocation?.hostname ?? "")) {
     return PUBLIC_FALLBACK_RPC;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const PUBLIC_FALLBACK_RPC = "https://rpc.ubq.fi";
export function isValidHttpUrl(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}
function getRawRpcUrl(): string {
const override = import.meta.env.VITE_RPC_URL;
const currentLocation = globalThis.location;
if (override) {
if (!isValidHttpUrl(override)) {
console.warn(
`[RPC Config] VITE_RPC_URL="${override}" is not a valid HTTP(S) URL. ` + `Falling back to default RPC.`
);
return PUBLIC_FALLBACK_RPC;
}
return override;
}
if (isDenoDeployHost(currentLocation?.hostname ?? "")) {
return PUBLIC_FALLBACK_RPC;
}
return `${currentLocation?.origin ?? ""}/rpc`;
const PUBLIC_FALLBACK_RPC = "https://rpc.ubq.fi";
const LOCAL_NODE_RPC = "http://localhost:8545";
export function isValidHttpUrl(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}
function getRawRpcUrl(): string {
const override = import.meta.env.VITE_RPC_URL;
const currentLocation = globalThis.location;
if (override) {
if (!isValidHttpUrl(override)) {
console.warn(
`[RPC Config] VITE_RPC_URL="${override}" is not a valid HTTP(S) URL. ` + `Falling back to default RPC.`
);
return isLocalNode ? LOCAL_NODE_RPC : PUBLIC_FALLBACK_RPC;
}
return override;
}
if (isLocalNode) {
return LOCAL_NODE_RPC;
}
if (isDenoDeployHost(currentLocation?.hostname ?? "")) {
return PUBLIC_FALLBACK_RPC;
}
return `${currentLocation?.origin ?? ""}/rpc`;

}

/**
* Synchronous, backward-compatible RPC URL.
* In local-node mode this is the raw URL (no chain suffixing).
* In dev/prod it may be overridden by VITE_RPC_URL.
*/
export const RPC_URL = getRawRpcUrl();

/**
* Fallback RPC endpoints for mainnet when the primary fails.
*/
export const DEV_FALLBACK_RPCS: readonly string[] = [PUBLIC_FALLBACK_RPC];

/**
* Get a healthy RPC URL from a list, checking sequentially.
* Returns the first that responds within the timeout, or the last checked.
*/
export async function getHealthyRpcUrl(
primaryUrl: string,
fallbackUrls: readonly string[],
timeoutMs = 1500,
checkHealth = rpcHealthCheck
): Promise<{ url: string; health: RpcHealthResult }> {
const primaryHealth = await checkHealth(primaryUrl, timeoutMs);
if (primaryHealth.healthy) {
return { url: primaryUrl, health: primaryHealth };
}

if (!isLocalNode) {
console.warn(`[RPC Health] Primary RPC (${primaryUrl}) unhealthy: ${primaryHealth.error}. Trying fallbacks…`);
}

for (const fallback of fallbackUrls) {
if (fallback === primaryUrl) continue;
const health = await checkHealth(fallback, timeoutMs);
if (health.healthy) {
if (!isLocalNode) {
console.info(`[RPC Health] Fallback RPC (${fallback}) is healthy. Using it.`);
}
return { url: fallback, health };
}
}

if (!isLocalNode) {
console.error(`[RPC Health] All RPC endpoints failed. Last error: ${primaryHealth.error}`);
}
return { url: primaryUrl, health: primaryHealth };
}

/**
* Resolve the best available RPC URL.
* - In local-node mode, returns the raw RPC_URL without health checks.
* - In dev/prod, probes the primary and falls back to known-good endpoints.
*/
export async function resolveRpcUrl(timeoutMs = 1500, checkHealth = rpcHealthCheck): Promise<string> {
if (isLocalNode) {
return RPC_URL;
}
const { url } = await getHealthyRpcUrl(RPC_URL, DEV_FALLBACK_RPCS, timeoutMs, checkHealth);
return url;
}
80 changes: 80 additions & 0 deletions src/utils/rpc-health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Lightweight RPC health check using eth_chainId with a short timeout.
*/
export interface RpcHealthResult {
healthy: boolean;
chainId: bigint | null;
error?: string;
latencyMs?: number;
}

/**
* Probe an RPC URL with eth_chainId to verify it's reachable.
*
* @param rpcUrl The endpoint to probe.
* @param timeoutMs Abort timeout in milliseconds (default 1500).
* @param fetchImpl Optional fetch implementation for testing.
*/
export async function rpcHealthCheck(
rpcUrl: string,
timeoutMs = 1500,
fetchImpl: typeof fetch = fetch
): Promise<RpcHealthResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const start = Date.now();

try {
const response = await fetchImpl(rpcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "eth_chainId",
params: [],
id: 1,
}),
signal: controller.signal,
});

clearTimeout(timer);

if (!response.ok) {
return {
healthy: false,
chainId: null,
error: `HTTP ${response.status}`,
latencyMs: Date.now() - start,
};
}

const data = (await response.json()) as {
result?: string;
error?: { message?: string };
};

if (data.error) {
return {
healthy: false,
chainId: null,
error: data.error.message ?? "RPC error",
latencyMs: Date.now() - start,
};
}

return {
healthy: true,
chainId: data.result ? BigInt(data.result) : null,
latencyMs: Date.now() - start,
Comment on lines +65 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Treat a missing eth_chainId result as unhealthy.

A 200 OK response without result is not a usable RPC endpoint. Returning healthy: true here prevents fallback and can misreport a broken endpoint as healthy.

🛠️ Suggested fix
-    return {
-      healthy: true,
-      chainId: data.result ? BigInt(data.result) : null,
-      latencyMs: Date.now() - start,
-    };
+    if (typeof data.result !== "string") {
+      return {
+        healthy: false,
+        chainId: null,
+        error: "Missing eth_chainId result",
+        latencyMs: Date.now() - start,
+      };
+    }
+
+    return {
+      healthy: true,
+      chainId: BigInt(data.result),
+      latencyMs: Date.now() - start,
+    };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return {
healthy: true,
chainId: data.result ? BigInt(data.result) : null,
latencyMs: Date.now() - start,
if (typeof data.result !== "string") {
return {
healthy: false,
chainId: null,
error: "Missing eth_chainId result",
latencyMs: Date.now() - start,
};
}
return {
healthy: true,
chainId: BigInt(data.result),
latencyMs: Date.now() - start,
};

};
} catch (err) {
clearTimeout(timer);
Comment on lines +40 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the timeout active through body parsing.

fetch() can resolve before the body is fully read. Clearing the timer at Line 40 lets response.json() hang forever if the server stalls after sending headers, which blocks fallback and diagnostics.

🛠️ Suggested fix
-    clearTimeout(timer);
-
     if (!response.ok) {
       return {
         healthy: false,
         chainId: null,
         error: `HTTP ${response.status}`,
@@
-  } catch (err) {
-    clearTimeout(timer);
+  } catch (err) {
     const message = err instanceof Error ? err.message : String(err);
     return {
       healthy: false,
       chainId: null,
       error: message,
       latencyMs: Date.now() - start,
     };
+  } finally {
+    clearTimeout(timer);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
clearTimeout(timer);
if (!response.ok) {
return {
healthy: false,
chainId: null,
error: `HTTP ${response.status}`,
latencyMs: Date.now() - start,
};
}
const data = (await response.json()) as {
result?: string;
error?: { message?: string };
};
if (data.error) {
return {
healthy: false,
chainId: null,
error: data.error.message ?? "RPC error",
latencyMs: Date.now() - start,
};
}
return {
healthy: true,
chainId: data.result ? BigInt(data.result) : null,
latencyMs: Date.now() - start,
};
} catch (err) {
clearTimeout(timer);
if (!response.ok) {
return {
healthy: false,
chainId: null,
error: `HTTP ${response.status}`,
latencyMs: Date.now() - start,
};
}
const data = (await response.json()) as {
result?: string;
error?: { message?: string };
};
if (data.error) {
return {
healthy: false,
chainId: null,
error: data.error.message ?? "RPC error",
latencyMs: Date.now() - start,
};
}
return {
healthy: true,
chainId: data.result ? BigInt(data.result) : null,
latencyMs: Date.now() - start,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
healthy: false,
chainId: null,
error: message,
latencyMs: Date.now() - start,
};
} finally {
clearTimeout(timer);
}

const message = err instanceof Error ? err.message : String(err);
return {
healthy: false,
chainId: null,
error: message,
latencyMs: Date.now() - start,
};
}
}
87 changes: 86 additions & 1 deletion tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test";

import { isDenoDeployHost } from "../src/constants/config";
import { isDenoDeployHost, isValidHttpUrl, getHealthyRpcUrl } from "../src/constants/config";
import type { RpcHealthResult } from "../src/utils/rpc-health";

describe("isDenoDeployHost", () => {
it("matches Deno Deploy preview hostnames", () => {
Expand All @@ -13,3 +14,87 @@ describe("isDenoDeployHost", () => {
expect(isDenoDeployHost("localhost")).toBe(false);
});
});

describe("isValidHttpUrl", () => {
it("accepts HTTP URLs", () => {
expect(isValidHttpUrl("http://localhost:8545")).toBe(true);
expect(isValidHttpUrl("https://rpc.ubq.fi")).toBe(true);
});

it("accepts HTTPS URLs with paths", () => {
expect(isValidHttpUrl("https://rpc.ubq.fi/v1")).toBe(true);
});

it("rejects non-HTTP protocols", () => {
expect(isValidHttpUrl("ftp://rpc.ubq.fi")).toBe(false);
expect(isValidHttpUrl("ws://rpc.ubq.fi")).toBe(false);
});

it("rejects malformed URLs", () => {
expect(isValidHttpUrl("not-a-url")).toBe(false);
expect(isValidHttpUrl("")).toBe(false);
});
});

describe("getHealthyRpcUrl", () => {
it("returns primary when healthy", async () => {
const checkHealth = async (): Promise<RpcHealthResult> => ({
healthy: true,
chainId: 1n,
latencyMs: 100,
});
const result = await getHealthyRpcUrl("https://primary.example.com", [], 1500, checkHealth);
expect(result.url).toBe("https://primary.example.com");
expect(result.health.healthy).toBe(true);
});

it("falls back when primary is unhealthy", async () => {
let calls = 0;
const checkHealth = async (url: string): Promise<RpcHealthResult> => {
calls++;
if (url === "https://primary.example.com") {
return { healthy: false, chainId: null, error: "DOWN" };
}
return { healthy: true, chainId: 1n, latencyMs: 100 };
};
const result = await getHealthyRpcUrl(
"https://primary.example.com",
["https://fallback.example.com"],
1500,
checkHealth
);
expect(result.url).toBe("https://fallback.example.com");
expect(calls).toBe(2);
});

it("returns primary when all fallbacks fail", async () => {
const checkHealth = async (): Promise<RpcHealthResult> => ({
healthy: false,
chainId: null,
error: "DOWN",
});
const result = await getHealthyRpcUrl(
"https://primary.example.com",
["https://fallback.example.com"],
1500,
checkHealth
);
expect(result.url).toBe("https://primary.example.com");
expect(result.health.healthy).toBe(false);
});

it("skips duplicate fallback equal to primary", async () => {
let calls = 0;
const checkHealth = async (): Promise<RpcHealthResult> => {
calls++;
return { healthy: false, chainId: null, error: "DOWN" };
};
await getHealthyRpcUrl(
"https://same.example.com",
["https://same.example.com"],
1500,
checkHealth
);
expect(calls).toBe(1);
});
});
Loading