Skip to content

PaymasterERC20 simplification via a PaymasterERC20Guarantor Extension #107

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from

Conversation

gonzaotc
Copy link
Contributor

@gonzaotc gonzaotc commented Apr 8, 2025

Inspired on #75 (review).

Guarantor logic is minimized on PaymasterERC20, with the presence of a dynamic prefundPayer being the userOp.sender in the case of a simple PaymasterERC20, and the guarantor in the case of PaymasterERC20Guarantor.

Extras: Modularization of _erc20Cost for re-usage of calculations, plus _decodeContext for it's re-usage on children contracts.

@gonzaotc gonzaotc requested a review from a team as a code owner April 8, 2025 23:02
@gonzaotc gonzaotc marked this pull request as draft April 8, 2025 23:04
@gonzaotc gonzaotc changed the title PaymasterERC20 simplification via a PaymasterERC20Guarantor Extension <DRAFT> PaymasterERC20 simplification via a PaymasterERC20Guarantor Extension Apr 8, 2025
@gonzaotc gonzaotc requested review from Amxx and ernestognw April 8, 2025 23:11
@ernestognw
Copy link
Member

There are a few things a don't feel quite right in the PaymasterERC20:

  1. The prefundPayer function: This function feels off because discerning who pre-funded the operation seems only important in the context of the guarantor. In the PaymasterERC20, it's always the userOp.sender, which makes it feel like the contract is accommodating for an extension.
  2. The _decodeContext function: Although I'd agree is a useful function, it can't be extended because the interface is pretty restrictive (I mean, it has strict return types so nobody would override it or reuse it in an extension).
  3. The userOpSender and prefundPayer in context: Similarly, these are details that are useful in the context of a guarantor, otherwise they don't make much sense here.

Instead, I think a better abstraction for the PaymasterERC20 is that it always calls performs _prefund and a _refund operations in _validatePaymasterUserOp and _postOp respectively. So, if we define these functions, then it'd be possible to pass extensible context between them as _validatePaymasterUserOp and _postOp do. See:

function _prefund(
    PackedUserOperation calldata userOp,
    bytes32 /* userOpHash */,
    IERC20 token,
    uint256 tokenPrice,
    address prefunder,
    uint256 maxCost
) internal virtual returns (bool success, uint256 prefundAmount, bytes memory context) {
    uint256 _prefundAmount = _erc20Cost(maxCost, userOp.maxFeePerGas(), tokenPrice);
    return (
        token.trySafeTransferFrom(prefunder, address(this), _prefundAmount),
        _prefundAmount,
        abi.encodePacked(prefunder)
    );
}

...

function _refund(
    IERC20 token,
    uint256 prefundAmount,
    uint256 actualAmount,
    bytes calldata prefundContext
) internal virtual returns (bool) {
    address prefundPayer = address(bytes20(prefundContext[0x00:0x24]));
    return token.trySafeTransfer(prefundPayer, prefundAmount - actualAmount);
}

I think this version is a better abstraction because it addresses the points I raised before. First, the prefundPayer is not there anymore since it's just passed as context between _prefund and _refund, also the _decodeContext is not needed and not called twice since the interface (i.e. what the context decodes into) is enforced by the PaymasterERC20 implementation. Lastly, the userOpSender is not needed in context.

This would require adjusting the _validatePaymasterUserOp and _postOp functions as follows:

function _validatePaymasterUserOp(
    PackedUserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 maxCost
) internal virtual override returns (bytes memory context, uint256 validationData) {
    (uint256 validationData_, IERC20 token, uint256 tokenPrice) = _fetchDetails(userOp, userOpHash);
    (bool success, uint256 prefundAmount, bytes memory prefundContext) = _prefund(
        userOp,
        userOpHash,
        token,
        tokenPrice,
        userOp.sender,
        maxCost
    );

    // if validation is obviously failed, don't even try to do the ERC-20 transfer
    return
        (validationData_ != ERC4337Utils.SIG_VALIDATION_FAILED && success)
            ? (abi.encodePacked(userOpHash, token, prefundAmount, tokenPrice, prefundContext), validationData_)
            : (bytes(""), ERC4337Utils.SIG_VALIDATION_FAILED);
}

/// @inheritdoc PaymasterCore
function _postOp(
    PostOpMode /* mode */,
    bytes calldata context,
    uint256 actualGasCost,
    uint256 actualUserOpFeePerGas
) internal virtual override {
    bytes32 userOpHash = bytes32(context[0x00:0x20]);
    IERC20 token = IERC20(address(bytes20(context[0x20:0x34])));
    uint256 prefundAmount = uint256(bytes32(context[0x34:0x54]));
    uint256 tokenPrice = uint256(bytes32(context[0x54:0x74]));
    bytes calldata prefundContext = context[0x74:0x94];

    uint256 actualAmount = _erc20Cost(actualGasCost, actualUserOpFeePerGas, tokenPrice);
    if (!_refund(token, prefundAmount, actualAmount, prefundContext)) {
        revert PaymasterERC20FailedRefund(token, prefundAmount, actualAmount, prefundContext);
    }

    emit UserOperationSponsored(userOpHash, actualAmount, tokenPrice);
}

Perhaps looks more complex at first, but the PaymasterERC20Guarantor implementation becomes very clean:

abstract contract PaymasterERC20Guarantor is PaymasterERC20 {
    using SafeERC20 for IERC20;

    event UserOperationGuaranteed(bytes32 indexed userOpHash, address indexed user, address indexed guarantor);

    function _prefund(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash,
        IERC20 token,
        uint256 tokenPrice,
        address /* prefunder */,
        uint256 maxCost
    ) internal virtual override returns (bool success, uint256 prefundAmount, bytes memory context) {
        (uint256 validationData_, address guarantor) = _fetchGuarantor(userOp);
        if (validationData_ == ERC4337Utils.SIG_VALIDATION_SUCCESS && guarantor != address(0)) {
            emit UserOperationGuaranteed(userOpHash, userOp.sender, guarantor);
        }
        (success, prefundAmount, context) = super._prefund(userOp, userOpHash, token, tokenPrice, guarantor, maxCost);
        context = abi.encodePacked(context, userOp.sender);
    }

    function _refund(
        IERC20 token,
        uint256 prefundAmount,
        uint256 actualAmount,
        bytes calldata prefundContext
    ) internal virtual override returns (bool) {
        address prefunder = address(bytes20(prefundContext[0x00:0x24]));
        address userOpSender = address(bytes20(prefundContext[0x24:0x48]));
        // If a prefunder is provided and the prefunder is not the userOpSender,
        // attempt to transfer the prefundAmount from the prefunder to this paymaster.
        if (prefunder != userOpSender && token.trySafeTransferFrom(userOpSender, address(this), actualAmount)) {
            // If successful, pay back the guarantor the prefundAmount.
            // Is it ok to revert here?
            token.safeTransfer(prefunder, prefundAmount);
            return true;
        } else {
            // Otherwise, refund the guarantor the prefund remainder.
            return super._refund(token, prefundAmount, actualAmount, prefundContext);
        }
    }

    function _postOpCost() internal view virtual override returns (uint256) {
        return super._postOpCost() + 15_000;
    }

    /**
     * @dev Fetches the guarantor address and validation data from the user operation.
     * Must be implemented in order to correctly enable guarantor functionality.
     */
    function _fetchGuarantor(
        PackedUserOperation calldata userOp
    ) internal view virtual returns (uint256 validationData, address guarantor);
}

In this way, the _decodeContext

Full PaymasterERC20 implementation
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {ERC4337Utils, PackedUserOperation} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {PaymasterCore} from "./PaymasterCore.sol";

/**
 * @dev Extension of {PaymasterCore} that enables users to pay gas with ERC-20 tokens.
 *
 * To enable this feature, developers must implement the {fetchDetails} function:
 *
 * ```solidity
 * function _fetchDetails(
 *     PackedUserOperation calldata userOp,
 *     bytes32 userOpHash
 * ) internal view override returns (uint256 validationData, IERC20 token, uint256 tokenPrice) {
 *     // Implement logic to fetch the token, and token price from the userOp
 * }
 * ```
 */
abstract contract PaymasterERC20 is PaymasterCore {
    using ERC4337Utils for *;
    using Math for *;
    using SafeERC20 for IERC20;

    error PaymasterERC20FailedRefund(IERC20 token, uint256 prefundAmount, uint256 actualAmount, bytes prefundContext);

    event UserOperationSponsored(bytes32 indexed userOpHash, uint256 tokenAmount, uint256 tokenPrice);

    /// @inheritdoc PaymasterCore
    function _validatePaymasterUserOp(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash,
        uint256 maxCost
    ) internal virtual override returns (bytes memory context, uint256 validationData) {
        (uint256 validationData_, IERC20 token, uint256 tokenPrice) = _fetchDetails(userOp, userOpHash);
        (bool success, uint256 prefundAmount, bytes memory prefundContext) = _prefund(
            userOp,
            userOpHash,
            token,
            tokenPrice,
            userOp.sender,
            maxCost
        );

        // if validation is obviously failed, don't even try to do the ERC-20 transfer
        return
            (validationData_ != ERC4337Utils.SIG_VALIDATION_FAILED && success)
                ? (abi.encodePacked(userOpHash, token, prefundAmount, tokenPrice, prefundContext), validationData_)
                : (bytes(""), ERC4337Utils.SIG_VALIDATION_FAILED);
    }

    function _prefund(
        PackedUserOperation calldata userOp,
        bytes32 /* userOpHash */,
        IERC20 token,
        uint256 tokenPrice,
        address prefunder,
        uint256 maxCost
    ) internal virtual returns (bool success, uint256 prefundAmount, bytes memory context) {
        uint256 _prefundAmount = _erc20Cost(maxCost, userOp.maxFeePerGas(), tokenPrice);
        return (
            token.trySafeTransferFrom(prefunder, address(this), _prefundAmount),
            _prefundAmount,
            abi.encodePacked(prefunder)
        );
    }

    /// @inheritdoc PaymasterCore
    function _postOp(
        PostOpMode /* mode */,
        bytes calldata context,
        uint256 actualGasCost,
        uint256 actualUserOpFeePerGas
    ) internal virtual override {
        bytes32 userOpHash = bytes32(context[0x00:0x20]);
        IERC20 token = IERC20(address(bytes20(context[0x20:0x34])));
        uint256 prefundAmount = uint256(bytes32(context[0x34:0x54]));
        uint256 tokenPrice = uint256(bytes32(context[0x54:0x74]));
        bytes calldata prefundContext = context[0x74:0x94];

        uint256 actualAmount = _erc20Cost(actualGasCost, actualUserOpFeePerGas, tokenPrice);
        if (!_refund(token, prefundAmount, actualAmount, prefundContext)) {
            revert PaymasterERC20FailedRefund(token, prefundAmount, actualAmount, prefundContext);
        }

        emit UserOperationSponsored(userOpHash, actualAmount, tokenPrice);
    }

    function _refund(
        IERC20 token,
        uint256 prefundAmount,
        uint256 actualAmount,
        bytes calldata prefundContext
    ) internal virtual returns (bool) {
        address prefundPayer = address(bytes20(prefundContext[0x00:0x24]));
        return token.trySafeTransfer(prefundPayer, prefundAmount - actualAmount);
    }

    /**
     * @dev Retrieves payment details for a user operation
     *
     * The values returned by this internal function are:
     * * `validationData`: ERC-4337 validation data, indicating success/failure and optional time validity (`validAfter`, `validUntil`).
     * * `token`: Address of the ERC-20 token used for payment to the paymaster.
     * * `tokenPrice`: Price of the token in native currency, scaled by `_tokenPriceDenominator()`.
     *
     * ==== Calculating the token price
     *
     * Given gas fees are paid in native currency, developers can use the `ERC20 price unit / native price unit` ratio to
     * calculate the price of an ERC20 token price in native currency. However, the token may have a different number of decimals
     * than the native currency. For a a generalized formula considering prices in USD and decimals, consider using:
     *
     * `(<ERC-20 token price in $> / 10**<ERC-20 decimals>) / (<Native token price in $> / 1e18) * _tokenPriceDenominator()`
     *
     * For example, suppose token is USDC ($1 with 6 decimals) and native currency is ETH (assuming $2524.86 with 18 decimals),
     * then each unit (1e-6) of USDC is worth `(1 / 1e6) / ((252486 / 1e2) / 1e18) = 396061563.8094785` wei. The `_tokenPriceDenominator()`
     * ensures precision by avoiding fractional value loss. (i.e. the 0.8094785 part).
     *
     */
    function _fetchDetails(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash
    ) internal view virtual returns (uint256 validationData, IERC20 token, uint256 tokenPrice);

    // @dev Over-estimates the cost of the post-operation logic.
    function _postOpCost() internal view virtual returns (uint256) {
        return 30_000;
    }

    /// @dev Denominator used for interpreting the `tokenPrice` returned by {_fetchDetails} as "fixed point".
    function _tokenPriceDenominator() internal view virtual returns (uint256) {
        return 1e18;
    }

    // @dev Calculates the cost of the user operation in ERC-20 tokens.
    function _erc20Cost(uint256 cost, uint256 feePerGas, uint256 tokenPrice) internal view virtual returns (uint256) {
        return (cost + _postOpCost() * feePerGas).mulDiv(tokenPrice, _tokenPriceDenominator());
    }

    /// @dev Public function that allows the withdrawer to extract ERC-20 tokens resulting from gas payments.
    function withdrawTokens(IERC20 token, address recipient, uint256 amount) public virtual onlyWithdrawer {
        if (amount == type(uint256).max) amount = token.balanceOf(address(this));
        token.safeTransfer(recipient, amount);
    }
}

) = _decodeContext(context);

uint256 actualAmount = _erc20Cost(actualGasCost, actualUserOpFeePerGas, tokenPrice);
token.safeTransfer(userOpSender, prefundAmount - actualAmount);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if _prefundPayer is overriden, and the returned value is NOT userOpSender, then this contract will take the prefund from that account and give the refund to the userOpSender

This incorrect.

if (validationData_ == ERC4337Utils.SIG_VALIDATION_SUCCESS && guarantor != address(0)) {
emit UserOperationGuaranteed(userOpHash, userOp.sender, guarantor);
}
return super._validatePaymasterUserOp(userOp, userOpHash, maxCost);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is a guarantor setup, the prefund will still be paid by _prefundPayer ... That doesn't feel right. This is a huge footgun if devs forget to override one of them, or if there is any inconsistency in the override

}

function _postOpCost() internal view virtual override returns (uint256) {
return super._postOpCost() + 15_000;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extra price should only be applied when there is a guarantor (to cover the extra transfers).
If there is no guarantor, the super._postOpCost() should not be modified.

Copy link
Collaborator

@Amxx Amxx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments about this PR:

  • The "simple" version includes a virtual _prefundPayer that basically support "someone else paying" ... except that if that someone else is not the sender, the refund function is broken :/
  • The "extended" version supports the guarantor (basically it supports the _prefundPayer from the simple version being overriden)

I don't think this is a good design because the basic version makes you think it contains logic that in fact it doesn't support. It feels like a huge footgun. if you want to support customizing the "prefunder" ... do it completelly! IMO that means supporting it in the postOp side as well ... and yes, we very quickly get the "full version" that we have right no.

IMO, slitting the contract in two is ok only if both versions are clean, and don't have "dangling feature" that look like they are here, and that you may override, but are actually not supporting.

@gonzaotc
Copy link
Contributor Author

Pushed another iteration addressing several of the raised points, including a different approach to allow _prefund and _refund customization in inheriting Extensions. That said, I'm still not convinced the added complexity is justified or that this is the cleanest path forward. For now, I’d prefer to stick with the current version while we focus on higher-priority items.

Known issue: The current implementation always adds 15,000 gas in _postOpCost, regardless of whether the transaction was guaranteed. This should only apply when a guarantor is actually involved.

Copy link
Member

@ernestognw ernestognw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll push some updates

@@ -30,74 +30,77 @@ abstract contract PaymasterERC20 is PaymasterCore {
event UserOperationSponsored(
bytes32 indexed userOpHash,
address indexed user,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove the user. Events are for indexers, and from their perspective, you can always query the User Operation by using the userOpHash, which would get you all the details, including user. Instead, I would track the token here

Suggested change
address indexed user,
address indexed token,

Comment on lines 45 to 52
uint256 prefundAmount = _erc20Cost(maxCost, userOp.maxFeePerGas(), tokenPrice);
(bool prefundSuccess, bytes memory prefundContext) = _prefund(
userOp,
userOpHash
userOpHash,
userOp.sender,
token,
prefundAmount
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should move _erc20Cost inside _prefund. The reasoning is that the prefundAmount should not be an argument to a function, we have the _erc20Cost virtual function to allow developers to consistently override the cost. Instead, I'd pass the tokenPrice and maxCost and return the prefundAmount and prefunder

Suggested change
uint256 prefundAmount = _erc20Cost(maxCost, userOp.maxFeePerGas(), tokenPrice);
(bool prefundSuccess, bytes memory prefundContext) = _prefund(
userOp,
userOpHash
userOpHash,
userOp.sender,
token,
prefundAmount
);
(bool prefunded, uint256 prefundAmount, address prefunder, bytes memory prefundContext) = _prefund(
userOp,
userOpHash,
token,
tokenPrice,
userOp.sender,
maxCost
);

Comment on lines 54 to 61
return
(validationData_ != ERC4337Utils.SIG_VALIDATION_FAILED &&
token.trySafeTransferFrom(
guarantor == address(0) ? userOp.sender : guarantor,
address(this),
prefundAmount
))
(validationData_ != ERC4337Utils.SIG_VALIDATION_FAILED && prefundSuccess)
? (
abi.encodePacked(userOpHash, token, prefundAmount, tokenPrice, userOp.sender, guarantor),
abi.encodePacked(userOpHash, token, prefundAmount, tokenPrice, userOp.sender, prefundContext),
validationData_
)
: (bytes(""), ERC4337Utils.SIG_VALIDATION_FAILED);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is better because it short-circuits if validationData_ == SIG_VALIDATION_FAILED already. Note that I'm returning the prefunder instead of the userOp.user. The reasoning is that in the _postOp you only care about who prefunded the operation since we're not passing the user to the UserOperationSponsored event

Suggested change
return
(validationData_ != ERC4337Utils.SIG_VALIDATION_FAILED &&
token.trySafeTransferFrom(
guarantor == address(0) ? userOp.sender : guarantor,
address(this),
prefundAmount
))
(validationData_ != ERC4337Utils.SIG_VALIDATION_FAILED && prefundSuccess)
? (
abi.encodePacked(userOpHash, token, prefundAmount, tokenPrice, userOp.sender, guarantor),
abi.encodePacked(userOpHash, token, prefundAmount, tokenPrice, userOp.sender, prefundContext),
validationData_
)
: (bytes(""), ERC4337Utils.SIG_VALIDATION_FAILED);
}
if (validationData_ == ERC4337Utils.SIG_VALIDATION_FAILED || !prefunded)
return (bytes(""), ERC4337Utils.SIG_VALIDATION_FAILED);
return (
abi.encodePacked(userOpHash, token, tokenPrice, prefundAmount, prefunder, prefundContext),
validationData_
);

Comment on lines 89 to 90
uint256 actualAmount = _erc20Cost(actualGasCost, actualUserOpFeePerGas, tokenPrice);
_refund(userOpSender, token, prefundAmount, actualAmount, prefundContext);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, I think it's better to encapsulate _erc20Cost inside _refund to avoid allowing a developer to override and provide a custom actualAmount that differs from the _erc20Cost implementation. The principle is that if we have an abstraction (e.g. _erc20Cost), we should recommend only that one to override the token amount calculation consistently.

Also, it seems that the_refund implementation should return a boolean instead, I'll elaborate on that in another comment

Suggested change
uint256 actualAmount = _erc20Cost(actualGasCost, actualUserOpFeePerGas, tokenPrice);
_refund(userOpSender, token, prefundAmount, actualAmount, prefundContext);
(bool refunded, uint256 actualAmount) = _refund(
token,
tokenPrice,
actualGasCost,
actualUserOpFeePerGas,
prefunder,
prefundAmount,
prefundContext
);

Comment on lines 96 to 104
function _refund(
address prefunder,
IERC20 token,
uint256 prefundAmount,
uint256 actualAmount,
bytes calldata /* prefundContext */
) internal virtual {
token.safeTransfer(prefunder, prefundAmount - actualAmount);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation is too restrictive. The reason is that safeTransfer may revert. In that case, any developer who overrides this function and calls virtual will need to catch the error and continue if they need.

I think a better representation of this function must return a success boolean along with the actualAmount as previously noted

Suggested change
function _refund(
address prefunder,
IERC20 token,
uint256 prefundAmount,
uint256 actualAmount,
bytes calldata /* prefundContext */
) internal virtual {
token.safeTransfer(prefunder, prefundAmount - actualAmount);
}
function _refund(
IERC20 token,
uint256 tokenPrice,
uint256 actualGasCost,
uint256 actualUserOpFeePerGas,
address prefunder,
uint256 prefundAmount,
bytes calldata /* prefundContext */
) internal virtual returns (bool refunded, uint256 actualAmount) {
uint256 actualAmount_ = _erc20Cost(actualGasCost, actualUserOpFeePerGas, tokenPrice);
return (token.trySafeTransferFrom(address(this), prefunder, prefundAmount - actualAmount_), actualAmount_);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants