ERC-4337
ERC-4337 is a token standard[1] introduces account abstraction to EVM-compatible chains without consensus-level changes by using an alt-mempool. Account abstraction added user experience improvements that help with scalability of the ecosystem. Functionalities introduced from this standard include gas-less payments, alternative signature schemes and programmable transaction logic for users. This isn't the first standard to introduce account abstraction but the reason it overshadows previous EIPs is that it doesn't require a hard-fork to implement.
History
During March 1st 2023 Yoav Weiss, a security from the Ethereum Foundation, announced that the primary contracts for ERC-4337, commonly referred to as "account abstraction" by blockchain developers, had undergone an audit by Open Zeppelin and were approved for use on all Ethereum Virtual Machine (EVM) compatible networks, including Polygon, Optimism, Arbitrum, BNB Smart Chain, Avalanche, and Gnosis Chain.
Concepts from past EIPs
ERC-4337 isn't the first to introduce account abstraction to the ecosystem, furthermore it's an amalgamation of different concepts from other EIPs, some examples are:
- “Trusted forwarder” concept from EIP-86 [Dropped] which introduced create2
- Custom msg.sender + msg.data provided from trusted forwarder from EIP-2771 [Live]
- EIP-2938 [Dropped], new opcodes introduced NONCE & PAYGAS. But this required significant change in protocol so dropped
- Allowing contracts to control EOAs via signatures from EIP-3074 [In review]
Components of ERC-4337
Smart accounts aka 4337-compatible wallets
ERC-4337 compatible wallets, aka smart accounts are smart contracts that have built in functions that adhere to the ERC-4337's specifications. This mainly includes the 2 main functionс:
validateUserOp
, it takes aUserOp
as input and verifies the signature and nonce on it, pays the fee and increments nonce if verification succeeds and throws an exception if verification fails.- An op execution function, defaulted as
execute
,that interprets calldata for the wallet to take actions
Other than the above 2 functions, the wallet could implement different authentication methods for users to authenticate themselves aside from using their seed phrase like EOAs.
User Operations
UserOps
are ABI-encoded structures comparable to internal transactions in smart contracts, enabling multiple atomic contract interactions in a single transaction. These are sent by the dApp to the alt-mempool.
Field | Type | Description |
---|---|---|
sender
|
address
|
The account making the operation |
nonce
|
uint256
|
Anti-replay parameter (see “Semi-abstracted Nonce Support” ) |
initCode
|
bytes
|
The initCode of the account (needed if and only if the account is not yet on-chain and needs to be created) |
callData
|
bytes
|
The data to pass to the sender during the main execution call
|
callGasLimit
|
uint256
|
The amount of gas to allocate the main execution call |
verificationGasLimit
|
uint256
|
The amount of gas to allocate for the verification step |
preVerificationGas
|
uint256
|
The amount of gas to pay for to compensate the bundler for pre-verification execution, calldata and any gas overhead that can’t be tracked on-chain |
maxFeePerGas
|
uint256
|
Maximum fee per gas (similar to EIP-1559 max_fee_per_gas )
|
maxPriorityFeePerGas
|
uint256
|
Maximum priority fee per gas (similar to EIP-1559 max_priority_fee_per_gas )
|
paymasterAndData
|
bytes
|
Address of paymaster sponsoring the transaction, followed by extra data to send to the paymaster (empty for self-sponsored transaction) |
signature
|
bytes
|
Data passed into the account along with the nonce during the verification step |
Example of UserOp Creation
// 1. Building a `User Operation`;
UserOperation memory op;
op.sender = address(walletAlice);
op.nonce = walletAlice.nonce();
op.callData = abi.encodeCall(SimpleAccount.execute, (address(this), 123, "Hello"));
op.callGasLimit = 1e6;
op.verificationGasLimit = 1e5;
op.preVerificationGas = 50e3; // Small constant
op.maxFeePerGas = 30e9; // Max gas cost for user (Similar to EIP-1559)
op.maxPriorityFeePerGas = 5e9
// Alice signs the `UserOperation` data.
bytes32 userOpHash = entryPoint.getUserOpHash(op);
bytes32 signHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", userOpHash));
(uint4 v, bytes32 r, bytes32 s) = vm.sign(alicePk, signHash);
op.signature = abi.encodePacked(r, s, v);
Account factory contract
In the initCode
section of the UserOp field, users can initialize and create a smart account if they don't already have one.
The logic of this is highly dependant on the protocol in charge of creating these wallets, but should adhere by the standards of this token standard.
In order to function properly, on creation, wallets should individually trust the EntryPoint contract for ERC-4337 transactions to be possible.
Bundlers
Bundlers primary take in UserOps
from the alt-mempool and bundle them together to send them to the EntryPoint contract. Nodes on the Ethereum network, mev searchers or anyone can choose to act as a bundler.
During the process of bundling the UserOps
together, a bundler also performs multiple sanity checks on the UserOp
to prevent all possible Denial of Service attack vectors and mempool fragmentation. This process includes checking for forbidden opcodes and proper storage access.
Bundlers pay for the gas of all ^ using their own EOA. Then gets paid back by either paymaster or the sender itself. They earn from the associated fees sent from the smart account/paymaster when the transaction is relayed to the chain. It is quite important for the bundler's incentive that they don't lose out in bundling the UserOps
, thus they run a simulation with the EntryPoint contract to make sure said UserOp
has enough has to pay the bundler back, paymasters are also another party to check as they could sponsor transactions for the user.
Since bundlers control inclusion, exclusion and ordering, so they’ll be a likely source of MEV, so they can either do this or outsource it to other searchers, where they bid for. Market is hard to guess but you can look at relayscan for an estimate.
EntryPoint Contract
The EntryPoint contract is a global “singleton” smart contract known as “EntryPoint”. There’s only one EntryPoint smart contract on the entire blockchain based on similar “fee-prioritization logic” used by block builders on Ethereum today.
The call-flow of this contract is divided into 2 parts
- Verification Loop
- Execution Loop
The verification loop verifies each UserOp to be valid and also make sure that either the Smart Account or Paymaster contract can pay the maximum gas cost for each User Operation.
While the execution loop sends the calldata in each userOp to the smart account for execution.
Bundler simulating validation of UserOp
function simulateValidation(UserOperation calldata userOp) external {
UserOpInfo memory outOpInfo;
_simulationOnlyValidations(userOp);
(uint256 validationData, uint256 paymasterValidationData) = _validatePrepayment(0, userOp, outOpInfo);
StakeInfo memory paymasterInfo = _getStakeInfo(outOpInfo.mUserOp.paymaster);
StakeInfo memory senderInfo = _getStakeInfo(outOpInfo.mUserOp.sender);
StakeInfo memory factoryInfo;
{
bytes calldata initCode = userOp.initCode;
address factory = initCode.length >= 20 ? address(bytes20(initCode[0 : 20])) : address(0);
factoryInfo = _getStakeInfo(factory);
}
ValidationData memory data = _intersectTimeRange(validationData, paymasterValidationData);
address aggregator = data.aggregator;
bool sigFailed = aggregator == address(1);
ReturnInfo memory returnInfo = ReturnInfo(outOpInfo.preOpGas, outOpInfo.prefund,
sigFailed, data.validAfter, data.validUntil, getMemoryBytesFromOffset(outOpInfo.contextOffset));
if (aggregator != address(0) && aggregator != address(1)) {
AggregatorStakeInfo memory aggregatorInfo = AggregatorStakeInfo(aggregator, _getStakeInfo(aggregator));
revert ValidationResultWithAggregation(returnInfo, senderInfo, factoryInfo, paymasterInfo, aggregatorInfo);
}
revert ValidationResult(returnInfo, senderInfo, factoryInfo, paymasterInfo);
}
Validation of the UserOp is simulated here. This function always reverts, and successful results will throw a ValidationResult error, while other errors are considered failures.
In detail, this function verifies no banned opcodes are used and checks that the UserOp does not reference storage outside the account's data. This function does not check if signature and nonce is valid. After called and an error being thrown, validateUserOp() is called.
Bundler validating UserOp
function validateUser0p(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds)
external override virtual returns (uint256 validationData) {
_requireFromEntryPoint();
validationData = _validateSignature(userOp, userOpHash);
_validateNonce(userOp.nonce);
_payPrefund(missngAccountFunds);
}
Similar to the previous function, this function also always reverts. But this checks the validity of the UserOp's signature and nonce.
The maximum gas limit for this function is 200k gas, so comparing this to cost of validating an EOA transaction, it’s about 5.5 times more expensive to perform DOS protections on 4337 transactions.
Bundler simulating validation of the UserOp
function simulateHandleOp(UserOperation calldata op, address target, bytes calldata targetCallData) external override {
UserOpInfo memory opInfo;
_simulationOnlyValidations(op);
(uint256 validationData, uint256 paymasterValidationData) = _validatePrepayment(0, op, opInfo);
ValidationData memory data = _intersectTimeRange(validationData, paymasterValidationData);
numberMarker();
uint256 paid = _executeUserOp(0, op, opInfo);
numberMarker();
bool targetSuccess;
bytes memory targetResult;
if (target != address(0)) {
(targetSuccess, targetResult) = target.call(targetCallData);
}
revert ExecutionResult(opInfo.preOpGas, paid, data.validAfter, data.validUntil, targetSuccess, targetResult);
}
This function simulates a full execution of the UserOp, including both validation and execution. Signature error is ignored here.
This function always reverts, giving an ExecutionResult error on success and other errors are considered failures. The optional target (Paymaster's address) is called and it's value is returned here.
With all checks passed and the UserOp considered to be valid, the bundler will then add it to the dedicated mempool, where the UserOp is eligible for bundling and transaction inclusion.
This is where the execution loop comes in where the bundlers finalize the process by submitting the set of UserOps as calldata.
Entrypoint.handleOps() called by bundler:
function handleOps(UserOperation[] calldata ops, address payable beneficiary) public nonReentrant {
uint256 opslen = ops.length;
UserOpInfo[] memory opInfos = new UserOpInfo[](opslen);
unchecked {
for (uint256 i = 0; i < opslen; i++) {
UserOpInfo memory opInfo = opInfos[i];
(uint256 validationData, uint256 pmValidationData) = _validatePrepayment(i, ops[i], opInfo);
_validateAccountAndPaymasterValidationData(i, validationData, pmValidationData, address(0));
}
uint256 collected = 0;
emit BeforeExecution();
for (uint256 i = 0; i < opslen; i++) {
collected += _executeUserOp(i, ops[i], opInfos[i]);
}
_compensate(beneficiary, collected);
} //unchecked
}
This function executes a batch of UserOps, and also compensates the bundler for the gas spent on the transaction here for all UserOps included.
The smart account's execute function is called for execution on the specifies target of the UserOp, making the transaction fully validated and executed.
Paymasters
Paymasters are contracts that allows the protocol or owner to pay gas fees on behalf of it's users. It serves like a gas tank for users, sponsoring, offering users gasless transactions or even allowing them to pay using ERC-20 tokens or off-chain methods.
This is achieved by the paymaster staking ETH into its own contract, and exchanging it for specified ERC-20 tokens that are approved by the paymaster to be eligible for transactions. There can be different logic applied here, like sponsoring user transactions if they own a certain NFT, or through whitelisting certain addresses.
For user safety, there is a reputation system in place for paymasters to make sure they adhere by the token standards. If a transaction manages to revert despite successful validation from previous validation from the bundler, penalties are applies to the paymaster.
Visa conducted a workshop to showcase the implementation of paymasters using testnet, here are some code snippets from their article:
Allowing users to pay for gas via ERC-20 tokens
function _postOp(PostOpMode mode, bytes calldata context, uint256 actualGasCost) internal override {
//we don't really care about the mode, we just pay the gas with the user's tokens.
(mode);
address sender = abi.decode(context, (address));
uint256 charge = getTokenValueOfEth(actualGasCost + COST_OF_POST);
//actualGasCost is known to be no larger than the above requiredPreFund, so the transfer should succeed.
_transfer(sender, address(this), charge);
}
User can pay transaction fees with alternative tokens, in this case: a custom ERC-2O token set by the paymaster.
Sponsoring whitelisted users gas fees
function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost)
internal virtual override view
returns (bytes memory context, uint256 validationData) {
//Check if the user is authorized to use the paymaster.
address user = userOp.sender;
require(authorized[user], "User is not authorized.");
(userOp, userOpHash, maxCost);
return ("", 0);//There is no paymaster data to send (e.g., time range)
}
Sponsoring user operations gas fee based on allowlisted users
Signature Aggregators
Instead of paying on behalf of other users, signature aggregators validate an aggregated signature for multiple transactions improving efficiency of the verification process. They ensure that the UserOperations are valid through alternative signature schemes (i.e. BLS Signatures), which provide single step verification for aggregated UserOps from bundled transactions
This extension improves efficiency as UserOps don't have to be validated one by one, saving gas. It also improves the ecosystem's scalability as alternative signature scheme can replace non-quantum proof signature schemes.
BLS signatures generated by multiple unique keys can be aggregated into a single signature which can then be verified in a single step. A base implementation of such an aggregator can be found here:
function validateSignatures(UserOperation[] calldata userOps, bytes calldata signature)
external view override {
require(signature.length == 64, "BLS: invalid signature");
(uint256[2] memory blsSignature) = abi.decode(signature, (uint256[2]));
uint userOpsLen = userOps.length;
uint256[4][] memory blsPublicKeys = new uint256[4][](userOpsLen);
uint256[2][] memory messages = new uint256[2][](userOpsLen);
for (uint256 i = 0; i < userOpsLen; i++) {
UserOperation memory userOp = userOps[i];
blsPublicKeys[i] = getUserOpPublicKey(userOp);
messages[i] = _userOpToMessage(userOp, _getPublicKeyHash(blsPublicKeys[i]));
}
require(BLSOpen.verifyMultiple(blsSignature, blsPublicKeys, messages), "BLS: validateSignatures failed");
}
Transaction flow without extensions
- Alice has an intent to create a ERC-4337 transaction,
- Alice calls a function in a dApp, creating a
UserOp
for her - The UserOp is sent with a creation of Alice's smart wallet from the dApp to the alt-mempool
- The bundler selects the
UserOp
from the alt-mempool - After bundling and running sanity checks, the
UserOp
bundled with otherUserOps
is send to the EntryPoint contract for further verification - The EntryPoint contract verifies the
UserOp's
validity, and reverts with a success error code. - With all checks done, the EntryPoint contract can now call the smart account for execution of the
UserOp
.
References
- https://eips.ethereum.org/EIPS/eip-4337
- https://medium.com/@morphsec/erc-4337-overview-189533e3c087
- https://www.blocknative.com/blog/account-abstraction-erc-4337-guide
- https://cointelegraph.com/learn/account-abstraction-guide-to-ethereums-erc-4337-standard
- https://medium.portto.com/understanding-ethereums-erc-4337-and-account-abstraction-what-you-need-to-know-61aa95ace48?gi=032566e6e53c
- https://medium.com/oak-security/a-deep-dive-into-the-main-components-of-erc-4337-account-abstraction-using-alt-mempool-part-3-6d721ff45f5f
- https://usa.visa.com/solutions/crypto/rethink-digital-transactions-with-account-abstraction.html