Breaking down the paymaster

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 several types of the paymaster.

The interfaces described by ERC-4337

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 start: intertaction with the EntryPoint

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.

The paymaster's operation overview. SourceCode: eth-infinitism

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.

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 Several types of the paymaster section.) The flexibility of the paymaster interface also opens up many possibilities for customizing operations further.

Paymaster-validation phase

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.

Try-Catch mechanism of EntryPoint in validation phase

try
    IPaymaster(paymaster).validatePaymasterUserOp{gas: pmVerificationGasLimit}(
        op,
        opInfo.userOpHash,
        requiredPreFund
    )
returns (bytes memory _context, uint256 _validationData) {
    context = _context;
    validationData = _validationData;
} catch {
    revert FailedOpWithRevert(opIndex, "AA33 reverted", Exec.getReturnData(REVERT_REASON_MAX_LEN));
}

validatePaymasterUserOp() function is called by EntryPoint in validation phase. 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.

validatePaymasterUserOp: params

  • [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 unpacked and stored in mUserOp.paymasterVerificationGasLimit before being passed to pmVerificationGasLimit. 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.

  • [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.

validatePaymasterUserOp: returns

  • [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 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 _validateAccountAndPaymasterValidationData function.

Paymaster-execution phase

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.

Before we start: PostOpMode

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.

The paymaster's interaction on the execution phase

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:

_postExecution: params

  • [param0: IPaymaster.PostOpMode mode] The value which represents whether the user's TX succeed or failed.

  • [param1: UserOpInfo memory opInfo]

    struct UserOpInfo {
        MemoryUserOp mUserOp;
        bytes32 userOpHash;
        uint256 prefund;
        uint256 contextOffset;
        uint256 preOpGas;
    }

    - 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.

postOp: params

  • [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.

What operation would be in the postOp?

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 Several types of the paymaster.

Caution: What if the initial postOp reverts?

If the call to the postOp function fails, the innerHandleOp() function will also revert, triggering the catch block.

            } else {
                emit PostOpRevertReason(
                    opInfo.userOpHash,
                    opInfo.mUserOp.sender,
                    opInfo.mUserOp.nonce,
                    Exec.getReturnData(REVERT_REASON_MAX_LEN)
                );

                uint256 actualGas = preGas - gasleft() + opInfo.preOpGas;
                collected = _postExecution(
                    IPaymaster.PostOpMode.postOpReverted,
                    opInfo,
                    context,
                    actualGas
                );
            }

There are several reversion catch codes, but the one we need to focus on is the else statement. 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.

Last updated