-
Notifications
You must be signed in to change notification settings - Fork 14
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
base: master
Are you sure you want to change the base?
Conversation
There are a few things a don't feel quite right in the PaymasterERC20:
Instead, I think a better abstraction for the PaymasterERC20 is that it always calls performs 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 This would require adjusting the 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 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 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); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
Pushed another iteration addressing several of the raised points, including a different approach to allow 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. |
There was a problem hiding this 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, |
There was a problem hiding this comment.
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
address indexed user, | |
address indexed token, |
uint256 prefundAmount = _erc20Cost(maxCost, userOp.maxFeePerGas(), tokenPrice); | ||
(bool prefundSuccess, bytes memory prefundContext) = _prefund( | ||
userOp, | ||
userOpHash | ||
userOpHash, | ||
userOp.sender, | ||
token, | ||
prefundAmount | ||
); |
There was a problem hiding this comment.
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
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 | |
); |
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); | ||
} |
There was a problem hiding this comment.
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
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_ | |
); |
uint256 actualAmount = _erc20Cost(actualGasCost, actualUserOpFeePerGas, tokenPrice); | ||
_refund(userOpSender, token, prefundAmount, actualAmount, prefundContext); |
There was a problem hiding this comment.
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
uint256 actualAmount = _erc20Cost(actualGasCost, actualUserOpFeePerGas, tokenPrice); | |
_refund(userOpSender, token, prefundAmount, actualAmount, prefundContext); | |
(bool refunded, uint256 actualAmount) = _refund( | |
token, | |
tokenPrice, | |
actualGasCost, | |
actualUserOpFeePerGas, | |
prefunder, | |
prefundAmount, | |
prefundContext | |
); |
function _refund( | ||
address prefunder, | ||
IERC20 token, | ||
uint256 prefundAmount, | ||
uint256 actualAmount, | ||
bytes calldata /* prefundContext */ | ||
) internal virtual { | ||
token.safeTransfer(prefunder, prefundAmount - actualAmount); | ||
} |
There was a problem hiding this comment.
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
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_); | |
} |
Inspired on #75 (review).
Guarantor logic is minimized on
PaymasterERC20
, with the presence of a dynamicprefundPayer
being theuserOp.sender
in the case of a simple PaymasterERC20, and theguarantor
in the case of PaymasterERC20Guarantor.Extras: Modularization of
_erc20Cost
for re-usage of calculations, plus_decodeContext
for it's re-usage on children contracts.