ERC-4337 Account abstraction with an alternative mempool (Account Abstraction Using Alt Mempool)
Final · Accounts & wallets · created 2021-09-29
A standard that lets smart contract accounts send actions under their own validation rules without a regular account (EOA), and lets another party pay the fees on their behalf. It is implemented with a separate mempool and an EntryPoint contract, without changing the consensus layer.
Why it is needed
To execute anything on Ethereum, a transaction had to be sent from a special account controlled by a private key (an externally owned account, or EOA), and the rules for validating transactions worked only as the protocol fixed them. So even when a smart contract account was created to use features such as multisig, other signature schemes, or account recovery, a separate EOA was still needed to pay the fees. Account abstraction has long been an Ethereum goal, but protocol-changing proposals were discussed many times without being implemented. Consensus layer development is focused on scalability, so there may be no opportunity to change the protocol for the time being. Having someone else pay the fees, paying fees in ERC-20 tokens, and bundling several actions into one were also difficult under the basic transaction model.
What to implement
function handleOps(PackedUserOperation[] calldata ops, address payable beneficiary);: Validates and executes a bundle of UserOperations, then pays the collected fees to the beneficiary.function validateUserOp (PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds) external returns (uint256 validationData);: The account verifies the signature and pays the EntryPoint any missing fee. It returns a value that packs validity and a time range.function executeUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash) external;: The account may optionally implement this, and the EntryPoint calls this function instead of executing callData directly.function getNonce(address sender, uint192 key) external view returns (uint256 nonce);: Reads the current nonce value for a given key of the sender from the EntryPoint.function validatePaymasterUserOp (PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) external returns (bytes memory context, uint256 validationData);: The paymaster decides whether to pay the fees for this UserOperation and returns the context to pass to postOp.function postOp (PostOpMode mode, bytes calldata context, uint256 actualGasCost, uint256 actualUserOpFeePerGas) external;: After execution finishes, the paymaster receives the actual gas cost and does post-processing. It is called only when context is non-empty.function addStake(uint32 _unstakeDelaySec) external payable;: The calling entity adds stake for the reputation system.function unlockStake() external;: Unlocks the stake. It can be withdrawn only after unstakeDelay has passed.function withdrawStake(address payable withdrawAddress) external;: Withdraws the unlocked stake to the specified address.function balanceOf(address account) public view returns (uint256);: Returns the account's deposit in the EntryPoint.function depositTo(address account) public payable;: Adds ETH to the deposit of the specified account.receive() external payable;: Adds ETH to the deposit of the calling account.function withdrawTo(address payable withdrawAddress, uint256 withdrawAmount) external;: Withdraws the calling account's deposit to the specified address.function getCurrentUserOpHash() public view returns (bytes32);: Returns the hash of the UserOperation currently being executed, or 0 if none is being executed.
In depth
UserOperation structure and PackedUserOperation
A UserOperation is a structure that carries the action to be sent on behalf of a user. To avoid confusion it is not called a transaction. Like a transaction, it has nonce, callData, maxFeePerGas, maxPriorityFeePerGas, and signature. It also has sender (the account sending the action), factory and factoryData (the address and data of the factory that creates a new account, or the 0x7702 flag for an EIP-7702 account), callGasLimit (gas for the execution call), verificationGasLimit (gas for the verification step), preVerificationGas (extra gas paid to the bundler), and paymaster, paymasterVerificationGasLimit, paymasterPostOpGasLimit, and paymasterData (paymaster fields). How the signature is interpreted is decided by the account contract implementation, not by the protocol. To prevent replay across chains or across EntryPoint versions, the signature must depend on the chainid and the EntryPoint address. On-chain, a compressed PackedUserOperation is used, which bundles initCode (the factory address concatenated with factoryData, or EIP-7702 data), accountGasLimits (verificationGasLimit as 16 bytes concatenated with callGasLimit as 16 bytes), gasFees (maxPriorityFeePerGas as 16 bytes concatenated with maxFeePerGas as 16 bytes), and paymasterAndData (the paymaster fields concatenated). An EIP-7702 authorization tuple can be passed along with the UserOperation, but it is not included in the struct itself.
The validation loop and execution loop of handleOps
The EntryPoint is a single contract that executes a batch of UserOperations, and its core function is handleOps. It must run two loops, a validation loop and an execution loop. In the validation loop, for each UserOperation, if the sender account does not exist it is created with initCode (it fails if the sender does not exist and initCode is empty, or if no contract can be created at the sender address; if the sender already exists, initCode is ignored). The EntryPoint then calculates the maximum fee the sender will pay and the amount to add to the deposit, using the verification and execution gas limits and the current gas values, and calls the sender's validateUserOp. If any validateUserOp fails, at minimum that UserOperation's execution must be skipped, and the whole bundle may be reverted. Next, it checks that the account deposit covers the maximum cost. In the execution loop, the account is called with callData. If callData starts with the method signature of IAccountExecute.executeUserOp, it is encoded as executeUserOp(userOp,userOpHash) and called. After the call, the remainder of the prepaid cost is refunded to the deposit. However, if the unused gas in callGasLimit and paymasterPostOpGasLimit is 40000 (PENALTY_GAS_THRESHOLD) or more, a 10% penalty (UNUSED_GAS_PENALTY_PERCENT) is charged on that unused amount. This mechanism prevents a UserOperation from reserving gas space in a bundle without using it and thereby blocking other UserOperations. When all calls finish, the collected fees are paid to the beneficiary address specified by the bundler.
validateUserOp and its return value, implemented by the account
A smart contract account must implement validateUserOp of IAccount. userOpHash is a hash over the userOp excluding signature, plus the entryPoint and chainId, computed as an EIP-712 typed message hash. The account must verify that the caller is the trusted EntryPoint and that signature is a valid signature of userOpHash. On a signature mismatch, it is recommended to return SIG_VALIDATION_FAILED (1) instead of reverting, and any other error must revert. To allow gas estimation, it is better to run the normal flow to the end even when returning 1. The account must pay at least missingAccountFunds to the EntryPoint; this can be 0 if the deposit is sufficient, and any excess paid can be recovered with withdrawTo. The return value packs aggregator/authorizer (0 if valid, 1 if the signature failed, otherwise the aggregator contract address), validUntil (a 6-byte timestamp, 0 means no expiry), and validAfter (a 6-byte timestamp). To express the range in block numbers, both values set the highest bit to 1, and timestamps and block numbers cannot be mixed in one range. Optionally, an account can implement executeUserOp of IAccountExecute, in which case the EntryPoint calls this function instead of executing callData directly. Most accounts will be upgraded through proxies or EIP-7702, so when changing the implementation you must ensure that the storage layouts of the two contracts do not collide. A common solution is the diamond storage of ERC-7201.
Semi-abstracted nonce: a 192-bit key and a 64-bit sequence
The sequential nonce in the Ethereum protocol is used for replay protection and ordering, but it is too restrictive for an account that wants to define its own ordering rules. This standard splits a single uint256 nonce into two values, a 192-bit key and a 64-bit sequence. The value is stored in the EntryPoint and read with getNonce(sender, key). The sequence is validated in the EntryPoint for each key, and if validation fails the UserOperation is invalid and the bundle is reverted. The sequence increases monotonically and sequentially per sender, and a new key can be introduced at any time with an arbitrary value, with its sequence starting at 0. This guarantees the on-chain uniqueness of the UserOperation hash while letting an account layer any logic it wants on the key field. A bundler is recommended to start validation with a getNonce call, and to accept multiple UserOperations from the same sender it must track the key and sequence pairs already in the mempool. To enforce a classic sequential nonce, the validation function performs require(userOp.nonce<type(uint64).max). To set up a separate channel for administrative operations, implement a check that the key is ADMIN_KEY when the first 4 bytes of callData are ADMIN_METHODSIG, or otherwise that the key is 0.
Paymaster extension: sponsorship, postOp, stake and deposit
If paymasterAndData is not empty, the EntryPoint follows a different flow. In the validation loop, in addition to validateUserOp, it checks that the paymaster has deposited enough ETH in the EntryPoint and calls validatePaymasterUserOp to confirm the paymaster's willingness to sponsor. In this case the account's validateUserOp receives missingAccountFunds as 0. If validatePaymasterUserOp returns a non-empty context, postOp is called after the execution call, and if it is empty, postOp is not called. The mode of postOp is opSucceeded or opReverted, and even if the execution reverts, the paymaster must still pay for the gas. paymasterAndData is a non-standard encoding that concatenates the 20-byte paymasterAddress, the 16-byte paymasterVerificationGasLimit, the 16-byte postOpGasLimit, and paymasterData. Optionally, paymasterSignature, its 2-byte length paymasterSignatureLength, and the magic value PAYMASTER_SIG_MAGIC (0x22e325a297439656) can be appended. Because signature and paymasterSignature do not affect the UserOperation hash, the sender and the paymaster can sign in parallel. The EntryPoint must provide APIs to manage stake with addStake, unlockStake, and withdrawStake, and deposits with balanceOf, depositTo, receive, and withdrawTo. The deposit from which gas costs are deducted and the locked stake are separate. Stake is not slashed and is used only to prevent Sybil attacks, and it can be withdrawn at any time after unstakeDelay.
Bundler basic checks, simulation, and error codes
A UserOperation arrives at the bundler through eth_sendUserOperation and goes through a first validation before entering the mempool, a second during bundle construction, and a third on the whole bundle before on-chain submission. In the basic checks, the sender must be an existing contract or initCode must be non-empty, but not both. verificationGasLimit and paymasterVerificationGasLimit must be below MAX_VERIFICATION_GAS (500000), and preVerificationGas must cover the serialized calldata cost plus PRE_VERIFICATION_OVERHEAD_GAS (50000). paymasterAndData must be empty or start with a paymaster address that has code, has sufficient deposit, and is not blocked. A sender may have only one UserOperation in the mempool and in a bundle, except for a staked sender. Simulation view-calls handleOps and performs only account creation, validateUserOp, and paymaster validation, and discards the UserOperation if it reverts. validAfter<=block.timestamp must hold, those about to expire soon are also discarded, and reading the returned time range requires tracing. To trace the cause of a failure, the EntryPoint reverts only with the errors SignatureValidationFailed(), FailedOp(), and FailedOpWithRevert(), and the message starts with an AA## code. AA1 is an error during sender creation, AA2 during sender validation (validateUserOp), and AA3 during paymaster validation (validatePaymasterUserOp).
Bundling rules, blocking, and transient storage
Bundling is the process in which a bundler collects multiple UserOperations and makes them into one transaction. A UserOperation that accesses the sender address of another UserOperation in the same bundle, or whose validation accesses an address created by a factory for another UserOperation, must be excluded, and for each paymaster the bundler must track whether the deposit covers all of its UserOperations. After building the bundle, it runs debug_traceCall with the maximum gas to verify the opcode and storage access rules and the whole handleOps transaction, and uses the gas consumed for the actual transaction. If it reverts, the entity that the EntryPoint called last with CALL in the trace is taken as the cause (it does not assume FailedOp), and the corresponding UserOperation is removed from the bundle and the mempool. If the cause is a factory or paymaster and the sender is not staked, that factory or paymaster is blocked, and if the sender is staked, the sender is blocked instead. This is repeated until debug_traceCall succeeds. This presupposes a reputation system that throttles or temporarily bans global entities (paymasters and factories) that invalidate multiple UserOperations, and stake is required to prevent Sybil attacks. Stake is not enforced on-chain; each bundler checks it during simulation, and staked entities get relaxed storage usage limits. Because UserOperations from unrelated senders can enter the same transaction, contracts that use EIP-1153 transient storage must clean up on their own any transient storage used for sensitive information or access control.
First account creation and EIP-7702 support
The design goal is that users can create an address first and receive funds right away, like an EOA. Account creation is handled by a factory, and initCode is interpreted as a 20-byte factory address followed by calldata. The factory is expected to use CREATE2 0xF5 rather than CREATE 0xF0 so that creation order does not affect the address, and it must return the address even if the account has already been created. A wallet obtains the pre-creation address by making a static call to entryPoint.getSenderAddress(). The EntryPoint does not call initCode directly, and the factory must verify that its createAccount() call comes from entryPoint.senderCreator(). The created address must depend on the initial signature, and a factory that accesses global storage must stake. On networks with EIP-7702 enabled, eth_sendUserOperation additionally accepts an eip7702Auth parameter, and the bundler places it in the authorizationList and executes the bundle in a SET_CODE_TX_TYPE transaction. If factory is 0x7702, the sender must be an EOA with an EIP-7702 delegation. If initCode starts with 0x7702 followed by 18 zero bytes, the first 20 bytes are replaced with the account's EIP-7702 delegation address when computing the hash, and the remainder beyond 20 bytes is used to call the account's own initialization function. This initialization function must also be callable only from entryPoint.senderCreator(). The EntryPoint cannot tell whether initialization has already happened and so may call it multiple times, so it is best to allow it only once. The EIP-7702 authorization cost PER_EMPTY_ACCOUNT_COST (25000) is not visible to the EntryPoint, so it must be included in preVerificationGas.
Things to watch out for
- Trust concentrated in the EntryPoint: The EntryPoint is the central trust point for all ERC-4337 accounts, so it needs auditing and formal verification. Each individual account only has to check the signature verification and fee payment logic in validateUserOp and the msg.sender == ENTRY_POINT gate on its other functions. This reduces the burden on the ecosystem as a whole, but the risk is concentrated in the single EntryPoint.
- Preventing arbitrary takeover: The EntryPoint must call a sender with userOp.calldata only if validateUserOp for that sender has passed. This property must be verified.
- Preventing fee leakage: If the EntryPoint called validateUserOp and it passed, the EntryPoint must actually perform the call with the same calldata as userOp.calldata. This condition prevents collecting fees without executing.