ERC-4337 for Account Abstraction is great for UX improvements, but it has added significant complexity to smart contract interactions. Ethereum natively only allows transactions that originate by EOAs. By using smart wallets and gas sponsors, this becomes cheaper via batching and "free" for users to execute a transaction. User recovery can also be done.
There are several roles in this:
- UserOperation: A transaction-like object for representing the users intent.
- Smart Contract Account: The sender is a contract that implements logic via
validateUserOp() and executeUserOp().
- Bundler: Off-chain service that acts as an alternative mempool. Collects user ops, packages them and pays the gas.
- Entrypoint: Central on-chain gateway for ERC4337. This validates and routes each user operation.
- Paymaster: Felixible gas payment options. Use the native token or ERC20 tokens. Calls
validatePaymasterUserOp and postOp().
The Entrypoint contract first validates that all operations are valid before executing them. This includes checking gas payments, nonce checks and more. In the execution phase, the entrypoint calls innerHandleOp() to forward the operation to the intended destination. Next, it calls postOp() on the Paymaster (if provided). Finally, the bundler is compensated for gas costs.
The author of this post has audited several implementations of ERC-4337 and has noticed two common issues. The first one is undercalculated gas costs. If the execution gas limit exceeds what's actually used during execution, a 10% penalty is charged, paid to the bundler/deducted from the user's deposit. In the example code, the returned funds includes the penalty - this allows for more funds than put it to be taken from the paymaster.
For ERC20 token transfers, there are two types: pre-payment + refund and post-payment. The pre-payment path is more secure; the issues stem from the post-payment method. If postOp() fails, then the error is just handled. If it's not one of two specific errors, then the state isn't rolled back. In practice, this means that the bundler will still get paid for the failed transaction.
Why does this matter? Even if the
postOp() call fails because it can't collect funds from the user, the paymaster still needs to pay the bundler's gas costs. This is how the attack would work:
- Create a UserOperation with a high gas price.
- Revoke the paymaster's allowance before
postOp() executes.
postOp() fails. The paymaster pays Bundler for their high gas costs without receiving any funds.
- The paymaster loses money since they paid the bundler but couldn't collect from the user. The bundler profits as long as the actual gas costs less than what they charged.
This bug allows for bundlers to drain paymaster deposits. Some paymasters try to protect against this by simulating the UserOperation execution before signing. However, this can be bypassed by approving the transfer during simulation, but rejecting it on the actual call. So, what's the solution? Just don't use the post-payment strategy. If you absolutely have to, restrict usage to a whitelist of trusted bundlers. Just use pre-funding instead.
Overall, a great article explaining how ERC-4337 works and bugs around it.