ERC-4337

From WEB3 Vulnerapedia
Jump to navigation Jump to search

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 a UserOp 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

UserOpsare 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 initCodesection 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

Call graph from Bundler to Entrypoint with Wallet contract and optional paymaster

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

  1. Alice has an intent to create a ERC-4337 transaction,
  2. Alice calls a function in a dApp, creating a UserOp for her
  3. The UserOp is sent with a creation of Alice's smart wallet from the dApp to the alt-mempool
  4. The bundler selects the UserOp from the alt-mempool
  5. After bundling and running sanity checks, the UserOp bundled with other UserOps is send to the EntryPoint contract for further verification
  6. The EntryPoint contract verifies the UserOp's validity, and reverts with a success error code.
  7. With all checks done, the EntryPoint contract can now call the smart account for execution of the UserOp.

References