Skip to content

Commit 3f9b00d

Browse files
gonzaotcernestognw
andauthored
PaymasterERC20 simplification via a PaymasterERC20Guarantor Extension (#107)
Co-authored-by: ernestognw <[email protected]>
1 parent 0317d56 commit 3f9b00d

File tree

7 files changed

+1006
-451
lines changed

7 files changed

+1006
-451
lines changed

contracts/account/README.adoc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This directory includes contracts to build accounts for ERC-4337. These include:
1212
* {ERC7579SignatureValidator}: Implementation of ERC7579Validator using ERC-7913 signature verification for address-less cryptographic keys.
1313
* {PaymasterCore}: An ERC-4337 paymaster implementation that includes the core logic to validate and pay for user operations.
1414
* {PaymasterERC20}: A paymaster that allows users to pay for user operations using ERC-20 tokens.
15+
* {PaymasterERC20Guarantor}: A paymaster that enables third parties to guarantee user operations by pre-funding gas costs, with the option for users to repay or for guarantors to absorb the cost.
1516
* {PaymasterERC721Owner}: A paymaster that allows users to pay for user operations based on ERC-721 ownership.
1617
* {PaymasterSigner}: A paymaster that allows users to pay for user operations using an authorized signature.
1718

@@ -41,6 +42,8 @@ This directory includes contracts to build accounts for ERC-4337. These include:
4142

4243
{{PaymasterERC20}}
4344

45+
{{PaymasterERC20Guarantor}}
46+
4447
{{PaymasterERC721Owner}}
4548

4649
{{PaymasterSigner}}

contracts/account/paymaster/PaymasterERC20.sol

Lines changed: 125 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ pragma solidity ^0.8.20;
44

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

@@ -17,60 +16,105 @@ import {PaymasterCore} from "./PaymasterCore.sol";
1716
* function _fetchDetails(
1817
* PackedUserOperation calldata userOp,
1918
* bytes32 userOpHash
20-
* ) internal view override returns (uint256 validationData, IERC20 token, uint256 tokenPrice, address guarantor) {
21-
* // Implement logic to fetch the token, token price, and guarantor address from the userOp
19+
* ) internal view override returns (uint256 validationData, IERC20 token, uint256 tokenPrice) {
20+
* // Implement logic to fetch the token, and token price from the userOp
2221
* }
2322
* ```
23+
*
24+
* The contract follows a pre-charge and refund model:
25+
* 1. During validation, it pre-charges the maximum possible gas cost
26+
* 2. After execution, it refunds any unused gas back to the user
2427
*/
2528
abstract contract PaymasterERC20 is PaymasterCore {
2629
using ERC4337Utils for *;
2730
using Math for *;
2831
using SafeERC20 for IERC20;
2932

33+
/**
34+
* @dev Emitted when a user operation identified by `userOpHash` is sponsored by this paymaster
35+
* using the specified ERC-20 `token`. The `tokenAmount` is the amount charged for the operation,
36+
* and `tokenPrice` is the price of the token in native currency (e.g., ETH).
37+
*/
3038
event UserOperationSponsored(
3139
bytes32 indexed userOpHash,
32-
address indexed user,
33-
address indexed guarantor,
40+
address indexed token,
3441
uint256 tokenAmount,
35-
uint256 tokenPrice,
36-
bool paidByGuarantor
42+
uint256 tokenPrice
3743
);
3844

39-
// Over-estimations: ERC-20 balances/allowances may be cold and contracts may not be optimized
40-
uint256 private constant POST_OP_COST = 30_000;
41-
uint256 private constant POST_OP_COST_WITH_GUARANTOR = 45_000;
45+
/**
46+
* @dev Throws when the paymaster fails to refund the difference between the `prefundAmount`
47+
* and the `actualAmount` of `token`.
48+
*/
49+
error PaymasterERC20FailedRefund(IERC20 token, uint256 prefundAmount, uint256 actualAmount, bytes prefundContext);
4250

43-
/// @inheritdoc PaymasterCore
51+
/**
52+
* @dev See {PaymasterCore-_validatePaymasterUserOp}.
53+
*
54+
* Attempts to retrieve the `token` and `tokenPrice` from the user operation (see {_fetchDetails})
55+
* and prefund the user operation using these values and the `maxCost` argument (see {_prefund}).
56+
*
57+
* Returns `abi.encodePacked(userOpHash, token, tokenPrice, prefundAmount, prefunder, prefundContext)` in
58+
* `context` if the prefund is successful. Otherwise, it returns empty bytes.
59+
*/
4460
function _validatePaymasterUserOp(
4561
PackedUserOperation calldata userOp,
4662
bytes32 userOpHash,
4763
uint256 maxCost
4864
) internal virtual override returns (bytes memory context, uint256 validationData) {
49-
(uint256 validationData_, IERC20 token, uint256 tokenPrice, address guarantor) = _fetchDetails(
65+
IERC20 token;
66+
uint256 tokenPrice;
67+
(validationData, token, tokenPrice) = _fetchDetails(userOp, userOpHash);
68+
context = abi.encodePacked(userOpHash, token, tokenPrice);
69+
(bool prefunded, uint256 prefundAmount, address prefunder, bytes memory prefundContext) = _prefund(
5070
userOp,
51-
userOpHash
71+
userOpHash,
72+
token,
73+
tokenPrice,
74+
userOp.sender,
75+
maxCost
5276
);
5377

54-
uint256 prefundAmount = (maxCost +
55-
(guarantor == address(0)).ternary(POST_OP_COST, POST_OP_COST_WITH_GUARANTOR) *
56-
userOp.maxFeePerGas()).mulDiv(tokenPrice, _tokenPriceDenominator());
57-
58-
// if validation is obviously failed, don't even try to do the ERC-20 transfer
59-
return
60-
(validationData_ != ERC4337Utils.SIG_VALIDATION_FAILED &&
61-
token.trySafeTransferFrom(
62-
guarantor == address(0) ? userOp.sender : guarantor,
63-
address(this),
64-
prefundAmount
65-
))
66-
? (
67-
abi.encodePacked(userOpHash, token, prefundAmount, tokenPrice, userOp.sender, guarantor),
68-
validationData_
69-
)
70-
: (bytes(""), ERC4337Utils.SIG_VALIDATION_FAILED);
78+
if (validationData == ERC4337Utils.SIG_VALIDATION_FAILED || !prefunded)
79+
return (bytes(""), ERC4337Utils.SIG_VALIDATION_FAILED);
80+
81+
return (abi.encodePacked(context, prefundAmount, prefunder, prefundContext), validationData);
82+
}
83+
84+
/**
85+
* @dev Prefunds the `userOp` by charging the maximum possible gas cost (`maxCost`) in ERC-20 `token`.
86+
*
87+
* The `token` and `tokenPrice` is obtained from the {_fetchDetails} function and are funded by the `prefunder_`,
88+
* which is the user operation sender by default. The `prefundAmount` is calculated using {_erc20Cost}.
89+
*
90+
* Returns a `prefundContext` that's passed to the {_postOp} function through its `context` return value.
91+
*
92+
* NOTE: Consider not reverting if the prefund fails when overriding this function. This is to avoid reverting
93+
* during the validation phase of the user operation, which may penalize the paymaster's reputation according
94+
* to ERC-7562 validation rules.
95+
*/
96+
function _prefund(
97+
PackedUserOperation calldata userOp,
98+
bytes32 /* userOpHash */,
99+
IERC20 token,
100+
uint256 tokenPrice,
101+
address prefunder_,
102+
uint256 maxCost
103+
) internal virtual returns (bool prefunded, uint256 prefundAmount, address prefunder, bytes memory prefundContext) {
104+
uint256 feePerGas = userOp.maxFeePerGas();
105+
uint256 _prefundAmount = _erc20Cost(maxCost, feePerGas, tokenPrice);
106+
return (token.trySafeTransferFrom(prefunder_, address(this), _prefundAmount), _prefundAmount, prefunder_, "");
71107
}
72108

73-
/// @inheritdoc PaymasterCore
109+
/**
110+
* @dev Attempts to refund the user operation after execution. See {_refund}.
111+
*
112+
* Reverts with {PaymasterERC20FailedRefund} if the refund fails.
113+
*
114+
* IMPORTANT: This function may revert after the user operation has been executed without
115+
* reverting the user operation itself. Consider implementing a mechanism to handle
116+
* this case gracefully.
117+
*/
74118
function _postOp(
75119
PostOpMode /* mode */,
76120
bytes calldata context,
@@ -79,36 +123,54 @@ abstract contract PaymasterERC20 is PaymasterCore {
79123
) internal virtual override {
80124
bytes32 userOpHash = bytes32(context[0x00:0x20]);
81125
IERC20 token = IERC20(address(bytes20(context[0x20:0x34])));
82-
uint256 prefundAmount = uint256(bytes32(context[0x34:0x54]));
83-
uint256 tokenPrice = uint256(bytes32(context[0x54:0x74]));
84-
address user = address(bytes20(context[0x74:0x88]));
85-
address guarantor = address(bytes20(context[0x88:0x9C]));
86-
87-
uint256 actualAmount = (actualGasCost +
88-
(guarantor == address(0)).ternary(POST_OP_COST, POST_OP_COST_WITH_GUARANTOR) *
89-
actualUserOpFeePerGas).mulDiv(tokenPrice, _tokenPriceDenominator());
90-
91-
if (guarantor == address(0)) {
92-
token.safeTransfer(user, prefundAmount - actualAmount);
93-
emit UserOperationSponsored(userOpHash, user, address(0), actualAmount, tokenPrice, false);
94-
} else if (token.trySafeTransferFrom(user, address(this), actualAmount)) {
95-
token.safeTransfer(guarantor, prefundAmount);
96-
emit UserOperationSponsored(userOpHash, user, guarantor, actualAmount, tokenPrice, false);
97-
} else {
98-
token.safeTransfer(guarantor, prefundAmount - actualAmount);
99-
emit UserOperationSponsored(userOpHash, user, guarantor, actualAmount, tokenPrice, true);
126+
uint256 tokenPrice = uint256(bytes32(context[0x34:0x54]));
127+
uint256 prefundAmount = uint256(bytes32(context[0x54:0x74]));
128+
address prefunder = address(bytes20(context[0x74:0x88]));
129+
bytes calldata prefundContext = context[0x88:];
130+
131+
(bool refunded, uint256 actualAmount) = _refund(
132+
token,
133+
tokenPrice,
134+
actualGasCost,
135+
actualUserOpFeePerGas,
136+
prefunder,
137+
prefundAmount,
138+
prefundContext
139+
);
140+
if (!refunded) {
141+
revert PaymasterERC20FailedRefund(token, prefundAmount, actualAmount, prefundContext);
100142
}
143+
144+
emit UserOperationSponsored(userOpHash, address(token), actualAmount, tokenPrice);
145+
}
146+
147+
/**
148+
* @dev Refunds any unused gas back to the user (i.e. `prefundAmount - actualAmount`) in `token`.
149+
*
150+
* The `actualAmount` is calculated using {_erc20Cost} and the `actualGasCost`, `actualUserOpFeePerGas`, `prefundContext`
151+
* and the `tokenPrice` from the {_postOp}'s context.
152+
*/
153+
function _refund(
154+
IERC20 token,
155+
uint256 tokenPrice,
156+
uint256 actualGasCost,
157+
uint256 actualUserOpFeePerGas,
158+
address prefunder,
159+
uint256 prefundAmount,
160+
bytes calldata /* prefundContext */
161+
) internal virtual returns (bool refunded, uint256 actualAmount) {
162+
uint256 actualAmount_ = _erc20Cost(actualGasCost, actualUserOpFeePerGas, tokenPrice);
163+
return (token.trySafeTransfer(prefunder, prefundAmount - actualAmount_), actualAmount_);
101164
}
102165

103166
/**
104-
* @dev Retrieves payment details for a user operation
167+
* @dev Retrieves payment details for a user operation.
105168
*
106169
* The values returned by this internal function are:
107170
*
108171
* * `validationData`: ERC-4337 validation data, indicating success/failure and optional time validity (`validAfter`, `validUntil`).
109172
* * `token`: Address of the ERC-20 token used for payment to the paymaster.
110173
* * `tokenPrice`: Price of the token in native currency, scaled by `_tokenPriceDenominator()`.
111-
* * `guarantor`: Address of an entity advancing funds if the user lacks them; receives tokens during execution or pays if the user can't.
112174
*
113175
* ==== Calculating the token price
114176
*
@@ -121,23 +183,27 @@ abstract contract PaymasterERC20 is PaymasterCore {
121183
* For example, suppose token is USDC ($1 with 6 decimals) and native currency is ETH (assuming $2524.86 with 18 decimals),
122184
* then each unit (1e-6) of USDC is worth `(1 / 1e6) / ((252486 / 1e2) / 1e18) = 396061563.8094785` wei. The `_tokenPriceDenominator()`
123185
* ensures precision by avoiding fractional value loss. (i.e. the 0.8094785 part).
124-
*
125-
* ==== Guarantor
126-
*
127-
* To support a guarantor, developers can use the `paymasterData` field to store the guarantor's address. Developers can disable
128-
* support for a guarantor by returning `address(0)`. If supported, ensure explicit consent (e.g., signature verification) to prevent
129-
* unauthorized use.
130186
*/
131187
function _fetchDetails(
132188
PackedUserOperation calldata userOp,
133189
bytes32 userOpHash
134-
) internal view virtual returns (uint256 validationData, IERC20 token, uint256 tokenPrice, address guarantor);
190+
) internal view virtual returns (uint256 validationData, IERC20 token, uint256 tokenPrice);
191+
192+
/// @dev Over-estimates the cost of the post-operation logic.
193+
function _postOpCost() internal view virtual returns (uint256) {
194+
return 30_000;
195+
}
135196

136-
/// @dev Denominator used for interpreting the `tokenPrice` returned by {_fetchDetails} as "fixed point".
197+
/// @dev Denominator used for interpreting the `tokenPrice` returned by {_fetchDetails} as "fixed point" in {_erc20Cost}.
137198
function _tokenPriceDenominator() internal view virtual returns (uint256) {
138199
return 1e18;
139200
}
140201

202+
/// @dev Calculates the cost of the user operation in ERC-20 tokens.
203+
function _erc20Cost(uint256 cost, uint256 feePerGas, uint256 tokenPrice) internal view virtual returns (uint256) {
204+
return (cost + _postOpCost() * feePerGas).mulDiv(tokenPrice, _tokenPriceDenominator());
205+
}
206+
141207
/// @dev Public function that allows the withdrawer to extract ERC-20 tokens resulting from gas payments.
142208
function withdrawTokens(IERC20 token, address recipient, uint256 amount) public virtual onlyWithdrawer {
143209
if (amount == type(uint256).max) amount = token.balanceOf(address(this));
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
import {ERC4337Utils, PackedUserOperation} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
6+
import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
7+
import {PaymasterERC20} from "./PaymasterERC20.sol";
8+
9+
/**
10+
* @dev Extension of {PaymasterERC20} that enables third parties to guarantee user operations.
11+
*
12+
* This contract allows a guarantor to pre-fund user operations on behalf of users. The guarantor
13+
* pays the maximum possible gas cost upfront, and after execution:
14+
* 1. If the user repays the guarantor, the guarantor gets their funds back
15+
* 2. If the user fails to repay, the guarantor absorbs the cost
16+
*
17+
* A common use case is for guarantors to pay for the operations of users claiming airdrops. In this scenario:
18+
*
19+
* * The guarantor pays the gas fees upfront
20+
* * The user claims their airdrop tokens
21+
* * The user repays the guarantor from the claimed tokens
22+
* * If the user fails to repay, the guarantor absorbs the cost
23+
*
24+
* The guarantor is identified through the {_fetchGuarantor} function, which must be implemented
25+
* by developers to determine who can guarantee operations. This allows for flexible guarantor selection
26+
* logic based on the specific requirements of the application.
27+
*/
28+
abstract contract PaymasterERC20Guarantor is PaymasterERC20 {
29+
using SafeERC20 for IERC20;
30+
31+
/// @dev Emitted when a user operation identified by `userOpHash` is guaranteed by a `guarantor` for `prefundAmount`.
32+
event UserOperationGuaranteed(bytes32 indexed userOpHash, address indexed guarantor, uint256 prefundAmount);
33+
34+
/**
35+
* @dev Prefunds the user operation using either the guarantor or the default prefunder.
36+
* See {PaymasterERC20-_prefund}.
37+
*
38+
* Returns `abi.encodePacked(..., userOp.sender)` in `prefundContext` to allow
39+
* the refund process to identify the user operation sender.
40+
*/
41+
function _prefund(
42+
PackedUserOperation calldata userOp,
43+
bytes32 userOpHash,
44+
IERC20 token,
45+
uint256 tokenPrice,
46+
address prefunder_,
47+
uint256 maxCost
48+
)
49+
internal
50+
virtual
51+
override
52+
returns (bool prefunded, uint256 prefundAmount, address prefunder, bytes memory prefundContext)
53+
{
54+
address guarantor = _fetchGuarantor(userOp);
55+
bool isGuaranteed = guarantor != address(0);
56+
(prefunded, prefundAmount, prefunder, prefundContext) = super._prefund(
57+
userOp,
58+
userOpHash,
59+
token,
60+
tokenPrice,
61+
isGuaranteed ? guarantor : prefunder_,
62+
maxCost + (isGuaranteed ? 0 : _guaranteedPostOpCost())
63+
);
64+
if (prefunder == guarantor) {
65+
emit UserOperationGuaranteed(userOpHash, prefunder, prefundAmount);
66+
}
67+
return (prefunded, prefundAmount, prefunder, abi.encodePacked(prefundContext, userOp.sender));
68+
}
69+
70+
/**
71+
* @dev Handles the refund process for guaranteed operations.
72+
*
73+
* If the operation was guaranteed, it attempts to get repayment from the user first and then refunds the guarantor.
74+
* Otherwise, fallback to {PaymasterERC20-refund}. See {_refundGuaranteed}.
75+
*
76+
* NOTE: For guaranteed user operations where the user paid the `actualGasCost` back, this function
77+
* doesn't call `super._refund`. Consider whether there are side effects in the parent contract that need to be executed.
78+
*/
79+
function _refund(
80+
IERC20 token,
81+
uint256 tokenPrice,
82+
uint256 actualGasCost,
83+
uint256 actualUserOpFeePerGas,
84+
address prefunder,
85+
uint256 prefundAmount,
86+
bytes calldata prefundContext
87+
) internal virtual override returns (bool refunded, uint256 actualAmount) {
88+
address userOpSender = address(bytes20(prefundContext[0x00:0x14]));
89+
90+
if (prefunder != userOpSender) {
91+
actualAmount = _erc20Cost(actualGasCost, actualUserOpFeePerGas, tokenPrice);
92+
if (token.trySafeTransferFrom(userOpSender, address(this), actualAmount)) {
93+
// The paymaster gets the funds first, so in case of a failure, the guarantor absorbs the cost.
94+
return (token.trySafeTransfer(prefunder, prefundAmount), actualAmount);
95+
}
96+
}
97+
return
98+
super._refund(
99+
token,
100+
tokenPrice,
101+
actualGasCost,
102+
actualUserOpFeePerGas,
103+
prefunder,
104+
prefundAmount,
105+
prefundContext
106+
);
107+
}
108+
109+
/**
110+
* @dev Fetches the guarantor address and validation data from the user operation.
111+
*
112+
* NOTE: Return `address(0)` to disable the guarantor feature. If supported, ensure
113+
* explicit consent (e.g., signature verification) to prevent unauthorized use.
114+
*/
115+
function _fetchGuarantor(PackedUserOperation calldata userOp) internal view virtual returns (address guarantor);
116+
117+
/// @dev Over-estimates the cost of the post-operation logic. Added on top of guaranteed userOps post-operation cost.
118+
function _guaranteedPostOpCost() internal view virtual returns (uint256) {
119+
return 15_000;
120+
}
121+
}

0 commit comments

Comments
 (0)