Exploring CREATE3 - Simplifying Smart Contract Deployment on Ethereum
What is Create3
With the advancement of multichain development, CREATE3 emerges as a transformative and cheap alternative to the deployment of smart contracts. With the existing Solidity opcodes such as create
and create2
, the Create3 is built upon the solidity of create2
to grant developers control over contract deployment. By enabling developers to predetermine contract addresses before deployment, CREATE3 imbues them with a newfound sense of agency. This new approach not only enhances control but also fosters consistency and uniformity across Ethereum Virtual Machine (EVM) blockchains, allowing for the synchronization of smart contract ecosystems. Whether navigating the Ethereum mainnet, testnets, or private networks, CREATE3 facilitates the harmonious deployment of contracts with identical addresses, thereby heralding a new era of streamlined blockchain development.
Solmate CREATE3
For a more technical perspective, let's dive into Solmate's CREATE3 library, which is designed for deploying smart contracts to deterministic addresses without necessitating an initcode factor. The CREATE3 deploy
method operates by leveraging create2
initially to deploy a CREATE factory, with its nonce set to 1 which then is used to deploy the desired derived contract.
Another important part is the Proxy Bytecode.
bytes internal constant PROXY_BYTECODE = hex"67_36_3d_3d_37_36_3d_34_f0_3d_52_60_08_60_18_f3";
The proxy bytecode essentially acts as an intermediary contract that executes the create
opcode within its context. By deploying this proxy contract with a unique salt value and having it call "create" with its current nonce (starting at 1 upon first use), the contract bytecode itself becomes independent of the address derivation process. This means that the address of the deployed contract is determined solely by the creator's address, the salt value, and the hash of the proxy bytecode, without being influenced by the specific bytecode of the contract being deployed. This is what makes the contracts deterministic and predictable in their deployments, and how developers can ensure resulting contract addresses deployed on other networks are consistent.
Then we deploy this proxy contract as a new contract using the create2
opcode. The create2 opcode will take in the ether value which is defined as 0
, the bytecode of the proxy contract and offset by 32 bytes to skip the first 32 bytes which represent the length of the bytecode, then the bytecode loaded from memory and the salt used for deterministic address derivation.
deployed = getDeployed(salt);
(bool success, ) = proxy.call{value: value}(creationCode);
Following that the getDeployed
function is called to retrieve the address of the deployed contract based on the provided salt value. Subsequently, the proxy contract is invoked with the creationCode
to initialize the deployed contract.
There are also two required checks,
require(proxy != address(0), "DEPLOYMENT_FAILED");
require(success && deployed.code.length != 0, "INITIALIZATION_FAILED");
- The first statement ensures that the deployment was successful by checking that the proxy address is not zero.
- the second statement verifies that the initialization was successful and that the deployed contract's bytecode is not empty.
So in general the steps to deploying a smart contract using create3 is,
- creating a Factory contract (
Deployer.sol
) - calls
create3.deploy
- calls
create2
to create a proxy contract - the proxy contract will be called
create
from the proxy contract to create the contract
- calls
Looking at the Deployer Example contract
The Deployer.sol
contract is essentially a factory contract that deploys the contract bytecode to a deterministic address. The contract has a deploy
method that takes in the salt value and can take the bytecode of the contract to be deployed. In a more generic sense, you can pass any bytecode in so the deployer()
method can be,
function deploy(bytes memory _salt, bytes memory _bytecode, /* more args for constructor */) external {
bytes memory bc = abi.encodePacked(
_bytecode,
abi.encode(msg.sender)
// args,
);
CREATE3.deploy(keccak256(_salt), bc, _value);
}
For this contract, we've encapsulated the deployment of a contract identified as Ownerable.sol
. Utilizing the built-in methods, we extract the bytecode of Ownerable
and subsequently invoke the create3.deploy
function from the Solmate library. This invocation passes along the salt, the extracted bytecode, and any specified ether value as parameters. The create3.deploy
function is then responsible for deploying the contract using the provided bytecode.
Deploy using CREATE3
It is important to note the given example using CREATE3 on multichain with the same address, you MUST use a wallet with the same nonce on each network. The best solution is to create an entirely new wallet and send some funds on each chain.
For our demonstration, we'll deploy contracts on the BASE Sepolia and Polygon Mumbai testnet. Begin by compiling and deploying Deployer.sol
. This action will advance the nonce of your wallet by one once the transaction is completed. Following this, switch your Metamask to the Mumbai testnet and deploy using the same newly created wallet. The outcome should be consistent across both networks. It's important to note that the Deployer.sol
used in both instances is identical, ensuring uniformity in the deployment process.
The examples of deployers on both networks are as follows,
With CREATE3, we gain the flexibility to selectively target a specific address for contract deployment by using a salt. For simplicity, let's use the address 0xfeB362F2148F1303ea6Bf026d32071EA295e25ac
as the salt. The Deployer contract includes a function named getDeployer
, which, as mentioned, calculates the contract address using the provided salt. Although initially, no contract is deployed at this address, the getDeployer
can compute the output Ownerable
address for the contract wallet specified with a salt. This approach ensures that the derived address serves as the deployment target for the contract's implementation in bytecode. Importantly, this guarantees that the address will be consistent across different chains, assuming the deploying wallet's nonce remains the same. We should keep the output address in mind, as it'll be the one we can compare across when using deploy
.
To deploy with the CREATE3, the Deployer wrapped the deploy
from create3 and hence we can pass the the _salt
as whatever byte we want say, 0xfeB362F2148F1303ea6Bf026d32071EA295e25ac
. After a successful deployment, you can verify the contract on a block explorer by checking out the transaction.
After deploying, we can access the Ownerable.sol
(with Solide IDE) contract through the provided URL to interact with its features, such as the getOwner
function, which should identify the deploying wallet as the owner. To achieve consistent contract addresses across different chains, repeat this deployment process on an alternative blockchain, such as the Mumbai testnet, employing the identical salt. This method showcases CREATE3's ability to facilitate uniform contract addresses across various chains, underscoring its utility for cross-chain contract deployment. Should there be a need to modify the contract address, altering the salt value or ensuring the nonce differs across networks can accomplish this. Furthermore, if deploying a distinct contract while desiring the same address, leveraging the deploy()
function with the original salt allows for this flexibility.
In essence, CREATE3 empowers developers with enhanced control over the generation of smart contract addresses on Ethereum, presenting new opportunities for the development of decentralized applications with increased flexibility and uniformity across diverse networks. However, it's important to acknowledge that deploying contracts via CREATE3 may incur higher costs compared to the create
and create2
methods.
ERC4337 - Decoding Ethereum's EntryPoint
Basic
Before we go into the details of the EntryPoint, we need to understand the basic flow of how the EntryPoint works. The EntryPoint is a smart contract that is used to execute UserOperations
. The UserOperation
is a struct that contains all the necessary information to execute a transaction. The EntryPoint is used to execute multiple UserOperations at once. This is done to save gas costs and to make the execution of transactions more efficient.
UserOperation
The primary data structure for user interaction within the Account Abstraction framework is encapsulated in interfaces/UserOperation.sol
. This structure is typically created by the Bundler and transmitted to the EntryPoint contract. The UserOperation
struct comprises the following fields:
struct UserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
uint256 callGasLimit;
uint256 verificationGasLimit;
uint256 preVerificationGas;
uint256 maxFeePerGas;
uint256 maxPriorityFeePerGas;
bytes paymasterAndData;
bytes signature;
}
An example of a UserOperation
is provided below:
const emptyUserOp: UserOperation = {
sender: AddressZero,
callData: '0x',
nonce: 0,
preVerificationGas: 0,
verificationGasLimit: 100000,
callGasLimit: 0,
maxFeePerGas: 0,
maxPriorityFeePerGas: 0,
signature: '0x'
}
In contrast to the traditional approach of sending signed transactions to a mempool for validation, the initial step in ERC-4337 involves dispatching an operation in the form of a UserOperation. These operations are then forwarded to an alternative mempool. Users have the capability to dispatch multiple UserOperations concurrently through a Bundler smart contract, referred to as Bundler Transactions.
Entry Point Contract on Ethereum
The EntryPoint (EP) contract on Ethereum, found at 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789, is crucial for handling bundlerTransactions
.
You can explore this contract using Solidity's IDE ${SOLIDE_URL}/1/0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789)
As of writing this, its contract version is 0.6.0
and serves as the main hub for processing batches of UserOperations
. The contract offers two main methods: handleOps
and handleAggregatedOps
. We'll focus on handleOps
for now, leaving handleAggregatedOps
for later discussion.
👉 handleOps
The main flow of using the EntryPoint typically comes from the Bundler
contracts which are called the handleOps()
function handleOps(UserOperation[] calldata ops, address payable beneficiary) public nonReentrant
- Non-Reentrant Modifier: This modifier prevents reentrancy attacks, ensuring the security of the smart contract.
- UserOperation Array: The
ops
array consists ofUserOperation
objects, stored incalldata
. These objects hold data passed to the contract's entry point. - Beneficiary Address: The
beneficiary
address is where gas refunds are sent after execution. Typically, this address corresponds to the bundler, but it can be set to any desired address.
uint256 opslen = ops.length;
UserOpInfo[] memory opInfos = new UserOpInfo[](opslen);
Before executing the UserOperations, the EntryPoint will validate each UserOperation. It'll create a UserOpInfo
array to store the information of each UserOperation.
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));
}
In order to populate the opInfos from the Bundler, the function will undergo validation check in its loops. In order to save gas it'll be in the uncheck
Go to implementation information for _validatePrepayment
After the validation of both the Account and Paymaster, the validation is checked to see if they expire in _validateAccountAndPaymasterValidationData
. If the validation is successful, the EntryPoint will execute the UserOperations.
Go to implementation information for _validateAccountAndPaymasterValidationData
uint256 collected = 0;
emit BeforeExecution();
for (uint256 i = 0; i < opslen; i++) {
collected += _executeUserOp(i, ops[i], opInfos[i]);
}
_compensate(beneficiary, collected);
With all validation complete it'll emit an event before execution begins. Then start iterating through each user operation, executing them and adding the gas fees consumed by each operation to the total collected amount. After all operations are executed, it compensates the specified beneficiary with the total collected gas fees, transferring them to the beneficiary's address.
Go to implementation information for _executeUserOp
Go to implementation information for _compensate
✔️ _validatePrepayment
function _validatePrepayment(uint256 opIndex, UserOperation calldata userOp, UserOpInfo memory outOpInfo)
private returns (uint256 validationData, uint256 paymasterValidationData)
The _validatePrepayment
function serves a pivotal role in upholding the integrity and safety of UserOperations within the account abstraction framework.
It takes in three parameters:
opIndex
: The index of the operation.userOp
: The UserOperation data structure containing essential information about the operation.outOpInfo
: A UserOpInfo structure used for storing operation-specific data during validation.
uint256 preGas = gasleft();
MemoryUserOp memory mUserOp = outOpInfo.mUserOp;
_copyUserOpToMemory(userOp, mUserOp);
outOpInfo.userOpHash = getUserOpHash(userOp);
Initially, the function tracks the remaining gas at the start of its execution. Utilizing the built-in Solidity function gasleft()
, it determines the amount of gas remaining within the current Ethereum transaction. Throughout the call to the EntryPoint, gasleft
is employed to inform decisions based on the gas supplied by the Bundler. Subsequently, it creates a copy of the data from userOp
into memory for efficient processing. Following this, it calculates a hash of the UserOperation data using the getUserOpHash
function. This hash functions as a unique identifier for the operation, facilitating validation processes.
require(maxGasValues <= type(uint120).max, "AA94 gas values overflow");
This line of code ensures that certain numeric values within the UserOperation data, such as gas limits, do not exceed the maximum value representable by a 120-bit unsigned integer. This safeguard is implemented to mitigate the risk of overflow during subsequent calculations.
uint256 gasUsedByValidateAccountPrepayment;
(uint256 requiredPreFund) = _getRequiredPrefund(mUserOp);
(gasUsedByValidateAccountPrepayment, validationData) = _validateAccountPrepayment(opIndex, userOp, outOpInfo, requiredPreFund);
The function continues by calculating the gas needed to pre-fund the operation. This calculation is based on the UserOperation data and specific conditions defined within the _getRequiredPrefund
function. Furthermore, the function conducts validation checks using _validateAccountPrepayment
. These checks ensure that the account (Smart Contract Wallet), possesses adequate funds and allowances to cover the operation's gas costs.
Go to implementation information for _validateAccountPrepayment
if (mUserOp.paymaster != address(0)) {
(context, paymasterValidationData) = _validatePaymasterPrepayment(opIndex, userOp, outOpInfo, requiredPreFund, gasUsedByValidateAccountPrepayment);
}
This next stage is optional, contingent upon the bundler's inclusion of a paymaster for the UserOperation. The paymaster is an integral component in Account Abstraction as it enables users to settle transaction fees such as utilizing ERC-20 tokens rather than native tokens like ETH. Acting as an intermediary, the Paymaster gathers ERC-20 tokens from users and remits ETH to the blockchain for transaction facilitation. Therefore, this aspect is a crucial addition to the EntryPoint, permitting the Bundler to cover the UserOperation costs using ERC-20 tokens instead of native tokens such as ETH.
Go to implementation information for _validatePaymasterPrepayment
uint256 gasUsed = preGas - gasleft();
if (userOp.verificationGasLimit < gasUsed) {
revert FailedOp(opIndex, "AA40 over verificationGasLimit");
}
outOpInfo.prefund = requiredPreFund;
outOpInfo.contextOffset = getOffsetOfMemoryBytes(context);
outOpInfo.preOpGas = preGas - gasleft() + userOp.preVerificationGas;
After completing the necessary gas calculations and validations, the function ensures that the gas utilized during validation does not surpass the specified verification gas limit. Upon success, it finalizes the pre-funding details within the outOpInfo
structure, encompassing the pre-fund amount, memory context offset, and pre-operation gas usage.
outOpInfo.prefund
is set to therequiredPreFund
value, which represents the maximum gas fee deducted from the deposit on EP.outOpInfo.contextOffset
is designated as the offset of the context object in memory. Note that the context object is returned by thePaymaster.validatePaymasterUserOp
call. By storing only the memory offset of the context object, we alleviate the need to pass around the entire context object while invoking internal methods.outOpInfo.preOpGas
is determined as the sum of the total gas used thus far and theuserOp.preVerificationGas
.
In summary, _validatePrepayment
assumes the role of guaranteeing the validity and safety of UserOperations within the account abstraction framework. It encompasses crucial tasks such as gas tracking, hashing, validation, and pre-funding calculations, ensuring the seamless and secure execution of operations.
Go back to handleOps
_validateAccountPrepayment
_createSenderIfNeeded(opIndex, opInfo, op.initCode);
This internal method is crucial for validating the operation with a Smart Contract Wallet (SCW). Initially, it calls upon a Factory contract to create the account if required, utilizing _createSenderIfNeeded
. The Wallet contract generated by this factory must adhere to interfaces/IAccount.sol
, which includes the validateUserOp
function. This function is essential for validating the UserOp's signature, enabling the EntryPoint to execute operations on a Wallet account.
Once the Smart Contract Wallet is deemed valid for further validation, the method proceeds to perform calculations on the gas funds and validate the validateUserOp
function on the SCW if and only if paymaster == address(0)
. This condition signifies that the SCW, either passed or generated, will be responsible for covering the current UserOperation execution(s).
Important stage in handleOps
At this point, the EntryPoint call stack should handleOps.validatePrePayment._validateAccountPrepayment
, where the EntryPoint is validating that the SCW has enough gas to cover the UserOperation.
try IAccount(sender).validateUserOp{gas : mUserOp.verificationGasLimit}(op, opInfo.userOpHash, missingAccountFunds)
There is also the introduction of reverting the entire transaction if validations fail from the SCW or the call runs out of gas. Mainly the FailedOp
will revert the transaction.
Upon successful validation, both gasUsedByValidateAccountPrepayment
and validationData
provided by the SCW through its IAccount
interface are captured. It is crucial that the validation logic is tailored and executed according to each user's preferences and requirements.
Go back to _validatePrepayment
_validatePaymasterPrepayment
uint256 preGas = gasleft();
MemoryUserOp memory mUserOp = opInfo.mUserOp;
address paymaster = mUserOp.paymaster;
DepositInfo storage paymasterInfo = deposits[paymaster];
uint256 deposit = paymasterInfo.deposit;
if (deposit < requiredPreFund) {
revert FailedOp(opIndex, "AA31 paymaster deposit too low");
}
Similarly to the _validateAccountPrepayment
, this time, it'll check the paymaster's deposit balance in EP. If there is enough despot compared to the provided, it'll deduct the that the the requiredPreFund from the Paymaster's deposit,
MemoryUserOp memory mUserOp = opInfo.mUserOp;
uint256 verificationGasLimit = mUserOp.verificationGasLimit;
require(verificationGasLimit > gasUsedByValidateAccountPrepayment, "AA41 too little verificationGas");
uint256 gas = verificationGasLimit - gasUsedByValidateAccountPrepayment;
gasUsedByValidateAccountPrepayment
calculated by the Account validation, is used to calculate the gas required to pay back the bundler. This is done via _getRequiredPrefund
. Since the EntryPoint is executing the UserOperations, this means that EntryPoint must ensure it has enough gas to execute those UserOperations and in order for the Bundler to obtain the gas, depends on whether a Paymaster is set up or the Smart Contract Wallet provided.
paymasterInfo.deposit = deposit - requiredPreFund
After deducting the deposit as mentioned call the validationOp's validatePaymasterUserOp
for paymaster of interfaces/IPaymaster.sol
and with the userOp.verificationGasLimit
as gas limit and return the validation Data.
IPaymaster(paymaster).validatePaymasterUserOp{gas: gas}(op, opInfo.userOpHash, requiredPreFund) returns (bytes memory _context, uint256 _validationData)
This will return context object and validationData
.
Go back to _validatePrepayment
✔️ validateAccountAndPaymasterValidationData
function _validateAccountAndPaymasterValidationData(uint256 opIndex, uint256 validationData, uint256 paymasterValidationData,
address expectedAggregator)
validateAccountAndPaymasterValidationData
serves to validate the validation data from both the Smart Contract Wallet (SCW) and Paymaster. It verifies if the validation data has expired and reverts the transaction if it has. Notably, in the Ethereum EntryPoint, the address(0)
represents the expectedAggregator
.
Before unpacking the validationData, An example of validation data is as follows as taken by account-abstraction/ethereum/contracts/TokenPaymaster.sol
. Note this is just an example, other Paymaster's or SCW validation will have different validation data depending on it implementation.
validationResult = _packValidationData(
false,
uint48(cachedPriceTimestamp + tokenPaymasterConfig.priceMaxAge),
0
);
The validationData comprises three components, which are essential for the EntryPoint to validate the UserOperation,
- Aggregator: This value signifies the success of the aggregator. A value of
0
indicates a successful aggregator. For instance, iffalse
is provided, it results in the value0
, indicating success. - ValidUntil: This represents the start time when the signature is valid.
- ValidAfter: This denotes the end time when the signature is valid.
These components collectively determine whether signature verification was successful. The primary implementation of parsing the validationData is outlined in Helper.sol
within the _parseValidationData
function. In essence, this method extracts the aggregator value (an address
), the validUntil timestamp (a uint48
), and the validAfter timestamp (a uint48
) from the validationData. If the validUntil value is 0, as observed in the example provided, it signifies that the signature is valid until the maximum value of uint48
.
Since both validationData and paymasterValidationData undergo validation in a similar manner, we'll focus on the paymasterValidationData. The validation process involves comparing these values with the current block timestamp in the EntryPoint to ascertain their validity.
address pmAggregator;
(pmAggregator, outOfTimeRange) = _getValidationData(paymasterValidationData);
if (pmAggregator != address(0)) {
revert FailedOp(opIndex, "AA34 signature error");
}
if (outOfTimeRange) {
revert FailedOp(opIndex, "AA32 paymaster expired or not due");
}
Hence if we go back to the EntryPoint where it'll parse the above validationResult
as paymasterValidationData
we see it extracts the pmAggregator
variable represents the aggregator status obtained from the paymasterValidationData. A value of 0
indicates a successful aggregator, while 1
implies an expired aggregator. With this, if pmAggregator
is assigned the value of address(0)
, it signifies that the aggregator is successful as it has the value of 0
.
Furthermore, validation is conducted by comparing the current block timestamp with the validUntil and validAfter timestamps obtained from the validation data. The outOfTimeRange
variable is set based on whether the current timestamp exceeds the validUntil timestamp or falls before the validAfter timestamp. If outOfTimeRange
is true
, it indicates that the paymaster has expired or the operation is not yet due.
outOfTimeRange = block.timestamp > data.validUntil || block.timestamp < data.validAfter;
In summary, the code snippet checks the status of the aggregator and verifies the validity of the paymaster based on timestamps, ensuring that the operation is executed within the designated time range. If any discrepancy is detected, the function reverts the transaction with an appropriate error message, such as "signature error" or "paymaster expired or not due."
Go back to handleOps
numberMarker()
//place the NUMBER opcode in the code.
// this is used as a marker during simulation, as this OP is completely banned from the simulated code of the
// account and paymaster.
function numberMarker() internal view {
assembly {mstore(0, number())}
}
This function is mainly useful for the method simulateValidation
, for tracing and checkpoints throughout the code. For our purpose, we don't need to really worry about this.
🔧 _executeUserOp
function _executeUserOp(uint256 opIndex, UserOperation calldata userOp, UserOpInfo memory opInfo)
private returns (uint256 collected)
bytes memory context = getMemoryBytesFromOffset(opInfo.contextOffset);
The code begins by retrieving the context from memory. The context is stored as a byte array and contains essential information needed for the Paymaster.postOp
function.
try this.innerHandleOp(userOp.callData, opInfo, context) returns (
uint256 _actualGasCost
) {
collected = _actualGasCost;
}
The code then attempts to execute the UserOperation by invoking the innerHandleOp
function. This function, implemented by the Paymaster, takes userOp.callData
, opInfo
, and context
as parameters. Upon invocation, innerHandleOp
returns the actual gas cost incurred by the operation, which is subsequently added to the collected
variable.
Go to implementation information for innerHandleOp
innerHandleOp
function innerHandleOp(
bytes memory callData,
UserOpInfo memory opInfo,
bytes calldata context
) external returns (uint256 actualGasCost)
The innerHandleOp
method is invoked to execute the UserOperation
calldata on the wallet contract. Leveraging the Exec
solidity library, available in util/Exec.sol
, developers gain access to a range of utility functions designed for diverse contract calls. These include regular call, staticcall, and delegatecall functionalities, along with features for retrieving return data and reverting with explicit byte arrays. Such capabilities empower developers to interact flexibly and efficiently with other contracts directly within Solidity contracts, seamlessly managing value transfers and data retrieval. In the following code snippet, Exec.call
is utilized—a low-level call function—utilizing the calldata provided by userOp.callData
.
if (callData.length > 0) {
bool success = Exec.call(mUserOp.sender, 0, callData, callGasLimit);
if (!success) {
bytes memory result = Exec.getReturnData(REVERT_REASON_MAX_LEN);
if (result.length > 0) {
emit UserOperationRevertReason(opInfo.userOpHash, mUserOp.sender, mUserOp.nonce, result);
}
mode = IPaymaster.PostOpMode.opReverted;
}
}
unchecked {
uint256 actualGas = preGas - gasleft() + opInfo.preOpGas;
//note: opIndex is ignored (relevant only if mode==postOpReverted, which is only possible outside of innerHandleOp)
return _handlePostOp(0, mode, opInfo, context, actualGas);
}
Go to implementation information for _handlePostOp
_handlePostOp
function _handlePostOp(uint256 opIndex, IPaymaster.PostOpMode mode, UserOpInfo memory opInfo, bytes memory context,
uint256 actualGas) private returns (uint256 actualGasCost)
address refundAddress;
MemoryUserOp memory mUserOp = opInfo.mUserOp;
uint256 gasPrice = getUserOpGasPrice(mUserOp);
address paymaster = mUserOp.paymaster;
if (paymaster == address(0)) {
refundAddress = mUserOp.sender;
} else {
refundAddress = paymaster;
// ...
}
When a paymaster is specified and its validation results in a non-empty context, the surplus amount is reimbursed to the account or paymaster, depending on its involvement in the transaction request. As mentioned, the following code executes the IPaymaster
's postOp
function, which is another essential method similar to validatePaymasterUserOp
.
The postOp()
function acts as a post-execution hook after completing a user operation. It manages tasks to be executed upon successful validation of the user operation, such as handling custom token payments for transaction fees. For example, if a user chooses to pay with an ERC-20 token, the entry point invokes postOp()
after executing the operation and provides information about gas consumption. Importantly, access to postOp()
is contingent upon the validation context generated by validatePaymasterUserOp()
not being null. This mechanism simplifies the process of managing token payments and facilitates smooth transaction processing on the blockchain.
if (context.length > 0) {
actualGasCost = actualGas * gasPrice;
if (mode != IPaymaster.PostOpMode.postOpReverted) {
IPaymaster(paymaster).postOp{gas : mUserOp.verificationGasLimit}(mode, context, actualGasCost);
} else {
// solhint-disable-next-line no-empty-blocks
try IPaymaster(paymaster).postOp{gas : mUserOp.verificationGasLimit}(mode, context, actualGasCost) {}
catch Error(string memory reason) {
revert FailedOp(opIndex, string.concat("AA50 postOp reverted: ", reason));
}
catch {
revert FailedOp(opIndex, "AA50 postOp revert");
}
}
}
actualGas += preGas - gasleft();
actualGasCost = actualGas * gasPrice;
if (opInfo.prefund < actualGasCost) {
revert FailedOp(opIndex, "AA51 prefund below actualGasCost");
}
uint256 refund = opInfo.prefund - actualGasCost;
_incrementDeposit(refundAddress, refund);
bool success = mode == IPaymaster.PostOpMode.opSucceeded;
emit UserOperationEvent(opInfo.userOpHash, mUserOp.sender, mUserOp.paymaster, mUserOp.nonce, success, actualGasCost, actualGas);
After determining the refund address and ensuring that the paymaster context isn't empty, the code proceeds to calculate the actual gas usage and its associated cost during the execution of a smart contract operation. It then verifies whether the pre-funded amount is adequate to cover the gas cost. If not, the transaction is reverted. Any excess pre-funded amount is calculated as a refund and subsequently added to the deposit of the specified address. Finally, the success status of the operation is determined based on the paymaster's mode setting.
Additionally:
- The
_incrementDeposit
function in theStakeManager
contract is invoked to increase the paymaster's deposit by the actual gas cost. - The
actualGasCost = actualGas * gasPrice
calculation determines the actual gas cost of the operation, which is stored as the value ofcollected
in thehandleOps
function.
Go back to handleOps
💵 _compensate
/**
* compensate the caller's beneficiary address with the collected fees of all UserOperations.
* @param beneficiary the address to receive the fees
* @param amount amount to transfer.
*/
function _compensate(address payable beneficiary, uint256 amount) internal {
require(beneficiary != address(0), "AA90 invalid beneficiary");
(bool success,) = beneficiary.call{value : amount}("");
require(success, "AA91 failed send to beneficiary");
}
The final step is to compensate the beneficiary with the collected fees. The collected fees are transferred to the beneficiary address. The beneficiary address can be any address where the bundler wants to receive the refund as provided in the handleOps
.
Conclusion
In conclusion, the EntryPoint efficiently executes the bundled UserOperations and ensures fair compensation for the beneficiary by collecting fees. This is the flow of handleOp
. There is also handleAggregatorOp
. Note also the EntryPoint extends StakeManger
found in core/StakeManger.sol
which as mentioned is responsible for managing deposits
and stakes to ensure reimbursement for beneficiaries during the execution of handleOps and handleAggregatedOps functions. Deposits represent balances used to cover the costs of UserOperations, while stakes are values locked for a specified duration by paymasters, crucial for the reputation system. To learn more about the EIP proposal and its specifications, you can refer to the official document here.
Welcome
Welcome to Solide