From e5a3fef7b7ea7724e96eee90446f16953c8a0b12 Mon Sep 17 00:00:00 2001 From: Joel Orzet Date: Mon, 20 Apr 2026 19:47:49 -0300 Subject: [PATCH 1/4] fix: guard wallet RPC calls against undefined result and retry transients Consolidate native and ERC20 balance fetchers onto a shared rpcCall helper with exponential backoff for HTTP 429, 5xx, network errors, and malformed gateway responses with missing result fields. The missing-result case was surfacing as BigInt(undefined) on the analytics page. --- lib/wallet/fetch-balances.ts | 359 +++++++++++++++++++---------------- 1 file changed, 192 insertions(+), 167 deletions(-) diff --git a/lib/wallet/fetch-balances.ts b/lib/wallet/fetch-balances.ts index 46fac8206..efb14dafb 100644 --- a/lib/wallet/fetch-balances.ts +++ b/lib/wallet/fetch-balances.ts @@ -87,6 +87,138 @@ function buildExplorerAddressUrl( return `${chain.explorerUrl}${path.replace("{address}", address)}`; } +/** + * Encode an ERC20 `balanceOf(address)` call payload. + */ +function encodeBalanceOfCallData(address: string): string { + const balanceOfSelector = "0x70a08231"; + const stripped = address.startsWith("0x") ? address.slice(2) : address; + const padded = stripped.toLowerCase().padStart(64, "0"); + return `${balanceOfSelector}${padded}`; +} + +/** + * Parse a hex wei string into BigInt, treating empty `"0x"` as zero. + * Caller must ensure hex is a non-empty string (rpcCall guarantees this). + */ +function hexWeiToBigInt(hex: string): bigint { + return hex === "0x" ? BIGINT_ZERO : BigInt(hex); +} + +type JsonRpcPayload = { + jsonrpc: "2.0"; + method: string; + params: unknown[]; + id: number; +}; + +/** + * RPC retry configuration. + * + * Two exponential-backoff schedules, 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. + * + * Schedule = `min(BASE_MS * 2^attempt, CAP_MS)`. + * + * With MAX_RETRIES = 3: + * - STANDARD delays: 500ms, 1s, 2s (total ~3.5s across 4 attempts) + * - RATE_LIMIT delays: 1s, 2s, 4s (total ~7s across 4 attempts) + */ +const RPC_RETRY_CONFIG = { + MAX_RETRIES: 3, + STANDARD: { + BASE_MS: 500, + CAP_MS: 3000, + }, + RATE_LIMIT: { + BASE_MS: 1000, + CAP_MS: 5000, + }, +} as const; + +type RpcFailureKind = "standard" | "rate_limit"; + +function getRpcBackoffMs(attempt: number, kind: RpcFailureKind): number { + const schedule = + kind === "rate_limit" + ? RPC_RETRY_CONFIG.RATE_LIMIT + : RPC_RETRY_CONFIG.STANDARD; + return Math.min(schedule.BASE_MS * 2 ** attempt, schedule.CAP_MS); +} + +/** + * 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. + * + * Returns the raw `result` string (guaranteed non-empty). Callers interpret + * `"0x"` per their context via {@link hexWeiToBigInt}. + */ +async function rpcCall( + rpcUrl: string, + payload: JsonRpcPayload +): Promise { + let lastError: Error = new Error("RPC call failed"); + let lastFailureKind: RpcFailureKind = "standard"; + + for (let attempt = 0; attempt <= RPC_RETRY_CONFIG.MAX_RETRIES; attempt++) { + if (attempt > 0) { + await delay(getRpcBackoffMs(attempt - 1, lastFailureKind)); + } + + 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; +} + /** * Fetch native token balance for a single chain */ @@ -95,23 +227,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 rpcCall(chain.defaultPrimaryRpc, { + 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 +277,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 rpcCall(chain.defaultPrimaryRpc, { + 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 +363,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 rpcCall(chain.defaultPrimaryRpc, { + 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), + }; + } } /** From 6f77e7ad64f648172d94d26b95f93ca995984725 Mon Sep 17 00:00:00 2001 From: Joel Orzet Date: Mon, 20 Apr 2026 19:57:09 -0300 Subject: [PATCH 2/4] refactor: extract wallet RPC client with jitter, breadcrumbs, and address validation Split rpcCall and helpers out of fetch-balances.ts into lib/wallet/rpc.ts so the retry logic is unit-testable in isolation. Add randomized jitter (up to 30%) to the backoff to avoid lockstep retries, and clamp the absolute maximum delay to 5s regardless of schedule. Validate EVM addresses before encoding balanceOf call data so an oversized input cannot silently produce wrong call data via padStart. Emit a Sentry breadcrumb on every retry attempt so the retry history is attached to any captured exception. Add lib/wallet/rpc.test.ts covering encodeBalanceOfCallData validation, hexWeiToBigInt edge cases, getRpcBackoffMs jitter and cap behavior, and all rpcCall retry/throw branches. --- lib/wallet/fetch-balances.ts | 133 +--------------- lib/wallet/rpc.test.ts | 290 +++++++++++++++++++++++++++++++++++ lib/wallet/rpc.ts | 183 ++++++++++++++++++++++ 3 files changed, 474 insertions(+), 132 deletions(-) create mode 100644 lib/wallet/rpc.test.ts create mode 100644 lib/wallet/rpc.ts diff --git a/lib/wallet/fetch-balances.ts b/lib/wallet/fetch-balances.ts index efb14dafb..422775ebe 100644 --- a/lib/wallet/fetch-balances.ts +++ b/lib/wallet/fetch-balances.ts @@ -3,6 +3,7 @@ */ import { ErrorCategory, logUserError } from "@/lib/logging"; +import { encodeBalanceOfCallData, hexWeiToBigInt, rpcCall } from "./rpc"; import type { ChainBalance, ChainData, @@ -87,138 +88,6 @@ function buildExplorerAddressUrl( return `${chain.explorerUrl}${path.replace("{address}", address)}`; } -/** - * Encode an ERC20 `balanceOf(address)` call payload. - */ -function encodeBalanceOfCallData(address: string): string { - const balanceOfSelector = "0x70a08231"; - const stripped = address.startsWith("0x") ? address.slice(2) : address; - const padded = stripped.toLowerCase().padStart(64, "0"); - return `${balanceOfSelector}${padded}`; -} - -/** - * Parse a hex wei string into BigInt, treating empty `"0x"` as zero. - * Caller must ensure hex is a non-empty string (rpcCall guarantees this). - */ -function hexWeiToBigInt(hex: string): bigint { - return hex === "0x" ? BIGINT_ZERO : BigInt(hex); -} - -type JsonRpcPayload = { - jsonrpc: "2.0"; - method: string; - params: unknown[]; - id: number; -}; - -/** - * RPC retry configuration. - * - * Two exponential-backoff schedules, 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. - * - * Schedule = `min(BASE_MS * 2^attempt, CAP_MS)`. - * - * With MAX_RETRIES = 3: - * - STANDARD delays: 500ms, 1s, 2s (total ~3.5s across 4 attempts) - * - RATE_LIMIT delays: 1s, 2s, 4s (total ~7s across 4 attempts) - */ -const RPC_RETRY_CONFIG = { - MAX_RETRIES: 3, - STANDARD: { - BASE_MS: 500, - CAP_MS: 3000, - }, - RATE_LIMIT: { - BASE_MS: 1000, - CAP_MS: 5000, - }, -} as const; - -type RpcFailureKind = "standard" | "rate_limit"; - -function getRpcBackoffMs(attempt: number, kind: RpcFailureKind): number { - const schedule = - kind === "rate_limit" - ? RPC_RETRY_CONFIG.RATE_LIMIT - : RPC_RETRY_CONFIG.STANDARD; - return Math.min(schedule.BASE_MS * 2 ** attempt, schedule.CAP_MS); -} - -/** - * 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. - * - * Returns the raw `result` string (guaranteed non-empty). Callers interpret - * `"0x"` per their context via {@link hexWeiToBigInt}. - */ -async function rpcCall( - rpcUrl: string, - payload: JsonRpcPayload -): Promise { - let lastError: Error = new Error("RPC call failed"); - let lastFailureKind: RpcFailureKind = "standard"; - - for (let attempt = 0; attempt <= RPC_RETRY_CONFIG.MAX_RETRIES; attempt++) { - if (attempt > 0) { - await delay(getRpcBackoffMs(attempt - 1, lastFailureKind)); - } - - 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; -} - /** * Fetch native token balance for a single chain */ diff --git a/lib/wallet/rpc.test.ts b/lib/wallet/rpc.test.ts new file mode 100644 index 000000000..88eddf114 --- /dev/null +++ b/lib/wallet/rpc.test.ts @@ -0,0 +1,290 @@ +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, +} from "./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 + ); + }); +}); diff --git a/lib/wallet/rpc.ts b/lib/wallet/rpc.ts new file mode 100644 index 000000000..11c7c5939 --- /dev/null +++ b/lib/wallet/rpc.ts @@ -0,0 +1,183 @@ +/** + * 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`. + * + * With MAX_RETRIES = 3 and JITTER_FACTOR = 0.3, each individual delay is + * capped at ABSOLUTE_MAX_BACKOFF_MS = 5s regardless of schedule. + */ +export const RPC_RETRY_CONFIG = { + MAX_RETRIES: 3, + 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 +): Promise { + let lastError: Error = new Error("RPC call failed"); + let lastFailureKind: RpcFailureKind = "standard"; + + for (let attempt = 0; attempt <= RPC_RETRY_CONFIG.MAX_RETRIES; 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; +} From cc0f88ec744a2c076dc83aa83391344984dfa6d0 Mon Sep 17 00:00:00 2001 From: Joel Orzet Date: Mon, 20 Apr 2026 20:49:29 -0300 Subject: [PATCH 3/4] feat: fail over wallet balance fetches to secondary RPC when primary fails Wire the chain-level default_fallback_rpc through ChainData so the wallet balance fetchers can fail over when the primary RPC is throttled or down. Add rpcCallWithFailover that walks the URL list in order with a reduced per-URL retry budget so the handoff is fast. Emit rpc.failover breadcrumbs for every hop. The user now sees an error only when every configured RPC is exhausted, not when the primary alone gets a 429. --- lib/wallet/fetch-balances.ts | 22 +++++-- lib/wallet/rpc.test.ts | 121 +++++++++++++++++++++++++++++++++++ lib/wallet/rpc.ts | 60 +++++++++++++++-- lib/wallet/types.ts | 1 + 4 files changed, 196 insertions(+), 8 deletions(-) diff --git a/lib/wallet/fetch-balances.ts b/lib/wallet/fetch-balances.ts index 422775ebe..8c9fbf342 100644 --- a/lib/wallet/fetch-balances.ts +++ b/lib/wallet/fetch-balances.ts @@ -3,7 +3,11 @@ */ import { ErrorCategory, logUserError } from "@/lib/logging"; -import { encodeBalanceOfCallData, hexWeiToBigInt, rpcCall } from "./rpc"; +import { + encodeBalanceOfCallData, + hexWeiToBigInt, + rpcCallWithFailover, +} from "./rpc"; import type { ChainBalance, ChainData, @@ -88,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 */ @@ -96,7 +110,7 @@ export async function fetchNativeBalance( chain: ChainData ): Promise { try { - const resultHex = await rpcCall(chain.defaultPrimaryRpc, { + const resultHex = await rpcCallWithFailover(getChainRpcUrls(chain), { jsonrpc: "2.0", method: "eth_getBalance", params: [address, "latest"], @@ -146,7 +160,7 @@ export async function fetchTokenBalance( chain: ChainData ): Promise { try { - const resultHex = await rpcCall(chain.defaultPrimaryRpc, { + const resultHex = await rpcCallWithFailover(getChainRpcUrls(chain), { jsonrpc: "2.0", method: "eth_call", params: [ @@ -238,7 +252,7 @@ export async function fetchSupportedTokenBalance( chain: ChainData ): Promise { try { - const resultHex = await rpcCall(chain.defaultPrimaryRpc, { + const resultHex = await rpcCallWithFailover(getChainRpcUrls(chain), { jsonrpc: "2.0", method: "eth_call", params: [ diff --git a/lib/wallet/rpc.test.ts b/lib/wallet/rpc.test.ts index 88eddf114..8f5e8ffe5 100644 --- a/lib/wallet/rpc.test.ts +++ b/lib/wallet/rpc.test.ts @@ -12,6 +12,7 @@ import { type JsonRpcPayload, RPC_RETRY_CONFIG, rpcCall, + rpcCallWithFailover, } from "./rpc"; const VALID_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; @@ -287,4 +288,124 @@ describe("rpcCall", () => { 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/); + }); }); diff --git a/lib/wallet/rpc.ts b/lib/wallet/rpc.ts index 11c7c5939..42957ab57 100644 --- a/lib/wallet/rpc.ts +++ b/lib/wallet/rpc.ts @@ -36,11 +36,13 @@ export type JsonRpcPayload = { * Each delay = `min((BASE_MS * 2^attempt) + jitter, ABSOLUTE_MAX_BACKOFF_MS)` * where `jitter = random() * base * JITTER_FACTOR`. * - * With MAX_RETRIES = 3 and JITTER_FACTOR = 0.3, each individual delay is - * capped at ABSOLUTE_MAX_BACKOFF_MS = 5s regardless of schedule. + * `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: { @@ -113,12 +115,13 @@ export function hexWeiToBigInt(hex: string): bigint { */ export async function rpcCall( rpcUrl: string, - payload: JsonRpcPayload + 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 <= RPC_RETRY_CONFIG.MAX_RETRIES; attempt++) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { if (attempt > 0) { const backoffMs = getRpcBackoffMs(attempt - 1, lastFailureKind); addBreadcrumb({ @@ -181,3 +184,52 @@ export async function rpcCall( 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; From f35ac72747386843d184a5c46aa84e49a44d9620 Mon Sep 17 00:00:00 2001 From: Joel Orzet Date: Mon, 20 Apr 2026 20:51:08 -0300 Subject: [PATCH 4/4] chore: move wallet rpc test to tests/unit to match project convention --- lib/wallet/rpc.test.ts => tests/unit/wallet-rpc.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename lib/wallet/rpc.test.ts => tests/unit/wallet-rpc.test.ts (99%) diff --git a/lib/wallet/rpc.test.ts b/tests/unit/wallet-rpc.test.ts similarity index 99% rename from lib/wallet/rpc.test.ts rename to tests/unit/wallet-rpc.test.ts index 8f5e8ffe5..7b921a5cc 100644 --- a/lib/wallet/rpc.test.ts +++ b/tests/unit/wallet-rpc.test.ts @@ -13,7 +13,7 @@ import { RPC_RETRY_CONFIG, rpcCall, rpcCallWithFailover, -} from "./rpc"; +} from "@/lib/wallet/rpc"; const VALID_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; const TEST_RPC_URL = "https://rpc.example.test";