Skip to content

BundlerApiImpl hardcoded 3-gwei maxFeePerGas floor breaks Gas Station policy on every L2 #15

@superbigroach

Description

@superbigroach

Summary

BundlerApiImpl.kt defaults maxFeePerGas to max(3 gwei, 2 × estimated) (lines 170–184), then pm_getPaymasterData evaluates the worst-case spend (gas × maxFeePerGas) against the user's Gas Station per-tx policy cap. The 3-gwei floor is reasonable for L1 Ethereum but is two to three orders of magnitude above real network gas prices on every popular L2, which means even modest per-tx policy caps reject otherwise-trivial UserOperations on Base, Arbitrum, Optimism, etc.

Concretely: every UserOp coming out of the Android SDK on Base mainnet today gets a worst-case quote of 3 gwei × ~250k gas ≈ $2.20+ (at ETH $3k), while the real on-chain cost lands at 0.006 gwei × ~250k gas ≈ $0.005. A Gas Station policy with a per-tx cap of, say, $0.15 — perfectly reasonable for the actual cost profile of these L2 UserOps — immediately rejects every request:

Error returned from API. Error: Exceeded max spend USD per transaction of the policy.

The only workarounds today are (a) raising the policy cap by 100×+ and accepting the loss of the policy as a real safety bound, or (b) plumbing estimateFeesPerGas callbacks through every call site to suppress the floor. (a) is unsafe — it defeats the whole purpose of the policy on a chain where real gas spikes are still in the sub-dollar range. (b) shifts the burden to every SDK consumer.

Reproduction

  1. Configure a Gas Station policy on Base mainnet with Max spend USD per transaction = $0.15 (this is comfortably above real on-chain cost: ~$0.005 today).
  2. Call BundlerClient.sendUserOperation(...) with any non-trivial UserOp (e.g. ERC-20 approve + a contract call batched into one op).
  3. SDK builds the UserOp with maxFeePerGas = 3 gwei (the hardcoded floor).
  4. pm_getPaymasterData rejects with the policy-cap error above.

Captured Android log from a real UserOp (USDC approve + a one-shot contract call, ~250k gas total) on Base mainnet today:

E/CircleWalletManager: executeContractCallBatch failed: RpcRequestError: RPC Request failed.
URL: https://modular-sdk.circle.com/v1/rpc/w3s/buidl/base
Request body: {"method":"pm_getPaymasterData","params":[{...
  "maxFeePerGas":"0xb2d05e00",         // 3 gwei
  "maxPriorityFeePerGas":"0x3b9aca00", // 1 gwei
  ...
}]}
Details: Exceeded max spend USD per transaction of the policy.

Live Base mainnet base fee at the time of this log, via eth_gasPrice on https://mainnet.base.org:

gasPrice (baseFee): 0.006 gwei
maxFeePerGas:       0.011 gwei
Real cost (baseFee × 251k gas):   $0.0045
Worst case (3 gwei × 251k gas):   $2.26   ← what the SDK quoted, what the policy checks

That's a ~500× overestimate of the policy-relevant quote, on what is currently Circle's flagship L2 for Modular Wallets.

Root cause

lib/src/main/java/com/circle/modularwallets/core/apis/bundler/BundlerApiImpl.kt:170-184

val defaultMaxFeePerGas = parseGwei("3")
val defaultMaxPriorityFeePerGas = parseGwei("1")
val fees = publicApi.estimateFeesPerGas(...)
// ...
userOp.maxFeePerGas = defaultMaxFeePerGas.max(it.multiply(two))

The .max(defaultMaxFeePerGas, 2 × estimated) clamp means even when estimateFeesPerGas() returns the correct sub-gwei value for Base / Arb / OP, the floor wins and the user's UserOp gets the L1 quote. The SDK never calls eth_gasPrice against the target chain to sanity-check.

The iOS SDK mirrors the same defaults; the Web SDK has the same pattern.

Suggested fix

Drop the 3-gwei floor on chain IDs where it's empirically wrong. Either:

Option A — chain-aware default (minimum-disruption):

private fun defaultMaxFeePerGasFor(chain: Chain): BigInteger = when (chain.chainId) {
    8453L, 84532L,                  // Base, Base Sepolia
    42161L, 421614L,                // Arbitrum One, Arbitrum Sepolia
    10L, 11155420L,                 // Optimism, Optimism Sepolia
    137L, 80002L                    // Polygon, Polygon Amoy
        -> parseGwei("0.05")        // L2s: a few cents of headroom over real prices
    else -> parseGwei("3")          // L1 Ethereum / mainnet-class chains keep the L1-tuned default
}

Option B — trust the estimator (cleanest, but a bigger behavioural change):

Drop the floor entirely. Use whatever publicApi.estimateFeesPerGas(...) returns, plus a configurable multiplier (already exists in the SDK's EstimateFeesPerGasResult shape). Users who want a paranoid floor can add it themselves via the estimateFeesPerGas callback.

Both options preserve the existing estimateFeesPerGas callback as the explicit override knob.

Why I'm filing this

Spent half a day debugging "Wallet unavailable, please try again" on Base mainnet UserOps in a production app today. The error returned to the SDK is unhelpful (RPC Request failed.) and the actual policy-violation explanation only appears in the SDK's debug log details. Once we tracked it back to the 3-gwei floor and started supplying an estimateFeesPerGas override pulling from eth_gasPrice, everything just worked.

Happy to open a draft PR if you'd like a starting point — let me know which option you prefer. Either way I think this is going to bite every Modular Wallets consumer who turns on Gas Station policy enforcement on an L2.

Repo / chain / version context

  • Affected SDK: this repo, BundlerApiImpl.kt:170-184 on main
  • Affected chains observed: Base mainnet (8453), Base Sepolia (84532), Arbitrum Sepolia (421614)
  • Live eth_gasPrice snapshot at filing time:
    Chain baseFee maxFee quoted by SDK Real cost on 251k gas Worst-case checked vs policy
    Base Mainnet 0.006 gwei 3 gwei $0.0045 $2.26
    Base Sepolia 0.006 gwei 3 gwei $0.0045 $2.26
    Arb Sepolia 0.020 gwei 3 gwei $0.015 $2.26

Thanks!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions