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
- 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).
- Call
BundlerClient.sendUserOperation(...) with any non-trivial UserOp (e.g. ERC-20 approve + a contract call batched into one op).
- SDK builds the UserOp with
maxFeePerGas = 3 gwei (the hardcoded floor).
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!
Summary
BundlerApiImpl.ktdefaultsmaxFeePerGastomax(3 gwei, 2 × estimated)(lines 170–184), thenpm_getPaymasterDataevaluates 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 at0.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: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
estimateFeesPerGascallbacks 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
Max spend USD per transaction = $0.15(this is comfortably above real on-chain cost: ~$0.005 today).BundlerClient.sendUserOperation(...)with any non-trivial UserOp (e.g. ERC-20approve+ a contract call batched into one op).maxFeePerGas = 3 gwei(the hardcoded floor).pm_getPaymasterDatarejects 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:Live Base mainnet base fee at the time of this log, via
eth_gasPriceon https://mainnet.base.org: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-184The
.max(defaultMaxFeePerGas, 2 × estimated)clamp means even whenestimateFeesPerGas()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 callseth_gasPriceagainst 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):
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'sEstimateFeesPerGasResultshape). Users who want a paranoid floor can add it themselves via theestimateFeesPerGascallback.Both options preserve the existing
estimateFeesPerGascallback 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 anestimateFeesPerGasoverride pulling frometh_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
BundlerApiImpl.kt:170-184onmaineth_gasPricesnapshot at filing time:Thanks!