diff --git a/Overlord-Server/public/assets/sol-publish.js b/Overlord-Server/public/assets/sol-publish.js index 8c1aaa2..19336f8 100644 --- a/Overlord-Server/public/assets/sol-publish.js +++ b/Overlord-Server/public/assets/sol-publish.js @@ -15,11 +15,15 @@ const rpcEndpointList = document.getElementById("rpc-endpoint-list"); const newRpcInput = document.getElementById("new-rpc-url"); const addRpcBtn = document.getElementById("add-rpc-btn"); const rpcManagerError = document.getElementById("rpc-manager-error"); +const testAllRpcBtn = document.getElementById("test-all-rpc-btn"); +const rpcTestSummary = document.getElementById("rpc-test-summary"); -if (!rpcSelect || !customRpcWrapper || !customRpcInput || !privateKeyInput || !toggleKeyBtn || !serverUrlInput || !previewBtn || !publishBtn || !outputSection || !outputDiv || !walletInfo || !walletAddress || !rpcEndpointList || !newRpcInput || !addRpcBtn || !rpcManagerError) { +if (!rpcSelect || !customRpcWrapper || !customRpcInput || !privateKeyInput || !toggleKeyBtn || !serverUrlInput || !previewBtn || !publishBtn || !outputSection || !outputDiv || !walletInfo || !walletAddress || !rpcEndpointList || !newRpcInput || !addRpcBtn || !rpcManagerError || !testAllRpcBtn || !rpcTestSummary) { return; } +let rpcTestResults = new Map(); + function normalizeRpcEndpoints(value) { const seen = new Set(); const endpoints = []; @@ -75,8 +79,11 @@ function renderRpcEndpointManager(records) { return; } records.forEach((record) => { + const testResult = rpcTestResults.get(record.id); const row = document.createElement("div"); - row.className = "group flex items-center gap-1 rounded-lg border border-slate-800 bg-slate-950/60 p-1.5"; + row.className = `group flex flex-wrap items-center gap-1 rounded-lg border bg-slate-950/60 p-1.5 ${ + testResult ? (testResult.ok ? "border-emerald-500/40" : "border-red-500/40") : "border-slate-800" + }`; const input = document.createElement("input"); input.type = "url"; input.value = record.url; @@ -94,10 +101,51 @@ function renderRpcEndpointManager(records) { remove.innerHTML = ''; remove.addEventListener("click", () => mutateRpcEndpoint(`/api/sol/rpc-endpoints/${record.id}`, "DELETE")); row.append(input, save, remove); + if (testResult) { + const status = document.createElement("div"); + status.className = `basis-full px-2 pb-1 text-[11px] ${testResult.ok ? "text-emerald-300" : "text-red-300"}`; + status.title = testResult.ok ? `Latest blockhash: ${testResult.blockhash}` : testResult.error; + status.innerHTML = testResult.ok + ? `Valid response · ${testResult.latencyMs} ms · slot ${testResult.slot.toLocaleString()} · block height ${testResult.lastValidBlockHeight.toLocaleString()}` + : `${escapeHtml(testResult.error || "RPC test failed")} · ${testResult.latencyMs} ms`; + row.appendChild(status); + } rpcEndpointList.appendChild(row); }); } +function escapeHtml(value) { + const element = document.createElement("span"); + element.textContent = String(value); + return element.innerHTML; +} + +testAllRpcBtn.addEventListener("click", async () => { + setRpcManagerError(); + rpcTestSummary.classList.add("hidden"); + testAllRpcBtn.disabled = true; + testAllRpcBtn.innerHTML = ' Testing...'; + try { + const res = await fetch("/api/sol/rpc-endpoints/test", { + method: "POST", + credentials: "include", + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error || "Failed to test RPC endpoints"); + rpcTestResults = new Map((data.results || []).map((result) => [result.id, result])); + rpcTestSummary.textContent = `${data.passed} of ${data.tested} endpoints returned a valid Solana blockhash${data.failed ? `; ${data.failed} failed` : "."}`; + rpcTestSummary.className = `rounded-lg border px-3 py-2 text-xs ${ + data.failed ? "border-amber-500/30 bg-amber-500/5 text-amber-200" : "border-emerald-500/30 bg-emerald-500/5 text-emerald-200" + }`; + await loadRpcEndpoints(); + } catch (error) { + setRpcManagerError(error?.message || "Failed to test RPC endpoints"); + } finally { + testAllRpcBtn.disabled = false; + testAllRpcBtn.innerHTML = ' Test all'; + } +}); + async function mutateRpcEndpoint(path, method, body) { setRpcManagerError(); try { @@ -110,6 +158,8 @@ async function mutateRpcEndpoint(path, method, body) { const data = await res.json(); if (!res.ok) throw new Error(data?.error || "Failed to save RPC endpoints"); newRpcInput.value = ""; + rpcTestResults.clear(); + rpcTestSummary.classList.add("hidden"); await loadRpcEndpoints(); } catch (error) { setRpcManagerError(error?.message || "Failed to save RPC endpoints"); diff --git a/Overlord-Server/public/sol-publish.html b/Overlord-Server/public/sol-publish.html index 5a00b32..85aceab 100644 --- a/Overlord-Server/public/sol-publish.html +++ b/Overlord-Server/public/sol-publish.html @@ -135,9 +135,14 @@

RPC endpoint pool

Shared by future client builds.

+
+
diff --git a/Overlord-Server/src/server/routes/sol-routes.ts b/Overlord-Server/src/server/routes/sol-routes.ts index 41cee55..034d9eb 100644 --- a/Overlord-Server/src/server/routes/sol-routes.ts +++ b/Overlord-Server/src/server/routes/sol-routes.ts @@ -9,6 +9,7 @@ import { normalizeSolRpcUrl, saveSolRpcEndpointUrls, } from "../../sol-rpc-endpoints"; +import { testSolRpcEndpoint } from "../../sol-rpc-test"; let _solana: typeof import("@solana/web3.js") | null = null; async function getSolana() { @@ -73,6 +74,20 @@ export async function handleSolRoutes( try { requirePermission(user, "system:configure"); } catch (error) { return error instanceof Response ? error : new Response("Forbidden", { status: 403 }); } + if (req.method === "POST" && url.pathname === "/api/sol/rpc-endpoints/test") { + const records = getSolRpcEndpoints(); + const results = await Promise.all(records.map(async (record) => ({ + ...record, + ...await testSolRpcEndpoint(record.url), + }))); + return Response.json({ + tested: results.length, + passed: results.filter((result) => result.ok).length, + failed: results.filter((result) => !result.ok).length, + results, + }); + } + if (req.method === "POST" && url.pathname === "/api/sol/rpc-endpoints") { const body = await req.json(); const current = getSolRpcEndpoints().map((item) => item.url); diff --git a/Overlord-Server/src/sol-rpc-test.test.ts b/Overlord-Server/src/sol-rpc-test.test.ts new file mode 100644 index 0000000..c929e02 --- /dev/null +++ b/Overlord-Server/src/sol-rpc-test.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { testSolRpcEndpoint } from "./sol-rpc-test"; + +const validBlockhash = "11111111111111111111111111111111"; + +describe("testSolRpcEndpoint", () => { + test("accepts a valid latest blockhash response", async () => { + const fetchImpl = async () => Response.json({ + jsonrpc: "2.0", + id: 1, + result: { + context: { slot: 123456 }, + value: { blockhash: validBlockhash, lastValidBlockHeight: 123600 }, + }, + }); + + const result = await testSolRpcEndpoint("https://rpc.example.com", 100, fetchImpl); + expect(result).toMatchObject({ + ok: true, + slot: 123456, + blockhash: validBlockhash, + lastValidBlockHeight: 123600, + }); + }); + + test("rejects successful HTTP responses with invalid RPC values", async () => { + const fetchImpl = async () => Response.json({ + jsonrpc: "2.0", + id: 1, + result: { context: { slot: "not-a-number" }, value: {} }, + }); + + const result = await testSolRpcEndpoint("https://rpc.example.com", 100, fetchImpl); + expect(result.ok).toBe(false); + expect(result.error).toBe("Response contained an invalid slot"); + }); + + test("reports HTTP and JSON-RPC errors", async () => { + const httpResult = await testSolRpcEndpoint( + "https://rpc.example.com", + 100, + async () => new Response("unavailable", { status: 503 }), + ); + expect(httpResult).toMatchObject({ ok: false, error: "HTTP 503" }); + + const rpcResult = await testSolRpcEndpoint( + "https://rpc.example.com", + 100, + async () => Response.json({ error: { message: "rate limited" } }), + ); + expect(rpcResult).toMatchObject({ ok: false, error: "rate limited" }); + }); + + test("aborts endpoints that exceed the timeout", async () => { + const fetchImpl = (_input: string | URL | Request, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))); + }); + + const result = await testSolRpcEndpoint("https://rpc.example.com", 5, fetchImpl); + expect(result).toMatchObject({ ok: false, error: "Timed out after 5ms" }); + }); +}); diff --git a/Overlord-Server/src/sol-rpc-test.ts b/Overlord-Server/src/sol-rpc-test.ts new file mode 100644 index 0000000..3c46962 --- /dev/null +++ b/Overlord-Server/src/sol-rpc-test.ts @@ -0,0 +1,80 @@ +const BASE58_VALUE = /^[1-9A-HJ-NP-Za-km-z]{32,64}$/; + +export type SolRpcTestResult = { + ok: boolean; + latencyMs: number; + slot?: number; + blockhash?: string; + lastValidBlockHeight?: number; + error?: string; +}; + +type RpcFetch = (input: string | URL | Request, init?: RequestInit) => Promise; + +export async function testSolRpcEndpoint( + endpoint: string, + timeoutMs = 10_000, + fetchImpl: RpcFetch = fetch, +): Promise { + const startedAt = performance.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "getLatestBlockhash", + params: [{ commitment: "confirmed" }], + }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`); + } + + let data: any; + try { + data = await response.json(); + } catch { + throw new Error("Response was not valid JSON"); + } + + if (data?.error) { + const detail = typeof data.error.message === "string" ? data.error.message : "RPC returned an error"; + throw new Error(detail); + } + + const slot = data?.result?.context?.slot; + const blockhash = data?.result?.value?.blockhash; + const lastValidBlockHeight = data?.result?.value?.lastValidBlockHeight; + if (!Number.isSafeInteger(slot) || slot < 0) throw new Error("Response contained an invalid slot"); + if (typeof blockhash !== "string" || !BASE58_VALUE.test(blockhash)) { + throw new Error("Response contained an invalid blockhash"); + } + if (!Number.isSafeInteger(lastValidBlockHeight) || lastValidBlockHeight < 0) { + throw new Error("Response contained an invalid last valid block height"); + } + + return { + ok: true, + latencyMs: Math.round(performance.now() - startedAt), + slot, + blockhash, + lastValidBlockHeight, + }; + } catch (error: any) { + const timedOut = controller.signal.aborted; + return { + ok: false, + latencyMs: Math.round(performance.now() - startedAt), + error: timedOut ? `Timed out after ${timeoutMs}ms` : (error?.message || "RPC request failed"), + }; + } finally { + clearTimeout(timeout); + } +}