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 }) }