Skip to content
Merged
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
149 changes: 138 additions & 11 deletions lib/rpc.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
operation: (client: PublicClient) => Promise<T>,
label?: string,
): Promise<T> {
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++) {
Expand Down
15 changes: 5 additions & 10 deletions lib/wagmi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
13 changes: 11 additions & 2 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createPublicClient,
createWalletClient,
http,
fallback,
keccak256,
toHex,
decodeEventLog,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 2 additions & 7 deletions scripts/e2e-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading