majorly improve SOL push UI + allow for more customization.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<string>();
|
||||
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 }));
|
||||
}
|
||||
Reference in New Issue
Block a user