From 57ae0dcbb78ed537c8c8d4cd038a9be67fcc3355 Mon Sep 17 00:00:00 2001 From: Nic-dorman Date: Fri, 7 Aug 2026 15:02:11 +0100 Subject: [PATCH] fix(payment): pin real Arbitrum RPC transports so large gas preflights succeed The gas preflight added for explicit payment gas limits routes eth_estimateGas through the app's own wagmi public client. Without an explicit transport, Reown's WagmiAdapter points that client at its RPC proxy (rpc.walletconnect.org), which rejects any request body over 16KB with an HTTP 403 - and since the adapter also rewrites the chain's default RPC list to the same proxy URL, the viem fallback transport retried the identical broken endpoint. Large payForMerkleTree / payForQuotes estimates (a ~460MB merkle upload is ~100KB of calldata) therefore failed deterministically before any wallet prompt, surfacing as "Payment would fail on-chain: HTTP request failed." Two changes: - Pin explicit transports (arb1.arbitrum.io/rpc, sepolia-rollup) on the WagmiAdapter. The adapter keeps its proxy as an automatic fallback leg, so we gain the real RPC as primary without losing redundancy. - When every preflight attempt dies at the transport layer, say "Couldn't reach the Arbitrum RPC to estimate gas" instead of falsely claiming the payment would fail on-chain. Verified live: 462MB (119-chunk, depth-7) merkle upload now reaches the wallet with a normal fee quote. Co-Authored-By: Claude Fable 5 --- plugins/appkit.client.ts | 13 +++++++++++++ tests/utils/payment.test.ts | 23 +++++++++++++++++++++++ utils/payment.ts | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/plugins/appkit.client.ts b/plugins/appkit.client.ts index 1ef9bd9..cc1d57b 100644 --- a/plugins/appkit.client.ts +++ b/plugins/appkit.client.ts @@ -2,6 +2,7 @@ import { createAppKit } from '@reown/appkit/vue' import { WagmiAdapter } from '@reown/appkit-adapter-wagmi' import { arbitrumSepolia } from '@reown/appkit/networks' import type { AppKitNetwork } from '@reown/appkit/networks' +import { http } from '@wagmi/core' import { invoke } from '@tauri-apps/api/core' import { WALLETCONNECT_PROJECT_ID, @@ -71,6 +72,18 @@ export default defineNuxtPlugin(async () => { projectId: WALLETCONNECT_PROJECT_ID, networks, connectors: [browserBridge()], + // Without an explicit transport, the adapter routes every read and + // gas estimate through Reown's RPC proxy (rpc.walletconnect.org), + // which 403s any request body over 16KB — large payForMerkleTree / + // payForQuotes estimates exceed that. Its own "fallback" is the same + // proxy URL again (extendCaipNetwork rewrites rpcUrls.default), so + // the chain's real RPC was never tried. Pinning the canonical + // Arbitrum endpoints makes them primary; the adapter keeps the Reown + // proxy as an automatic fallback leg behind each. + transports: { + [SUPPORTED_CHAIN.id]: http('https://arb1.arbitrum.io/rpc'), + [arbitrumSepolia.id]: http('https://sepolia-rollup.arbitrum.io/rpc'), + }, }) const appkit = createAppKit({ diff --git a/tests/utils/payment.test.ts b/tests/utils/payment.test.ts index d03f899..1081268 100644 --- a/tests/utils/payment.test.ts +++ b/tests/utils/payment.test.ts @@ -125,6 +125,29 @@ describe('payment', () => { expect(writeContract).not.toHaveBeenCalled() }) + it('reports a transport failure as an unreachable RPC, not an on-chain verdict', async () => { + vi.useFakeTimers() + // Shape of a real failure: viem wraps the transport error, keeping it + // in the cause chain (e.g. Reown's RPC proxy 403ing a >16KB estimate). + const httpError = Object.assign(new Error('HTTP request failed.\nURL: …'), { + name: 'HttpRequestError', + shortMessage: 'HTTP request failed.', + }) + const wrapped = Object.assign(new Error('Gas estimation failed'), { + shortMessage: 'HTTP request failed.', + cause: httpError, + }) + estimateContractGas.mockRejectedValue(wrapped) + + const assertion = expect(payForQuotes({} as any, PAYMENTS)).rejects.toThrow( + "Couldn't reach the Arbitrum RPC to estimate gas: HTTP request failed.", + ) + await vi.advanceTimersByTimeAsync(3_000) + await assertion + + expect(writeContract).not.toHaveBeenCalled() + }) + it('preflights the approve tx too when an approval is needed', async () => { vi.mocked(readContract).mockResolvedValue(0n) estimateContractGas.mockResolvedValue(60_000n) diff --git a/utils/payment.ts b/utils/payment.ts index 863eff9..c261942 100644 --- a/utils/payment.ts +++ b/utils/payment.ts @@ -114,6 +114,17 @@ function shortReason(e: any): string { return e?.shortMessage ?? String(e?.message ?? e).split('\n')[0] } +/** True when the estimate never reached the chain: the RPC transport failed + * (HTTP error, timeout) rather than the node simulating a revert. viem + * nests the transport error inside the `cause` chain of the wrapper that + * estimateContractGas throws. */ +function isTransportError(e: any): boolean { + for (let err = e, depth = 0; err && depth < 10; err = err.cause, depth++) { + if (err.name === 'HttpRequestError' || err.name === 'TimeoutError') return true + } + return false +} + /** * Estimate the gas limit for a contract write on our own transport, with * retries: right after an approve receipt, a load-balanced public RPC can @@ -138,6 +149,13 @@ async function preflightGasLimit( } } } + if (isTransportError(lastError)) { + // Not an on-chain verdict — we never got one. Say so instead of + // implying the payment itself is doomed. + throw new Error(`Couldn't reach the Arbitrum RPC to estimate gas: ${shortReason(lastError)}`, { + cause: lastError, + }) + } throw new Error(`${label} would fail on-chain: ${shortReason(lastError)}`, { cause: lastError }) }