Skip to content

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

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions