Skip to content

Commit 6fe2715

Browse files
Amxxarr00ernestognw
authored
Add PaymasterNFT and PaymasterERC20 (#75)
Co-authored-by: Arr00 <[email protected]> Co-authored-by: Ernesto García <[email protected]>
1 parent 630ba44 commit 6fe2715

File tree

14 files changed

+915
-129
lines changed

14 files changed

+915
-129
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## XX-XX-XXXX
2+
3+
- `PaymasterNFT`: Extension of `PaymasterCore` that approves sponsoring of user operation based on ownership of an ERC-721 NFT.
4+
- `PaymasterERC20`: Extension of `PaymasterCore` that sponsors user operations against payment in ERC-20 tokens.
5+
16
## 28-03-2025
27

38
- Deprecate `Account` and rename `AccountCore` to `Account`.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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 {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
8+
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
9+
import {PaymasterCore} from "./PaymasterCore.sol";
10+
11+
/**
12+
* @dev Extension of {PaymasterCore} that enables users to pay gas with ERC-20 tokens.
13+
*
14+
* To enable this feature, developers must implement the {fetchDetails} function:
15+
*
16+
* ```solidity
17+
* function _fetchDetails(
18+
* PackedUserOperation calldata userOp,
19+
* 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
22+
* }
23+
* ```
24+
*/
25+
abstract contract PaymasterERC20 is PaymasterCore {
26+
using ERC4337Utils for *;
27+
using Math for *;
28+
using SafeERC20 for IERC20;
29+
30+
event UserOperationSponsored(
31+
bytes32 indexed userOpHash,
32+
address indexed user,
33+
address indexed guarantor,
34+
uint256 tokenAmount,
35+
uint256 tokenPrice,
36+
bool paidByGuarantor
37+
);
38+
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;
42+
43+
/// @inheritdoc PaymasterCore
44+
function _validatePaymasterUserOp(
45+
PackedUserOperation calldata userOp,
46+
bytes32 userOpHash,
47+
uint256 maxCost
48+
) internal virtual override returns (bytes memory context, uint256 validationData) {
49+
(uint256 validationData_, IERC20 token, uint256 tokenPrice, address guarantor) = _fetchDetails(
50+
userOp,
51+
userOpHash
52+
);
53+
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);
71+
}
72+
73+
/// @inheritdoc PaymasterCore
74+
function _postOp(
75+
PostOpMode /* mode */,
76+
bytes calldata context,
77+
uint256 actualGasCost,
78+
uint256 actualUserOpFeePerGas
79+
) internal virtual override {
80+
bytes32 userOpHash = bytes32(context[0x00:0x20]);
81+
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);
100+
}
101+
}
102+
103+
/**
104+
* @dev Retrieves payment details for a user operation
105+
*
106+
* The values returned by this internal function are:
107+
* * `validationData`: ERC-4337 validation data, indicating success/failure and optional time validity (`validAfter`, `validUntil`).
108+
* * `token`: Address of the ERC-20 token used for payment to the paymaster.
109+
* * `tokenPrice`: Price of the token in native currency, scaled by `_tokenPriceDenominator()`.
110+
* * `guarantor`: Address of an entity advancing funds if the user lacks them; receives tokens during execution or pays if the user can't.
111+
*
112+
* ==== Calculating the token price
113+
*
114+
* Given gas fees are paid in native currency, developers can use the `ERC20 price unit / native price unit` ratio to
115+
* calculate the price of an ERC20 token price in native currency. However, the token may have a different number of decimals
116+
* than the native currency. For a a generalized formula considering prices in USD and decimals, consider using:
117+
*
118+
* `(<ERC-20 token price in $> / 10**<ERC-20 decimals>) / (<Native token price in $> / 1e18) * _tokenPriceDenominator()`
119+
*
120+
* For example, suppose token is USDC ($1 with 6 decimals) and native currency is ETH (assuming $2524.86 with 18 decimals),
121+
* then each unit (1e-6) of USDC is worth `(1 / 1e6) / ((252486 / 1e2) / 1e18) = 396061563.8094785` wei. The `_tokenPriceDenominator()`
122+
* ensures precision by avoiding fractional value loss. (i.e. the 0.8094785 part).
123+
*
124+
* ==== Guarantor
125+
*
126+
* To support a guarantor, developers can use the `paymasterData` field to store the guarantor's address. Developers can disable
127+
* support for a guarantor by returning `address(0)`. If supported, ensure explicit consent (e.g., signature verification) to prevent
128+
* unauthorized use.
129+
*/
130+
function _fetchDetails(
131+
PackedUserOperation calldata userOp,
132+
bytes32 userOpHash
133+
) internal view virtual returns (uint256 validationData, IERC20 token, uint256 tokenPrice, address guarantor);
134+
135+
/// @dev Denominator used for interpreting the `tokenPrice` returned by {_fetchDetails} as "fixed point".
136+
function _tokenPriceDenominator() internal view virtual returns (uint256) {
137+
return 1e18;
138+
}
139+
140+
/// @dev Public function that allows the withdrawer to extract ERC-20 tokens resulting from gas payments.
141+
function withdrawTokens(IERC20 token, address recipient, uint256 amount) public virtual onlyWithdrawer {
142+
if (amount == type(uint256).max) amount = token.balanceOf(address(this));
143+
token.safeTransfer(recipient, amount);
144+
}
145+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
import {IERC721} from "@openzeppelin/contracts/interfaces/IERC721.sol";
6+
import {ERC4337Utils, PackedUserOperation} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
7+
import {PaymasterCore} from "./PaymasterCore.sol";
8+
9+
/**
10+
* @dev Extension of {PaymasterCore} that supports account based on ownership of an ERC-721 token.
11+
*
12+
* This paymaster will sponsor user operations if the user has at least 1 token of the token specified
13+
* during construction (or via {_setToken}).
14+
*/
15+
abstract contract PaymasterNFT is PaymasterCore {
16+
IERC721 private _token;
17+
18+
/// @dev Emitted when the paymaster token is set.
19+
event PaymasterNFTTokenSet(IERC721 token);
20+
21+
constructor(IERC721 token_) {
22+
_setToken(token_);
23+
}
24+
25+
/// @dev ERC-721 token used to validate the user operation.
26+
function token() public virtual returns (IERC721) {
27+
return _token;
28+
}
29+
30+
/// @dev Sets the ERC-721 token used to validate the user operation.
31+
function _setToken(IERC721 token_) internal virtual {
32+
_token = token_;
33+
emit PaymasterNFTTokenSet(token_);
34+
}
35+
36+
/**
37+
* @dev Internal validation of whether the paymaster is willing to pay for the user operation.
38+
* Returns the context to be passed to postOp and the validation data.
39+
*
40+
* NOTE: The default `context` is `bytes(0)`. Developers that add a context when overriding this function MUST
41+
* also override {_postOp} to process the context passed along.
42+
*/
43+
function _validatePaymasterUserOp(
44+
PackedUserOperation calldata userOp,
45+
bytes32 /* userOpHash */,
46+
uint256 /* maxCost */
47+
) internal virtual override returns (bytes memory context, uint256 validationData) {
48+
return (
49+
bytes(""),
50+
// balanceOf reverts if the `userOp.sender` is the address(0), so this becomes unreachable with address(0)
51+
// assuming a compliant entrypoint (`_validatePaymasterUserOp` is called after `validateUserOp`),
52+
token().balanceOf(userOp.sender) == 0
53+
? ERC4337Utils.SIG_VALIDATION_FAILED
54+
: ERC4337Utils.SIG_VALIDATION_SUCCESS
55+
);
56+
}
57+
}

contracts/mocks/ERC20Mock.sol

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6+
7+
abstract contract ERC20Mock is ERC20 {}

contracts/mocks/ERC721Mock.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33
pragma solidity ^0.8.20;
44

5-
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
5+
import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
66

7-
abstract contract ERC721Mock is ERC721 {}
7+
abstract contract ERC721Mock is ERC721Enumerable {}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
6+
import {ERC4337Utils, PackedUserOperation} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
7+
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
8+
import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
9+
import {PaymasterERC20, IERC20} from "../../../account/paymaster/PaymasterERC20.sol";
10+
11+
/**
12+
* NOTE: struct or the expected paymaster data is:
13+
* * [0x00:0x14 ] token (IERC20)
14+
* * [0x14:0x1a ] validAfter (uint48)
15+
* * [0x1a:0x20 ] validUntil (uint48)
16+
* * [0x20:0x40 ] tokenPrice (uint256)
17+
* * [0x40:0x54 ] oracle (address)
18+
* * [0x54:0x68 ] guarantor (address) (optional: 0 if no guarantor)
19+
* * [0x68:0x6a ] oracleSignatureLength (uint16)
20+
* * [0x6a:0x6a+oracleSignatureLength] oracleSignature (bytes)
21+
* * [0x6a+oracleSignatureLength: ] guarantorSignature (bytes)
22+
*/
23+
abstract contract PaymasterERC20Mock is EIP712, PaymasterERC20, AccessControl {
24+
using ERC4337Utils for *;
25+
26+
bytes32 private constant ORACLE_ROLE = keccak256("ORACLE_ROLE");
27+
bytes32 private constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE");
28+
bytes32 private constant TOKEN_PRICE_TYPEHASH =
29+
keccak256("TokenPrice(address token,uint48 validAfter,uint48 validUntil,uint256 tokenPrice)");
30+
bytes32 private constant PACKED_USER_OPERATION_TYPEHASH =
31+
keccak256(
32+
"PackedUserOperation(address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterAndData)"
33+
);
34+
35+
function _authorizeWithdraw() internal override onlyRole(WITHDRAWER_ROLE) {}
36+
37+
function _fetchDetails(
38+
PackedUserOperation calldata userOp,
39+
bytes32 /* userOpHash */
40+
)
41+
internal
42+
view
43+
virtual
44+
override
45+
returns (uint256 validationData, IERC20 token, uint256 tokenPrice, address guarantor)
46+
{
47+
uint256 validationData1;
48+
uint256 validationData2;
49+
(validationData1, token, tokenPrice) = _fetchOracleDetails(userOp);
50+
(validationData2, guarantor) = _fetchGuarantorDetails(userOp);
51+
validationData = ERC4337Utils.combineValidationData(validationData1, validationData2);
52+
}
53+
54+
function _fetchOracleDetails(
55+
PackedUserOperation calldata userOp
56+
) private view returns (uint256 validationData, IERC20 token, uint256 tokenPrice) {
57+
bytes calldata paymasterData = userOp.paymasterData();
58+
59+
// parse oracle and oracle signature
60+
address oracle = address(bytes20(paymasterData[0x40:0x54]));
61+
62+
// check oracle is registered
63+
if (!hasRole(ORACLE_ROLE, oracle)) return (ERC4337Utils.SIG_VALIDATION_FAILED, IERC20(address(0)), 0);
64+
65+
// parse repayment details
66+
token = IERC20(address(bytes20(paymasterData[0x00:0x14])));
67+
uint48 validAfter = uint48(bytes6(paymasterData[0x14:0x1a]));
68+
uint48 validUntil = uint48(bytes6(paymasterData[0x1a:0x20]));
69+
tokenPrice = uint256(bytes32(paymasterData[0x20:0x40]));
70+
71+
// verify signature
72+
validationData = SignatureChecker
73+
.isValidSignatureNow(
74+
oracle,
75+
_hashTypedDataV4(
76+
keccak256(abi.encode(TOKEN_PRICE_TYPEHASH, token, validAfter, validUntil, tokenPrice))
77+
),
78+
paymasterData[0x6a:0x6a + uint16(bytes2(paymasterData[0x68:0x6a]))]
79+
)
80+
.packValidationData(validAfter, validUntil);
81+
}
82+
83+
function _fetchGuarantorDetails(
84+
PackedUserOperation calldata userOp
85+
) private view returns (uint256 validationData, address guarantor) {
86+
bytes calldata paymasterData = userOp.paymasterData();
87+
88+
// parse guarantor details
89+
guarantor = address(bytes20(paymasterData[0x54:0x68]));
90+
91+
if (guarantor == address(0)) {
92+
validationData = ERC4337Utils.SIG_VALIDATION_SUCCESS;
93+
} else {
94+
// parse guarantor signature
95+
uint16 oracleSignatureLength = uint16(bytes2(paymasterData[0x68:0x6a]));
96+
bytes calldata guarantorSignature = paymasterData[0x6a + oracleSignatureLength:];
97+
98+
// check guarantor signature is valid
99+
validationData = SignatureChecker.isValidSignatureNow(
100+
guarantor,
101+
_hashTypedDataV4(_getStructHashWithoutOracleAndGuarantorSignature(userOp)),
102+
guarantorSignature
103+
)
104+
? ERC4337Utils.SIG_VALIDATION_SUCCESS
105+
: ERC4337Utils.SIG_VALIDATION_FAILED;
106+
}
107+
}
108+
109+
function _getStructHashWithoutOracleAndGuarantorSignature(
110+
PackedUserOperation calldata userOp
111+
) private pure returns (bytes32) {
112+
return
113+
keccak256(
114+
abi.encode(
115+
PACKED_USER_OPERATION_TYPEHASH,
116+
userOp.sender,
117+
userOp.nonce,
118+
keccak256(userOp.initCode),
119+
keccak256(userOp.callData),
120+
userOp.accountGasLimits,
121+
userOp.preVerificationGas,
122+
userOp.gasFees,
123+
keccak256(userOp.paymasterAndData[:0x9c]) // 0x34 (paymasterDataOffset) + 0x68 (token + validAfter + validUntil + tokenPrice + oracle + guarantor)
124+
)
125+
);
126+
}
127+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
6+
import {ERC4337Utils, PackedUserOperation} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
7+
import {PaymasterNFT} from "../../../account/paymaster/PaymasterNFT.sol";
8+
9+
abstract contract PaymasterNFTContextNoPostOpMock is PaymasterNFT, Ownable {
10+
using ERC4337Utils for *;
11+
12+
function _validatePaymasterUserOp(
13+
PackedUserOperation calldata userOp,
14+
bytes32 userOpHash,
15+
uint256 requiredPreFund
16+
) internal override returns (bytes memory context, uint256 validationData) {
17+
// use the userOp's callData as context;
18+
context = userOp.callData;
19+
// super call (PaymasterNFT) for the validation data
20+
(, validationData) = super._validatePaymasterUserOp(userOp, userOpHash, requiredPreFund);
21+
}
22+
23+
function _authorizeWithdraw() internal override onlyOwner {}
24+
}
25+
26+
abstract contract PaymasterNFTMock is PaymasterNFTContextNoPostOpMock {
27+
event PaymasterDataPostOp(bytes paymasterData);
28+
29+
function _postOp(
30+
PostOpMode mode,
31+
bytes calldata context,
32+
uint256 actualGasCost,
33+
uint256 actualUserOpFeePerGas
34+
) internal override {
35+
emit PaymasterDataPostOp(context);
36+
super._postOp(mode, context, actualGasCost, actualUserOpFeePerGas);
37+
}
38+
}

0 commit comments

Comments
 (0)