eth: Update gasses and debug bundler failures.#3525
eth: Update gasses and debug bundler failures.#3525JoeGruffins wants to merge 11 commits intodecred:masterfrom
Conversation
27eff2f to
3ce8bc6
Compare
|
Eth and base are working now with alchemy. pol needs something but may just disable for now and make an issue. All gasses are updated. For tokens I only tested USDT then used those values for everything else, which should be fine... txids are included for every contract function. |
4ee2496 to
3c9d466
Compare
e11ae2a to
66fd0ad
Compare
|
oh man, here when I thought we were so close. The NUMBER opcode (block.number) is banned during validateUserOp execution. Our contract uses block.number in three places within validateUserOp: // 1. Double-validation prevention // 2. Swap confirmation check // 3. Record validation block |
fae3b06 to
7989608
Compare
|
Working on the contract again so all of the addresses and gasses need to be redone. |
c88cc39 to
7ca9c67
Compare
|
I have high hopes these values are good now. Polygon is possible for free with the proper settings, but it requires some setup on two different websites |
010a24b to
8f00987
Compare
| // Pay prefund before callGasLimit and signature checks. During gas | ||
| // estimation, the bundler sends zero gas limits and a dummy signature, | ||
| // both of which would fail checks below. The EntryPoint requires the | ||
| // prefund to be paid regardless of validation result (AA21 otherwise). | ||
| _payPrefund(missingAccountFunds); |
There was a problem hiding this comment.
So if the prefund is paid even though the validation fails, the bundler still allows the user op to proceed? This doesn't make sense, because if the validation fails, the bundler wouldn't get the funds sent in _payPrefund.
There was a problem hiding this comment.
The ordering isn't for real execution. It's for gas estimation. When a bundler calls simulateValidation to estimate gas costs, it sends:
- Zero gas limits (so the callGasLimit check at line 481 would fail)
- A dummy signature (so the ECDSA check at line 485 would fail)
The EntryPoint's gas estimation needs to see the prefund transfer to calculate how much gas the account will pay. If _payPrefund were placed after those checks, it would never execute during estimation, and the EntryPoint would revert with AA21 (insufficient deposit).
So the flow is:
- Gas estimation: prefund paid, then validation "fails" on dummy values — that's expected; the EntryPoint got what it needed to estimate costs.
- Real execution: prefund paid, callGasLimit passes, real signature passes — returns SUCCESS.
- Real execution, bad signature: prefund paid, then validation fails — EntryPoint reverts everything including the prefund. No harm done.
| // execution. If execution fails, the flag remains set, preventing | ||
| // future AA redeems for that swap (the regular redeem function can | ||
| // still be used as a fallback). | ||
| mapping(bytes32 => bool) internal pendingValidation; |
There was a problem hiding this comment.
This doesn't prevent any attack vector, only a wallet error where the wallet signs two user ops with different nonces both redeeming the same swap.
There was a problem hiding this comment.
The attack it prevents relies on the two-phase execution model of ERC-4337 bundles.
How EntryPoint processes a bundle
The EntryPoint processes UserOps in two distinct phases:
- Validation phase — calls validateUserOp for every UserOp in the bundle. Each call pays a prefund (gas deposit) from the contract's ETH balance to the EntryPoint.
- Execution phase — calls redeemAA for every UserOp that passed validation.
Critically, all validations complete before any executions begin.
The attack without pendingValidation
Suppose the contract holds 10 ETH across multiple users' swaps, and an attacker has one swap worth 1 ETH.
- Attacker submits a bundle with N identical UserOps, all redeeming the same 1 ETH swap.
- Validation phase: Each validateUserOp call checks swaps[key] — the swap is still in the Filled state because no execution has happened yet. All N pass validation. Each pays a prefund (say 0.05 ETH) from the contract's pooled balance to the EntryPoint. That's N * 0.05 ETH extracted from the contract.
- Execution phase: The first redeemAA succeeds and marks the swap redeemed. The remaining N-1 calls revert on the "already redeemed" check. But the prefunds are not refunded — the EntryPoint keeps them (they go to the bundler as gas payment).
Net result: The contract paid N * 0.05 ETH in prefunds but only 1 swap worth 0.05 ETH in fees was legitimate. The excess came from other users' deposits. Repeat this and you drain the contract into the EntryPoint/bundler.
How pendingValidation stops it
// Line 446-448: in validateUserOp
if (pendingValidation[key]) {
return SIG_VALIDATION_FAILED;
}
// Line 460: after all checks pass
pendingValidation[key] = true;
The first UserOp's validateUserOp sets pendingValidation[key] = true. The second UserOp's validateUserOp sees the flag and returns SIG_VALIDATION_FAILED — no prefund is paid for it. Only one prefund per swap per bundle.
Then in redeemAA (line 536), the flag is cleared:
delete pendingValidation[key];
This re-enables the swap for future AA redemption attempts if needed (though typically a swap is only redeemed once).
In short
The contract holds pooled funds from many users. The prefund is paid from that pool. Without pendingValidation, an attacker can force the contract to pay arbitrarily many prefunds for a single swap by stuffing duplicate UserOps into one bundle, siphoning other users' deposits to the EntryPoint.
|
no idea how I fatfingered this. didn't mean to close |
919715b to
f5e694f
Compare
|
I regret this pr is getting big but fixing some things as I go. Have tested the gasless redeem full path with a server on base testnet. Still want to look into if we really need to set the paymaster when using polygon on mainnet. The contract reorder may have made it not necessary I dont know though. Would like to get these changes in and we can test more edge cases later. |
a885164 to
6361dbd
Compare
|
about the bundler or paymaster being set. seems we can really know if the coin needs a paymaster. We know that polygon with alchemy needs something. The current check: Caller level (gas test line 745, ReserveNRedemptions line 5588):
Inside canRedeemWithBundler (order placement only): Inside checkBundler (both gas test and order placement): So the complete decision tree: |
6361dbd to
950d93d
Compare
|
Applied suggestions from @martonp to determine if the bundler can handle redemption. |
The userOp struct was using v0.6 field names (initCode, paymasterAndData) but being sent to the v0.7 EntryPoint, causing bundlers to reject the operations. Update to the v0.7 unpacked RPC format (factory/factoryData, paymaster/paymasterData, etc.) and update the hash computation to use packed accountGasLimits and gasFees fields. Add optional paymaster support following the ERC-7677 standard so bundler providers that require sponsored user operations (e.g. Alchemy on Polygon) work automatically. The bundler auto-detects from the RPC provider endpoint. For the paymaster, users set the config to their provider's policy ID (e.g. Alchemy Gas Manager policy ID) or a custom paymaster endpoint URL. The policy ID is passed as the context object in pm_getPaymasterStubData and pm_getPaymasterData calls.
Add checkBundler method that validates bundler connectivity (via getGasPrice) and paymaster configuration (via getPaymasterStubData with a dummy user op) before attempting a gasless redeem. This catches bad endpoints, invalid policy IDs, and other misconfigurations early with a clear error instead of failing deep in the user op flow.
Use hexutil.Big instead of big.Int for the l1Fee receipt field so hex strings like "0x431c8e80e" returned by OP Stack RPCs are decoded correctly.
The bundler may submit user operations via the v0.7 EntryPoint's innerHandleOp function (selector 0x765e827f) rather than handleOps. Add ParseInnerHandleOpData to parse the innerHandleOp calldata and fall back to it when ParseHandleOpsData fails in both the server (userOpBaseCoin) and client (swapOrRedemptionFeesPaidUserOp).
The sendAddr input handler was using this.selectedWalletID (the blockchain parent's assetID) for address validation. For L2 wallets like Base, this resolved to Ethereum mainnet (BipID 60) instead of Base (BipID 8453), causing "no wallet found" errors when only the L2 wallet was configured. Read the assetID from the send form's dataset, consistent with the stepSend and sendAmt handlers.
For EVM assets, the secret can be read directly from on-chain contract state without parsing transactions. Reduce the spent-ago threshold from 10 minutes to 30 seconds so the taker's findRedemption fallback kicks in quickly if the server fails to relay the maker's redemption.
When a GaslessRedeem UserOp is used, the redeem notification to the server was never sent because submitted=false deferred the notification, but the deferred send was never implemented. Send the notification when ConfirmRedemption reports the real transaction hash after the bundler submits.
950d93d to
0371ec1
Compare
|
@martonp I like your first approach better, and it seems to work fine. Was there a problem with it? Removed skandha, was it not working? |
0371ec1 to
15f70e8
Compare
closes #3316
The contracts here are pointing at the current v1 contract on chain. However, there are problems:
More context for polygon:
Alchemy requires their Gas Manager (paymaster) for all user operations on Polygon mainnet as of January 30, 2026. This is a hard requirement - you can't send unsponsored UserOps through Alchemy's bundler on Polygon anymore.
Their reasoning is that Polygon lacks a reliable private mempool, so they force all UserOps through their Gas Manager for front-running protection.
This means for gasless redeems on Polygon via Alchemy, you'd need to either: