Breaking down the paymaster
Last updated
Last updated
In this page, we will deep-dive the paymaster by code block-to-block and then pieces-by-pieces. If you already know the basic operations of the paymaster, skip to the
In ERC-4337, there's an interface that defines the interaction functions and an enum used by the paymaster when working with the EntryPoint
contract. These components are essential for the paymaster's proper operation. The paymaster interacts with these interfaces in two key steps: the validation step and the execution step, represented by the validatePaymasterUserOp()
and postOp()
functions, respectively.
Let’s dive into how these functions and the enum PostOpMode
interact with the EntryPoint
and the PackedUserOperation
struct, which is passed as calldata from EntryPoint
.
Well...it sounds pretty complicated, but don't worry. we will explore it step-by-step with the diagram and the descriptions!
Before we deep-dive how the function actually works, we will see the top-level of how these functions interact with the Entrypoint
contract. Let's see how it works with the flow graph below.
In the flow graph, you can see how the paymaster interacts with EntryPoint
through the two functions we previously discussed. EntryPoint
processes the userOp
in two main phases: the validation phase and the execution phase.
During the validation phase, EntryPoint
ensures that all involved entities—such as the paymaster, SCW, SCW factory (which can deploy the wallet contract), and the aggregator—are valid and meet the required conditions. In the execution phase, EntryPoint
allows the paymaster to check whether the user has sufficient gas, if necessary. This is the key feature that allows users to perform transactions without holding ETH in their wallets.
Now that we’ve covered the flow and use of the paymaster, let's dive into the actual parameters, return values, and examples of how the paymaster operates in practice.
[param0: op -> userOp]
The PackedUserOperation
struct that is parsed from the bundler's calldata, which should be verified in the paymaster's context. Since there is no standardized verification method for this struct, the paymaster has the freedom to validate it however it sees fit. For example, the paymaster might perform signature validation to confirm permission or simply check whether the user has the ability to cover the payment.
[param1: opInfo.userOpHash -> userOpHash]
The hash of the userOp
allows the paymaster to verify whether the userOp
is valid. For instance, the paymaster can use this hash to check the signature and confirm if the paymasterAndData
field in the struct is accurate.
[param2: requiredPreFund -> maxCost]
This parameter represents the maximum predicted cost of the transaction, estimated by the bundler (or the user). The paymaster uses this value to determine the compensation amount it may need to collect from the user, if necessary. Additionally, during the validation phase, the EntryPoint
temporarily deducts this amount in ether from the paymaster's deposited balance.
[returns0: bytes memory context]
The value to send to the postOp. If the paymaster needs to cache something after this validation and toss it to the postOp, it can be passed by this bytes.
This value is sent to postOp
function. If the paymaster needs to temporarily store data after validation and pass it to postOp
functionon, it can do so using this bytes
field.
Q: Why don't we just cache it to the storage slot in the paymaster?
A: Due to gas costs. Using the SSTORE
opcode (writing to storage) is significantly more expensive than the MSTORE
opcode (storing in memory). Thus, returning the value directly through memory is much cheaper.
[returns1: uint256 validationData]
This field consists of:
sigAuthorizer (20 bytes)
validUntil (6 bytes)
validAfter (6 bytes)
The execution phase is significantly more complex than the validation phase. This is due to the need to handle the operation's result and the potential inability of the user to pay the required amount after the execution of the userOp
. Multiple calls to paymaster contract may occur during this phase.
To provide a clearer overview, we organized the process in a time table and present it as a flow graph, accompanied by additional explanations.
The enum introduced in the paymaster interface is crucial for the operation state when calling the paymaster's postOp
function. There are three values in the enum.
[value0: opSucceeded ] [value1: opReverted ] [value2: postOpReverted]
opSucceeded
is the state when the userOperation is executed by the SCW successfully. If it dosen't succeed, the state would be opReverted
. postOpReverted
is the state when the postOp has been reverted for unexpected operation. In this case, the postOp is not called again.
Let's begin with the innerHandleOp()
function. This function is invoked by _executeUserOp()
, which is responsible for handling all operations during the execution phase. Within this process, innerHandleOp()
is called to execute the actual operation and to interact with the paymaster through the postExecution()
function. When calling postExecution()
, innerHandleOp()
passes the following parameters:
[param0: IPaymaster.PostOpMode mode] The value which represents whether the user's TX succeed or failed.
[param1: UserOpInfo memory opInfo]
- mUserOp: A copy of the original transaction’s calldata sent by the bundler.
- userOpHash: The hash of the userOp
, used for verifying its correctness through ECDSA. However, it is not utilized during the validation phase.
- prefund: The estimated total gas required for executing the userOp
. The innerHandleOp()
function uses this value when calculating the expected gas cost to reimburse the bundler.
- contextOffset: The memory offset used to load the operation’s context. Since the context is cached in the EntryPoint
contract’s memory, it needs to be retrieved before passing it to postOp
function in the external paymaster contract.
- preOpGas: The amount of gas consumed before executing the userOp
call to the SCW. This value must be passed to innerHandleOp()
function to correctly calculate the total gas usage for the operation.
[param2: bytes memory context]
This field holds the cached values from the validation phase. As discussed, the paymaster passes this data to the EntryPoint
through the validatePaymasterUserOp()
function. The contents of the context
are determined by the paymaster, based on its internal logic and requirements.
[param3: uint256 actualGas]
The exact total gas used right before postOp
function. It is passed to postOp
function to verify whether the user or paymaster can cover the gas costs. This ensures that the transaction can be settled without issues.
In the postexecution()
function, there will be a call to the paymaster with several parameters.
[param0: PostOpMode mode] This value indicates whether the user’s TX succeeded or failed. The paymaster can apply different logic based on the mode. For instance, if a user’s TX reverts frequently, the paymaster might decide to block the user from accessing its services.
[param1: bytes calldata context] This parameter holds the data cached by the paymaster during the validation phase.
[param2: uint256 actualGasCost]
The exact total amount of gas used right before the postOp
function. It is provided to postOp
function to ensure that the gas cost can be covered, either by the user or the paymaster.
[param3: uint256 actualUserOpFeePerGas] This parameter represents the gas price used for the TX. In the context of SCWs, the gas fee must be calculated based on the initial price provided by the bundler to ensure consistency between off-chain and on-chain calculations. Since gas prices can fluctuate due to network volatility, mismatches between the predicted gas fee (off-chain) and the actual gas fee (on-chain) could lead to unexpected issues like denial of service (DoS). To mitigate this, all operations must use a consistent gas price to ensure stability and avoid such risks in account abstraction.
If the call to the postOp
function fails, the innerHandleOp()
function will also revert, triggering the catch block.
For example, if a user uses paymaster that converts their tokens into ETH, they can cover the gas fees using tokens like ERC-20 Token, which are repaid to the bundler. Additionally, if the paymaster is running a promotion, the user may benefit from reduced gas fees, as the paymaster can cover a portion of the total gas costs. (For more details, check out the section.) The flexibility of the paymaster interface also opens up many possibilities for customizing operations further.
validatePaymasterUserOp()
function is called by . All external contract calls are handled within try-catch blocks to manage potential revert messages issued by the EntryPoint
. These revert messages follow a specific format (e.g. AA33) and are used by the bundler to impose penalties within its system when a malicious operation triggers a revert.
[gas: pmVerificationGasLimit]
This parameter originates from the userOp
struct. Its value is taken from mUserOp
, which is used by the Entrypoint
to duplicate the userOp
calldata into memory, helping to save gas fees. Typically, this value is packed within the paymasterAndData
field in the userOp
calldata. However, if the user isn't utilizing a paymaster, the value in mUserOp
will be set to zero. The value is then before being . This process ensures that the value resides in the stack area since mUserOp
is located in memory, and pmVerificationGasLimit
(uint256
type) will be pushed onto the stack.
The validatePaymasterUserOp
function doesn't revert if the signature validation fails. Instead, it returns a flag value of 1
, encoded within the 20-byte sigAuthorizer
section. This design avoids immediate reversion since the EntryPoint
has its own reversion format, and it allows the bundler to handle potential penalties. If the signature validation succeeds, the function returns 0
.
The validUntil
and validAfter
fields represent timestamps. The operation will only be executed if the current timestamp is greater than validAfter
and less than validUntil
. This check is performed immediately after receiving the return value in the function.
Typically, operation in postOp
function is used to calculate the gas consumed and claim it from another entity. However, this is just an example—the specific gas claiming logic varies depending on the paymaster. Some paymasters cover all the gas fees and claim them off-chain, while others claim the gas fees during the transaction itself. If you're reviewing a specific paymaster, refer to this documentation. For examples of how different paymasters operate, check the postOp
section in the documentation for .
There are several reversion catch codes, but the one we need to focus on is the . In this scenario, _executeUserOp()
emits the PostOpRevertReason
and then calls another function, _postExecution()
, with the mode set to postOpReverted
. However, it does not invoke postOp
again because the state changes made by SCW are lost due to the revert. As a result, the responsibility for handling the revert in postOp
now falls on the paymaster.