diff --git a/lib/wallet/fetch-balances.ts b/lib/wallet/fetch-balances.ts index 46fac8206..8c9fbf342 100644 --- a/lib/wallet/fetch-balances.ts +++ b/lib/wallet/fetch-balances.ts @@ -3,6 +3,11 @@ */ import { ErrorCategory, logUserError } from "@/lib/logging"; +import { + encodeBalanceOfCallData, + hexWeiToBigInt, + rpcCallWithFailover, +} from "./rpc"; import type { ChainBalance, ChainData, @@ -87,6 +92,16 @@ function buildExplorerAddressUrl( return `${chain.explorerUrl}${path.replace("{address}", address)}`; } +/** + * Collect the ordered list of RPC URLs to attempt for a chain: primary + * first, fallback second when configured. + */ +function getChainRpcUrls(chain: ChainData): string[] { + return chain.defaultFallbackRpc + ? [chain.defaultPrimaryRpc, chain.defaultFallbackRpc] + : [chain.defaultPrimaryRpc]; +} + /** * Fetch native token balance for a single chain */ @@ -95,23 +110,14 @@ export async function fetchNativeBalance( chain: ChainData ): Promise { try { - const response = await fetch(chain.defaultPrimaryRpc, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_getBalance", - params: [address, "latest"], - id: 1, - }), + const resultHex = await rpcCallWithFailover(getChainRpcUrls(chain), { + jsonrpc: "2.0", + method: "eth_getBalance", + params: [address, "latest"], + id: 1, }); - const result = await response.json(); - if (result.error) { - throw new Error(result.error.message); - } - - const balanceWei = BigInt(result.result); + const balanceWei = hexWeiToBigInt(resultHex); return { chainId: chain.chainId, @@ -154,49 +160,17 @@ export async function fetchTokenBalance( chain: ChainData ): Promise { try { - // ERC20 balanceOf function signature - const balanceOfSelector = "0x70a08231"; - - // Encode the balanceOf call data - const addressWithoutPrefix = address.startsWith("0x") - ? address.slice(2) - : address; - const paddedAddress = addressWithoutPrefix.toLowerCase().padStart(64, "0"); - const callData = `${balanceOfSelector}${paddedAddress}`; - - const response = await fetch(chain.defaultPrimaryRpc, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_call", - params: [{ to: token.tokenAddress, data: callData }, "latest"], - id: 1, - }), + const resultHex = await rpcCallWithFailover(getChainRpcUrls(chain), { + jsonrpc: "2.0", + method: "eth_call", + params: [ + { to: token.tokenAddress, data: encodeBalanceOfCallData(address) }, + "latest", + ], + id: 1, }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - if (result.error) { - throw new Error(result.error.message || "RPC error"); - } - - if (!result.result || result.result === "0x") { - return { - tokenId: token.id, - chainId: token.chainId, - tokenAddress: token.tokenAddress, - symbol: token.symbol, - name: token.name, - balance: "0.000000", - loading: false, - }; - } - - const balanceWei = BigInt(result.result); + const balanceWei = hexWeiToBigInt(resultHex); return { tokenId: token.id, @@ -272,123 +246,57 @@ export function fetchAllTokenBalances( /** * Fetch balance for a single supported token with retry logic */ -export function fetchSupportedTokenBalance( +export async function fetchSupportedTokenBalance( address: string, token: SupportedToken, - chain: ChainData, - retries = 3 + chain: ChainData ): Promise { - const makeRequest = async ( - attempt: number - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Retry logic with exponential backoff requires this complexity - ): Promise => { - try { - // ERC20 balanceOf function signature - const balanceOfSelector = "0x70a08231"; - - // Encode the balanceOf call data - const addressWithoutPrefix = address.startsWith("0x") - ? address.slice(2) - : address; - const paddedAddress = addressWithoutPrefix - .toLowerCase() - .padStart(64, "0"); - const callData = `${balanceOfSelector}${paddedAddress}`; - - const response = await fetch(chain.defaultPrimaryRpc, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "eth_call", - params: [{ to: token.tokenAddress, data: callData }, "latest"], - id: 1, - }), - }); - - // Handle rate limiting with retry - if (response.status === 429 && attempt < retries) { - const backoffMs = Math.min(1000 * 2 ** attempt, 5000); - await new Promise((resolve) => setTimeout(resolve, backoffMs)); - return makeRequest(attempt + 1); - } - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - if (result.error) { - throw new Error(result.error.message || "RPC error"); - } - - const tokenExplorerUrl = buildExplorerAddressUrl( - chain, - token.tokenAddress - ); - - if (!result.result || result.result === "0x") { - return { - chainId: token.chainId, - tokenAddress: token.tokenAddress, - symbol: token.symbol, - name: token.name, - logoUrl: token.logoUrl, - balance: "0.000000", - loading: false, - explorerUrl: tokenExplorerUrl, - }; - } + try { + const resultHex = await rpcCallWithFailover(getChainRpcUrls(chain), { + jsonrpc: "2.0", + method: "eth_call", + params: [ + { to: token.tokenAddress, data: encodeBalanceOfCallData(address) }, + "latest", + ], + id: 1, + }); - const balanceWei = BigInt(result.result); + const balanceWei = hexWeiToBigInt(resultHex); - return { - chainId: token.chainId, - tokenAddress: token.tokenAddress, - symbol: token.symbol, - name: token.name, - logoUrl: token.logoUrl, - balance: formatWeiToBalance(balanceWei, token.decimals), - loading: false, - explorerUrl: tokenExplorerUrl, - }; - } catch (error) { - // Retry on network errors - if ( - attempt < retries && - error instanceof Error && - !error.message.includes("HTTP 4") - ) { - const backoffMs = Math.min(500 * 2 ** attempt, 3000); - await new Promise((resolve) => setTimeout(resolve, backoffMs)); - return makeRequest(attempt + 1); + return { + chainId: token.chainId, + tokenAddress: token.tokenAddress, + symbol: token.symbol, + name: token.name, + logoUrl: token.logoUrl, + balance: formatWeiToBalance(balanceWei, token.decimals), + loading: false, + explorerUrl: buildExplorerAddressUrl(chain, token.tokenAddress), + }; + } catch (error) { + logUserError( + ErrorCategory.NETWORK_RPC, + `Failed to fetch balance for ${token.symbol}:`, + error, + { + chain_id: token.chainId.toString(), + token_symbol: token.symbol, + token_address: token.tokenAddress, } - - logUserError( - ErrorCategory.NETWORK_RPC, - `Failed to fetch balance for ${token.symbol}:`, - error, - { - chain_id: token.chainId.toString(), - token_symbol: token.symbol, - token_address: token.tokenAddress, - } - ); - return { - chainId: token.chainId, - tokenAddress: token.tokenAddress, - symbol: token.symbol, - name: token.name, - logoUrl: token.logoUrl, - balance: "0", - loading: false, - error: error instanceof Error ? error.message : "Failed to fetch", - explorerUrl: buildExplorerAddressUrl(chain, token.tokenAddress), - }; - } - }; - - return makeRequest(0); + ); + return { + chainId: token.chainId, + tokenAddress: token.tokenAddress, + symbol: token.symbol, + name: token.name, + logoUrl: token.logoUrl, + balance: "0", + loading: false, + error: error instanceof Error ? error.message : "Failed to fetch", + explorerUrl: buildExplorerAddressUrl(chain, token.tokenAddress), + }; + } } /** diff --git a/lib/wallet/rpc.ts b/lib/wallet/rpc.ts new file mode 100644 index 000000000..42957ab57 --- /dev/null +++ b/lib/wallet/rpc.ts @@ -0,0 +1,235 @@ +/** + * Shared JSON-RPC client for wallet balance fetches. + * + * Consolidates retry/backoff semantics and payload encoding used by the + * native and ERC20 balance fetchers. Split into its own module so the + * retry logic can be unit-tested without pulling the balance-formatting + * machinery. + */ + +import { addBreadcrumb } from "@sentry/nextjs"; + +const BIGINT_ZERO = BigInt(0); +const EVM_ADDRESS_REGEX = /^(0x)?[0-9a-fA-F]{40}$/; +const ERC20_BALANCE_OF_SELECTOR = "0x70a08231"; +const ERC20_ADDRESS_PADDING = 64; + +export type RpcFailureKind = "standard" | "rate_limit"; + +export type JsonRpcPayload = { + jsonrpc: "2.0"; + method: string; + params: unknown[]; + id: number; +}; + +/** + * RPC retry configuration. + * + * Two exponential-backoff schedules with jitter, picked by failure type: + * + * - `STANDARD`: network errors, HTTP 5xx, and malformed responses (missing + * `result` field). Short backoff because these usually clear quickly. + * - `RATE_LIMIT`: HTTP 429. Longer backoff because the server is actively + * throttling us; retrying too soon just extends the throttle. + * + * Each delay = `min((BASE_MS * 2^attempt) + jitter, ABSOLUTE_MAX_BACKOFF_MS)` + * where `jitter = random() * base * JITTER_FACTOR`. + * + * `RETRIES_PER_URL_WITH_FAILOVER` applies when `rpcCallWithFailover` has a + * fallback URL available — retry fewer times per URL so we hand off to the + * fallback sooner when the primary is throttled or flaky. + */ +export const RPC_RETRY_CONFIG = { + MAX_RETRIES: 3, + RETRIES_PER_URL_WITH_FAILOVER: 1, + JITTER_FACTOR: 0.3, + ABSOLUTE_MAX_BACKOFF_MS: 5000, + STANDARD: { + BASE_MS: 500, + CAP_MS: 3000, + }, + RATE_LIMIT: { + BASE_MS: 1000, + CAP_MS: 5000, + }, +} as const; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Compute the backoff delay for a given retry attempt and failure kind. + * Adds randomized jitter to avoid lockstep retries across concurrent callers. + * Guaranteed to return at most `RPC_RETRY_CONFIG.ABSOLUTE_MAX_BACKOFF_MS`. + */ +export function getRpcBackoffMs( + attempt: number, + kind: RpcFailureKind +): number { + const schedule = + kind === "rate_limit" + ? RPC_RETRY_CONFIG.RATE_LIMIT + : RPC_RETRY_CONFIG.STANDARD; + const base = Math.min(schedule.BASE_MS * 2 ** attempt, schedule.CAP_MS); + const jitter = Math.random() * base * RPC_RETRY_CONFIG.JITTER_FACTOR; + return Math.min(base + jitter, RPC_RETRY_CONFIG.ABSOLUTE_MAX_BACKOFF_MS); +} + +/** + * Encode an ERC20 `balanceOf(address)` call payload. + * Validates the address is a well-formed 20-byte hex string to prevent + * silent mis-encoding from over-long input slipping past `padStart`. + */ +export function encodeBalanceOfCallData(address: string): string { + if (!EVM_ADDRESS_REGEX.test(address)) { + throw new Error(`Invalid EVM address: ${address}`); + } + const stripped = address.startsWith("0x") ? address.slice(2) : address; + const padded = stripped.toLowerCase().padStart(ERC20_ADDRESS_PADDING, "0"); + return `${ERC20_BALANCE_OF_SELECTOR}${padded}`; +} + +/** + * Parse a hex wei string into BigInt, treating empty `"0x"` as zero. + * `rpcCall` guarantees the input is a non-empty string. + */ +export function hexWeiToBigInt(hex: string): bigint { + return hex === "0x" ? BIGINT_ZERO : BigInt(hex); +} + +/** + * Execute a JSON-RPC POST with retry/backoff for transient failures. + * + * Retries: HTTP 429, HTTP 5xx, network errors, and missing `result` fields + * (malformed gateway responses — the root cause behind `BigInt(undefined)`). + * Does not retry HTTP 4xx (except 429) or RPC-reported errors — those are + * deterministic and would fail again. + * + * Each retry adds a Sentry breadcrumb so the retry history is attached to + * any error eventually captured on the same scope. + * + * Returns the raw `result` string (guaranteed non-empty). Callers interpret + * `"0x"` per their context via {@link hexWeiToBigInt}. + */ +export async function rpcCall( + rpcUrl: string, + payload: JsonRpcPayload, + maxRetries: number = RPC_RETRY_CONFIG.MAX_RETRIES +): Promise { + let lastError: Error = new Error("RPC call failed"); + let lastFailureKind: RpcFailureKind = "standard"; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + const backoffMs = getRpcBackoffMs(attempt - 1, lastFailureKind); + addBreadcrumb({ + category: "rpc.retry", + level: "info", + message: `Retrying RPC after ${lastFailureKind} failure: ${lastError.message}`, + data: { + url: rpcUrl, + method: payload.method, + attempt, + backoffMs: Math.round(backoffMs), + kind: lastFailureKind, + }, + }); + await delay(backoffMs); + } + + let response: Response; + try { + response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + lastFailureKind = "standard"; + continue; + } + + if (response.status === 429) { + lastError = new Error("HTTP 429: rate limited"); + lastFailureKind = "rate_limit"; + continue; + } + + if (response.status >= 500) { + lastError = new Error(`HTTP ${response.status}: ${response.statusText}`); + lastFailureKind = "standard"; + continue; + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + if (data.error) { + throw new Error(data.error.message || "RPC error"); + } + + if (data.result === undefined || data.result === null) { + lastError = new Error("RPC returned no result"); + lastFailureKind = "standard"; + continue; + } + + return data.result; + } + + throw lastError; +} + +/** + * Execute a JSON-RPC call across a primary URL with optional fallbacks. + * + * When more than one URL is provided, each URL uses the reduced + * `RETRIES_PER_URL_WITH_FAILOVER` budget so a throttled primary hands off to + * the fallback quickly instead of burning the full retry schedule first. + * + * Throws the last error after all URLs are exhausted. A Sentry breadcrumb is + * emitted for every failover hop. + */ +export async function rpcCallWithFailover( + rpcUrls: ReadonlyArray, + payload: JsonRpcPayload +): Promise { + if (rpcUrls.length === 0) { + throw new Error("rpcCallWithFailover requires at least one URL"); + } + + const maxRetries = + rpcUrls.length > 1 + ? RPC_RETRY_CONFIG.RETRIES_PER_URL_WITH_FAILOVER + : RPC_RETRY_CONFIG.MAX_RETRIES; + + let lastError: Error = new Error("RPC call failed"); + + for (const [i, url] of rpcUrls.entries()) { + try { + return await rpcCall(url, payload, maxRetries); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + const nextUrl = rpcUrls[i + 1]; + if (nextUrl) { + addBreadcrumb({ + category: "rpc.failover", + level: "info", + message: `RPC primary failed, failing over: ${lastError.message}`, + data: { + method: payload.method, + failedUrl: url, + nextUrl, + }, + }); + } + } + } + + throw lastError; +} diff --git a/lib/wallet/types.ts b/lib/wallet/types.ts index ba82eec57..44fd03993 100644 --- a/lib/wallet/types.ts +++ b/lib/wallet/types.ts @@ -16,6 +16,7 @@ export type ChainData = { symbol: string; chainType: string; defaultPrimaryRpc: string; + defaultFallbackRpc: string | null; explorerUrl: string | null; explorerAddressPath: string | null; isTestnet: boolean; diff --git a/tests/unit/wallet-rpc.test.ts b/tests/unit/wallet-rpc.test.ts new file mode 100644 index 000000000..7b921a5cc --- /dev/null +++ b/tests/unit/wallet-rpc.test.ts @@ -0,0 +1,411 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const addBreadcrumbMock = vi.fn(); +vi.mock("@sentry/nextjs", () => ({ + addBreadcrumb: (...args: unknown[]) => addBreadcrumbMock(...args), +})); + +import { + encodeBalanceOfCallData, + getRpcBackoffMs, + hexWeiToBigInt, + type JsonRpcPayload, + RPC_RETRY_CONFIG, + rpcCall, + rpcCallWithFailover, +} from "@/lib/wallet/rpc"; + +const VALID_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; +const TEST_RPC_URL = "https://rpc.example.test"; +const TEST_PAYLOAD: JsonRpcPayload = { + jsonrpc: "2.0", + method: "eth_getBalance", + params: [VALID_ADDRESS, "latest"], + id: 1, +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function plainResponse(status: number, statusText = ""): Response { + return new Response(null, { status, statusText }); +} + +describe("encodeBalanceOfCallData", () => { + it("encodes a 0x-prefixed address", () => { + const data = encodeBalanceOfCallData(VALID_ADDRESS); + expect(data).toBe( + "0x70a08231000000000000000000000000" + + "1234567890abcdef1234567890abcdef12345678" + ); + }); + + it("encodes an unprefixed address", () => { + const data = encodeBalanceOfCallData(VALID_ADDRESS.slice(2)); + expect(data).toBe( + "0x70a08231000000000000000000000000" + + "1234567890abcdef1234567890abcdef12345678" + ); + }); + + it("lowercases mixed-case input", () => { + const data = encodeBalanceOfCallData( + "0x1234567890ABCDEF1234567890abcdef12345678" + ); + expect(data).toContain("1234567890abcdef1234567890abcdef12345678"); + }); + + it("throws on too-short input", () => { + expect(() => encodeBalanceOfCallData("0x1234")).toThrow( + /Invalid EVM address/ + ); + }); + + it("throws on too-long input", () => { + expect(() => + encodeBalanceOfCallData(`${VALID_ADDRESS}deadbeef`) + ).toThrow(/Invalid EVM address/); + }); + + it("throws on non-hex characters", () => { + expect(() => + encodeBalanceOfCallData("0xzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz") + ).toThrow(/Invalid EVM address/); + }); +}); + +describe("hexWeiToBigInt", () => { + it('treats "0x" as zero', () => { + expect(hexWeiToBigInt("0x")).toBe(BigInt(0)); + }); + + it('parses "0x0" as zero', () => { + expect(hexWeiToBigInt("0x0")).toBe(BigInt(0)); + }); + + it("parses a non-zero hex value", () => { + expect(hexWeiToBigInt("0x1bc16d674ec80000")).toBe( + BigInt("2000000000000000000") + ); + }); +}); + +describe("getRpcBackoffMs", () => { + beforeEach(() => { + vi.spyOn(Math, "random").mockReturnValue(0); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns the base delay for standard attempt 0 with no jitter", () => { + expect(getRpcBackoffMs(0, "standard")).toBe( + RPC_RETRY_CONFIG.STANDARD.BASE_MS + ); + }); + + it("doubles the base delay for standard attempt 1", () => { + expect(getRpcBackoffMs(1, "standard")).toBe( + RPC_RETRY_CONFIG.STANDARD.BASE_MS * 2 + ); + }); + + it("caps standard backoff at CAP_MS", () => { + expect(getRpcBackoffMs(20, "standard")).toBeLessThanOrEqual( + RPC_RETRY_CONFIG.STANDARD.CAP_MS + ); + }); + + it("uses longer base for rate_limit", () => { + expect(getRpcBackoffMs(0, "rate_limit")).toBe( + RPC_RETRY_CONFIG.RATE_LIMIT.BASE_MS + ); + }); + + it("never exceeds ABSOLUTE_MAX_BACKOFF_MS even with maximum jitter", () => { + vi.spyOn(Math, "random").mockReturnValue(0.999999); + for (let attempt = 0; attempt < 10; attempt++) { + expect(getRpcBackoffMs(attempt, "standard")).toBeLessThanOrEqual( + RPC_RETRY_CONFIG.ABSOLUTE_MAX_BACKOFF_MS + ); + expect(getRpcBackoffMs(attempt, "rate_limit")).toBeLessThanOrEqual( + RPC_RETRY_CONFIG.ABSOLUTE_MAX_BACKOFF_MS + ); + } + }); + + it("adds jitter proportional to the base delay", () => { + vi.spyOn(Math, "random").mockReturnValue(0.5); + const base = RPC_RETRY_CONFIG.STANDARD.BASE_MS; + const expectedJitter = 0.5 * base * RPC_RETRY_CONFIG.JITTER_FACTOR; + expect(getRpcBackoffMs(0, "standard")).toBeCloseTo(base + expectedJitter); + }); +}); + +describe("rpcCall", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + vi.useFakeTimers(); + addBreadcrumbMock.mockClear(); + fetchMock.mockReset(); + vi.spyOn(Math, "random").mockReturnValue(0); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + async function runWithTimers(promise: Promise): Promise { + const settled = promise.then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }) + ); + await vi.runAllTimersAsync(); + const outcome = await settled; + if (outcome.ok) { + return outcome.value; + } + throw outcome.error; + } + + it("returns the result on first success", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0x1234" }) + ); + + const result = await runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)); + + expect(result).toBe("0x1234"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(addBreadcrumbMock).not.toHaveBeenCalled(); + }); + + it("retries on 429 then succeeds", async () => { + fetchMock + .mockResolvedValueOnce(plainResponse(429, "Too Many Requests")) + .mockResolvedValueOnce( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0xdead" }) + ); + + const result = await runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)); + + expect(result).toBe("0xdead"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(addBreadcrumbMock).toHaveBeenCalledTimes(1); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + category: "rpc.retry", + data: expect.objectContaining({ kind: "rate_limit", attempt: 1 }), + }) + ); + }); + + it("retries on 5xx then succeeds", async () => { + fetchMock + .mockResolvedValueOnce(plainResponse(502, "Bad Gateway")) + .mockResolvedValueOnce( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0x1" }) + ); + + const result = await runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)); + + expect(result).toBe("0x1"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ kind: "standard" }), + }) + ); + }); + + it("retries on network error then succeeds", async () => { + fetchMock + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValueOnce( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0x2" }) + ); + + const result = await runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)); + + expect(result).toBe("0x2"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("retries when result is missing then succeeds", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ jsonrpc: "2.0", id: 1 })) + .mockResolvedValueOnce( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0x3" }) + ); + + const result = await runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)); + + expect(result).toBe("0x3"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("throws immediately on RPC-reported error without retrying", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + jsonrpc: "2.0", + id: 1, + error: { code: -32_000, message: "execution reverted" }, + }) + ); + + await expect( + runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)) + ).rejects.toThrow(/execution reverted/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws immediately on non-429 4xx without retrying", async () => { + fetchMock.mockResolvedValueOnce(plainResponse(404, "Not Found")); + + await expect( + runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)) + ).rejects.toThrow(/HTTP 404/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws the last error after exhausting retries", async () => { + fetchMock.mockResolvedValue(plainResponse(429, "Too Many Requests")); + + await expect( + runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD)) + ).rejects.toThrow(/HTTP 429/); + expect(fetchMock).toHaveBeenCalledTimes(RPC_RETRY_CONFIG.MAX_RETRIES + 1); + expect(addBreadcrumbMock).toHaveBeenCalledTimes( + RPC_RETRY_CONFIG.MAX_RETRIES + ); + }); + + it("honors the custom maxRetries argument", async () => { + fetchMock.mockResolvedValue(plainResponse(429, "Too Many Requests")); + + await expect( + runWithTimers(rpcCall(TEST_RPC_URL, TEST_PAYLOAD, 1)) + ).rejects.toThrow(/HTTP 429/); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe("rpcCallWithFailover", () => { + const fetchMock = vi.fn(); + const PRIMARY_URL = "https://primary.rpc.test"; + const FALLBACK_URL = "https://fallback.rpc.test"; + + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + vi.useFakeTimers(); + addBreadcrumbMock.mockClear(); + fetchMock.mockReset(); + vi.spyOn(Math, "random").mockReturnValue(0); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + async function runWithTimers(promise: Promise): Promise { + const settled = promise.then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }) + ); + await vi.runAllTimersAsync(); + const outcome = await settled; + if (outcome.ok) { + return outcome.value; + } + throw outcome.error; + } + + it("returns the primary result when primary succeeds", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0xprimary" }) + ); + + const result = await runWithTimers( + rpcCallWithFailover([PRIMARY_URL, FALLBACK_URL], TEST_PAYLOAD) + ); + + expect(result).toBe("0xprimary"); + expect(fetchMock).toHaveBeenCalledWith( + PRIMARY_URL, + expect.anything() + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("fails over to the fallback URL when primary is exhausted", async () => { + fetchMock.mockImplementation((url: string) => { + if (url === PRIMARY_URL) { + return Promise.resolve(plainResponse(429, "Too Many Requests")); + } + return Promise.resolve( + jsonResponse({ jsonrpc: "2.0", id: 1, result: "0xfallback" }) + ); + }); + + const result = await runWithTimers( + rpcCallWithFailover([PRIMARY_URL, FALLBACK_URL], TEST_PAYLOAD) + ); + + expect(result).toBe("0xfallback"); + // Primary uses the reduced retry budget (1 retry => 2 attempts). + const primaryAttempts = fetchMock.mock.calls.filter( + ([url]) => url === PRIMARY_URL + ).length; + expect(primaryAttempts).toBe( + RPC_RETRY_CONFIG.RETRIES_PER_URL_WITH_FAILOVER + 1 + ); + expect(addBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + category: "rpc.failover", + data: expect.objectContaining({ + failedUrl: PRIMARY_URL, + nextUrl: FALLBACK_URL, + }), + }) + ); + }); + + it("throws the last error when every URL is exhausted", async () => { + fetchMock.mockResolvedValue(plainResponse(429, "Too Many Requests")); + + await expect( + runWithTimers( + rpcCallWithFailover([PRIMARY_URL, FALLBACK_URL], TEST_PAYLOAD) + ) + ).rejects.toThrow(/HTTP 429/); + }); + + it("does not emit a failover breadcrumb when there is only one URL", async () => { + fetchMock.mockResolvedValue(plainResponse(429, "Too Many Requests")); + + await expect( + runWithTimers(rpcCallWithFailover([PRIMARY_URL], TEST_PAYLOAD)) + ).rejects.toThrow(/HTTP 429/); + const failoverBreadcrumbs = addBreadcrumbMock.mock.calls.filter( + (args) => (args[0] as { category?: string }).category === "rpc.failover" + ); + expect(failoverBreadcrumbs).toHaveLength(0); + }); + + it("rejects an empty URL list", async () => { + await expect( + runWithTimers(rpcCallWithFailover([], TEST_PAYLOAD)) + ).rejects.toThrow(/at least one URL/); + }); +});