From d5aa562e2678a364b59026de4b73a26c93130180 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 20 Mar 2026 14:13:40 +0000 Subject: [PATCH 1/3] =?UTF-8?q?[#384]=20Add=20RPC=20fallback=20rotation=20?= =?UTF-8?q?=E2=80=94=20port=20from=20Dropcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/rpc.ts is now the single global RPC module with: - 8 public Base RPC endpoints ordered by reliability - CORS-safe subset for client-side (wagmi) - Fallback-aware publicClient (server-side, 10s timeout) - createFallbackTransport() for wagmi config - withServerRpcFallback() for API route operations - getReceiptWithRetry() using fallback client internally lib/wagmi.ts updated to use createFallbackTransport(). All existing consumers already import from lib/rpc.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/rpc.ts | 149 +++++++++++++++++++++++++++++++++++++++++++++++---- lib/wagmi.ts | 15 ++---- 2 files changed, 143 insertions(+), 21 deletions(-) diff --git a/lib/rpc.ts b/lib/rpc.ts index 1c3f2278..7b44ea61 100644 --- a/lib/rpc.ts +++ b/lib/rpc.ts @@ -1,30 +1,157 @@ -import { createPublicClient, http, fallback, type Hex } from "viem"; +import { createPublicClient, http, fallback, type Hex, type PublicClient } from "viem"; import { base, baseSepolia } from "viem/chains"; const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || "84532"); const chain = chainId === 8453 ? base : baseSepolia; +const IS_MAINNET = chainId === 8453; -const customRpc = process.env.NEXT_PUBLIC_RPC_URL; +const CUSTOM_RPC_URL = process.env.NEXT_PUBLIC_RPC_URL; -const transport = customRpc - ? fallback([http(customRpc), http()]) - : http(); +// --------------------------------------------------------------------------- +// RPC endpoint lists (Base mainnet only — Sepolia uses chain default) +// --------------------------------------------------------------------------- + +/** Server-side RPC endpoints ordered by reliability. */ +const PUBLIC_RPC_ENDPOINTS = [ + "https://base-rpc.publicnode.com", + "https://mainnet.base.org", + "https://base.drpc.org", + "https://base.llamarpc.com", + "https://base.meowrpc.com", + "https://developer-access-mainnet.base.org", + "https://base-mainnet.public.blastapi.io", + "https://1rpc.io/base", +]; + +export const RPC_ENDPOINTS = CUSTOM_RPC_URL + ? [CUSTOM_RPC_URL, ...PUBLIC_RPC_ENDPOINTS] + : PUBLIC_RPC_ENDPOINTS; + +/** Client-side CORS-enabled RPC endpoints for wagmi/browser. */ +const PUBLIC_CORS_ENDPOINTS = [ + "https://mainnet.base.org", + "https://base-rpc.publicnode.com", + "https://base.drpc.org", + "https://base.llamarpc.com", + "https://base.meowrpc.com", +]; + +export const CORS_RPC_ENDPOINTS = CUSTOM_RPC_URL + ? [CUSTOM_RPC_URL, ...PUBLIC_CORS_ENDPOINTS] + : PUBLIC_CORS_ENDPOINTS; + +// --------------------------------------------------------------------------- +// Transports +// --------------------------------------------------------------------------- + +function buildServerTransport() { + if (!IS_MAINNET) { + return CUSTOM_RPC_URL ? fallback([http(CUSTOM_RPC_URL), http()]) : http(); + } + return fallback( + RPC_ENDPOINTS.map((url) => http(url, { timeout: 10_000, retryCount: 1 })), + { rank: false }, + ); +} + +// --------------------------------------------------------------------------- +// Public client +// --------------------------------------------------------------------------- /** * Shared public client for reading from Base (or Base Sepolia). - * - * Chain is selected via NEXT_PUBLIC_CHAIN_ID (default: 84532 / Base Sepolia). - * Uses NEXT_PUBLIC_RPC_URL with a fallback to the chain's default public RPC. + * On mainnet, uses fallback across multiple RPC endpoints. */ export const publicClient = createPublicClient({ chain, - transport, + transport: buildServerTransport(), }); +// --------------------------------------------------------------------------- +// Exports for wagmi +// --------------------------------------------------------------------------- + +/** + * Create a CORS-safe fallback transport for wagmi browser config. + * On testnet, returns a single http() transport. + */ +export function createFallbackTransport() { + if (!IS_MAINNET) { + return CUSTOM_RPC_URL ? fallback([http(CUSTOM_RPC_URL), http()]) : http(); + } + return fallback( + CORS_RPC_ENDPOINTS.map((url) => + http(url, { + timeout: 5_000, + retryCount: 0, + fetchOptions: { mode: "cors", credentials: "omit" }, + }), + ), + { rank: false }, + ); +} + +// --------------------------------------------------------------------------- +// Server-side fallback helper +// --------------------------------------------------------------------------- + +function getRpcDisplayName(url: string): string { + if (CUSTOM_RPC_URL && url === CUSTOM_RPC_URL) return "Custom RPC"; + if (url.includes("publicnode.com")) return "PublicNode"; + if (url.includes("mainnet.base.org")) return "Base Official"; + if (url.includes("drpc.org")) return "DRPC"; + if (url.includes("llamarpc.com")) return "LlamaRPC"; + if (url.includes("meowrpc.com")) return "MeowRPC"; + if (url.includes("1rpc.io")) return "1RPC"; + if (url.includes("blastapi.io")) return "BlastAPI"; + if (url.includes("developer-access")) return "Base Dev"; + return "RPC"; +} + +/** + * Try an RPC operation against each endpoint until one succeeds. + * Use in server-side API routes for operations that need explicit + * per-endpoint fallback with logging. + */ +export async function withServerRpcFallback( + operation: (client: PublicClient) => Promise, + label?: string, +): Promise { + const endpoints = RPC_ENDPOINTS; + let lastError: Error | null = null; + const prefix = label ? `[RPC:${label}]` : "[RPC]"; + + for (let i = 0; i < endpoints.length; i++) { + const url = endpoints[i]; + const name = getRpcDisplayName(url); + try { + const client = createPublicClient({ + chain, + transport: http(url, { timeout: 10_000, retryCount: 0 }), + }) as PublicClient; + const result = await operation(client); + if (i > 0) console.log(`${prefix} Success with ${name} (attempt ${i + 1})`); + return result; + } catch (error) { + lastError = error as Error; + console.warn(`${prefix} ${name} failed: ${(lastError.message || "").slice(0, 100)}`); + if (i < endpoints.length - 1) { + await new Promise((r) => setTimeout(r, 100)); + } + } + } + + console.error(`${prefix} All ${endpoints.length} RPC endpoints failed`); + throw lastError || new Error("All RPC endpoints failed"); +} + +// --------------------------------------------------------------------------- +// Receipt helper +// --------------------------------------------------------------------------- + /** * Fetch a transaction receipt with retries and backoff. - * Load-balanced RPC nodes may not have the receipt immediately after - * `waitForTransactionReceipt` completes on the client side. + * Uses the fallback-aware publicClient internally. */ export async function getReceiptWithRetry(hash: Hex, maxAttempts = 5) { for (let attempt = 1; attempt <= maxAttempts; attempt++) { diff --git a/lib/wagmi.ts b/lib/wagmi.ts index 87c35670..ec2bae94 100644 --- a/lib/wagmi.ts +++ b/lib/wagmi.ts @@ -2,21 +2,16 @@ import { http, createConfig } from "wagmi"; import { base, baseSepolia } from "wagmi/chains"; import { injected } from "wagmi/connectors"; import { farcasterMiniApp } from "@farcaster/miniapp-wagmi-connector"; +import { createFallbackTransport } from "./rpc"; + +const IS_MAINNET = process.env.NEXT_PUBLIC_CHAIN_ID === "8453"; export const config = createConfig({ chains: [base, baseSepolia], connectors: [farcasterMiniApp(), injected()], transports: { - [base.id]: http( - process.env.NEXT_PUBLIC_CHAIN_ID === "8453" - ? process.env.NEXT_PUBLIC_RPC_URL - : undefined, - ), - [baseSepolia.id]: http( - process.env.NEXT_PUBLIC_CHAIN_ID !== "8453" - ? process.env.NEXT_PUBLIC_RPC_URL - : undefined, - ), + [base.id]: IS_MAINNET ? createFallbackTransport() : http(), + [baseSepolia.id]: IS_MAINNET ? http() : createFallbackTransport(), }, ssr: true, }); From 07a946842df9e885e08c87c2bcb71d556ea212b5 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 20 Mar 2026 14:15:43 +0000 Subject: [PATCH 2/3] [#384] Use lib/rpc.ts publicClient in e2e-verify script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove local createPublicClient — import from lib/rpc.ts instead, making it the single global RPC module with no exceptions. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/e2e-verify.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/scripts/e2e-verify.ts b/scripts/e2e-verify.ts index b513b066..94270b62 100644 --- a/scripts/e2e-verify.ts +++ b/scripts/e2e-verify.ts @@ -19,8 +19,8 @@ import { readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { createClient } from "@supabase/supabase-js"; import { keccak256, toHex, formatUnits, decodeEventLog, type Address } from "viem"; -import { createPublicClient, http, fallback } from "viem"; import { base, baseSepolia } from "viem/chains"; +import { publicClient } from "../lib/rpc"; // --------------------------------------------------------------------------- // CLI args @@ -154,14 +154,9 @@ interface BroadcastArtifact { const results: E2EResults = JSON.parse(readFileSync(resultsPath, "utf-8")); const artifactPath = resolve(dirname(resultsPath), results.broadcastArtifact); -// Initialize chain from e2e-results.json (not env — ensures correct chain) +// Chain from e2e-results.json (for display only — publicClient uses env config) const chainId = results.chainId; const resolvedChain = chainId === 8453 ? base : baseSepolia; -const customRpc = process.env.NEXT_PUBLIC_RPC_URL; -const publicClient = createPublicClient({ - chain: resolvedChain, - transport: customRpc ? fallback([http(customRpc), http()]) : http(), -}); let broadcast: BroadcastArtifact; try { From df5d7daf2edf641cfaf572d6bcb69d71f400a546 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 20 Mar 2026 14:18:14 +0000 Subject: [PATCH 3/3] [#384] Add fallback RPC support to SDK client PlotLinkConfig now accepts optional rpcUrls[] for fallback rotation. When provided, SDK builds a fallback transport from all URLs. No more standalone http() transport in the SDK. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/sdk/src/client.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index c3abe1f2..31c93288 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -2,6 +2,7 @@ import { createPublicClient, createWalletClient, http, + fallback, keccak256, toHex, decodeEventLog, @@ -50,6 +51,8 @@ export interface PlotLinkConfig { privateKey: string; /** JSON-RPC URL for the Base chain. */ rpcUrl: string; + /** Optional additional RPC URLs for fallback rotation (tried in order after rpcUrl). */ + rpcUrls?: string[]; /** Chain ID — defaults to 84532 (Base Sepolia). */ chainId?: number; /** Override StoryFactory contract address. */ @@ -169,15 +172,21 @@ export class PlotLink { : `0x${config.privateKey}`; const account = privateKeyToAccount(normalizedKey as Hex); + const allUrls = [config.rpcUrl, ...(config.rpcUrls ?? [])]; + const transport = + allUrls.length > 1 + ? fallback(allUrls.map((url) => http(url, { timeout: 10_000, retryCount: 1 })), { rank: false }) + : http(config.rpcUrl); + this.publicClient = createPublicClient({ chain: this.chain, - transport: http(config.rpcUrl), + transport, }); this.walletClient = createWalletClient({ account, chain: this.chain, - transport: http(config.rpcUrl), + transport, }); this.address = account.address;