Skip to content
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

Add PaymasterNFT and PaymasterERC20 #75

Open
wants to merge 27 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## XX-XX-XXXX

- `PaymasterNFT`: Extension of `PaymasterCore` that approves sponsoring of user operation based on ownership of an ERC-721 NFT.
- `PaymasterERC20`: Extension of `PaymasterCore` that sponsors user operations against payment in ERC-20 tokens.

## 31-01-2025

- `PaymasterCore`: Add a simple ERC-4337 paymaster implementation with minimal logic.
Expand Down
1 change: 0 additions & 1 deletion contracts/account/paymaster/PaymasterCore.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ pragma solidity ^0.8.20;

import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
import {IEntryPoint, IPaymaster, PackedUserOperation} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol";
import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";

/**
* @dev A simple ERC4337 paymaster implementation. This base implementation only includes the minimal logic to validate
Expand Down
115 changes: 115 additions & 0 deletions contracts/account/paymaster/PaymasterERC20.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// 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";
import {AbstractSigner} from "../../utils/cryptography/AbstractSigner.sol";

/**
* @dev Extension of {PaymasterCore} that enables users to pay gas with ERC-20 tokens.
*/
abstract contract PaymasterERC20 is PaymasterCore {
using ERC4337Utils for *;
using Math for *;
using SafeERC20 for IERC20;

event UserOperationSponsored(
bytes32 indexed userOpHash,
address indexed user,
address indexed guarantor,
uint256 tokenAmount,
uint256 tokenPrice,
bool paidByGuarantor
);

// Over-estimations: ERC-20 balances/allowances may be cold and contracts may not be optimized
uint256 private constant POST_OP_COST = 30_000;
uint256 private constant POST_OP_COST_WITH_GUARANTOR = 50_000;

/// @inheritdoc PaymasterCore
function _validatePaymasterUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 maxCost
) internal virtual override returns (bytes memory context, uint256 validationData) {
(IERC20 token, uint48 validAfter, uint48 validUntil, uint256 tokenPrice, address guarantor) = _fetchDetails(
userOp,
userOpHash
);

uint256 prefundAmount = (maxCost +
(guarantor == address(0)).ternary(POST_OP_COST, POST_OP_COST_WITH_GUARANTOR) *
userOp.maxFeePerGas()).mulDiv(tokenPrice, _tokenPriceDenominator());

return (
abi.encodePacked(userOpHash, token, prefundAmount, tokenPrice, userOp.sender, guarantor),
token
.trySafeTransferFrom(guarantor == address(0) ? userOp.sender : guarantor, address(this), prefundAmount)
.packValidationData(validAfter, validUntil)
);
}

/// @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]));
address user = address(bytes20(context[0x74:0x88]));
address guarantor = address(bytes20(context[0x88:0x9C]));

uint256 actualAmount = (actualGasCost +
(guarantor == address(0)).ternary(POST_OP_COST, POST_OP_COST_WITH_GUARANTOR) *
actualUserOpFeePerGas).mulDiv(tokenPrice, _tokenPriceDenominator());

if (guarantor == address(0)) {
token.safeTransfer(user, prefundAmount - actualAmount);
emit UserOperationSponsored(userOpHash, user, address(0), actualAmount, tokenPrice, false);
} else if (token.trySafeTransferFrom(user, address(this), actualAmount)) {
token.safeTransfer(guarantor, prefundAmount);
emit UserOperationSponsored(userOpHash, user, guarantor, actualAmount, tokenPrice, false);
} else {
token.safeTransfer(guarantor, prefundAmount - actualAmount);
emit UserOperationSponsored(userOpHash, user, guarantor, actualAmount, tokenPrice, true);
}
}

/**
* @dev Internal function that returns the repayment details for a given user operation
*
* This may be implemented in any number of ways, including
* * Hardcoding values (only one token supported)
* * Getting the price from an onchain oracle
* * Getting the (signed) values through the userOp's paymasterData
*
* The paymaster can also decide to not support guarantors, and always return address(0) for that part.
*/
function _fetchDetails(
PackedUserOperation calldata userOp,
bytes32 userOpHash
)
internal
view
virtual
returns (IERC20 token, uint48 validAfter, uint48 validUntil, uint256 tokenPrice, address guarantor);

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

/// @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);
}
}
49 changes: 49 additions & 0 deletions contracts/account/paymaster/PaymasterNFT.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {IERC721} from "@openzeppelin/contracts/interfaces/IERC721.sol";
import {ERC4337Utils, PackedUserOperation} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
import {PaymasterCore} from "./PaymasterCore.sol";

/**
* @dev Extension of {PaymasterCore} that supports account based on ownership of an ERC-721 token
*/
abstract contract PaymasterNFT is PaymasterCore {
IERC721 private _token;

event PaymasterNFTTokenSet(IERC721 token);

constructor(IERC721 token_) {
_setToken(token_);
}

function token() public virtual returns (IERC721) {
return _token;
}

function _setToken(IERC721 token_) internal virtual {
_token = token_;
emit PaymasterNFTTokenSet(token_);
}

/**
* @dev Internal validation of whether the paymaster is willing to pay for the user operation.
* Returns the context to be passed to postOp and the validation data.
*
* NOTE: The `context` returned is `bytes(0)`. Developers overriding this function MUST
* override {_postOp} to process the context passed along.
Comment on lines +34 to +35
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
* NOTE: The `context` returned is `bytes(0)`. Developers overriding this function MUST
* override {_postOp} to process the context passed along.
* NOTE: The default `context` returned is `bytes(0)`. Developers overriding this function, such that context is not `bytes(0)`, MUST
* override {_postOp} to process the context passed along.

*/
function _validatePaymasterUserOp(
PackedUserOperation calldata userOp,
bytes32 /* userOpHash */,
uint256 /* maxCost */
) internal virtual override returns (bytes memory context, uint256 validationData) {
return (
bytes(""),
token().balanceOf(userOp.sender) == 0
? ERC4337Utils.SIG_VALIDATION_FAILED
: ERC4337Utils.SIG_VALIDATION_SUCCESS
);
}
}
8 changes: 3 additions & 5 deletions contracts/account/paymaster/PaymasterSigner.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@

pragma solidity ^0.8.20;

import {PackedUserOperation} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol";
import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
import {ERC4337Utils, PackedUserOperation} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {Calldata} from "@openzeppelin/contracts/utils/Calldata.sol";
import {PaymasterCore} from "./PaymasterCore.sol";
import {AbstractSigner} from "../../utils/cryptography/AbstractSigner.sol";

Expand All @@ -26,7 +24,7 @@ import {AbstractSigner} from "../../utils/cryptography/AbstractSigner.sol";
abstract contract PaymasterSigner is AbstractSigner, EIP712, PaymasterCore {
using ERC4337Utils for *;

bytes32 internal constant _USER_OPERATION_REQUEST =
bytes32 private constant USER_OPERATION_REQUEST_TYPEHASH =
keccak256(
"UserOperationRequest(address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,uint256 paymasterVerificationGasLimit,uint256 paymasterPostOpGasLimit,uint48 validAfter,uint48 validUntil)"
);
Expand All @@ -45,7 +43,7 @@ abstract contract PaymasterSigner is AbstractSigner, EIP712, PaymasterCore {
_hashTypedDataV4(
keccak256(
abi.encode(
_USER_OPERATION_REQUEST,
USER_OPERATION_REQUEST_TYPEHASH,
userOp.sender,
userOp.nonce,
keccak256(userOp.initCode),
Expand Down
7 changes: 7 additions & 0 deletions contracts/mocks/ERC20Mock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

abstract contract ERC20Mock is ERC20 {}
4 changes: 2 additions & 2 deletions contracts/mocks/ERC721Mock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

pragma solidity ^0.8.20;

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

abstract contract ERC721Mock is ERC721 {}
abstract contract ERC721Mock is ERC721Enumerable {}
32 changes: 32 additions & 0 deletions contracts/mocks/account/paymaster/PaymasterERC20Mock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ERC4337Utils, PackedUserOperation} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
import {PaymasterERC20, IERC20} from "../../../account/paymaster/PaymasterERC20.sol";

abstract contract PaymasterERC20Mock is PaymasterERC20, Ownable {
/// Note: this is for testing purpose only. Rate should be fetched from a trusted source (or signed).
function _fetchDetails(
PackedUserOperation calldata userOp,
bytes32 /* userOpHash */
)
internal
view
virtual
override
returns (IERC20 token, uint48 validAfter, uint48 validUntil, uint256 tokenPrice, address guarantor)
{
bytes calldata paymasterData = ERC4337Utils.paymasterData(userOp);
return (
IERC20(address(bytes20(paymasterData[0x00:0x14]))),
uint48(bytes6(paymasterData[0x14:0x1a])),
uint48(bytes6(paymasterData[0x1a:0x20])),
uint256(bytes32(paymasterData[0x20:0x40])),
address(bytes20(paymasterData[0x40:0x54]))
);
}

function _authorizeWithdraw() internal override onlyOwner {}
}
122 changes: 122 additions & 0 deletions contracts/mocks/account/paymaster/PaymasterERC20SignerMock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ERC4337Utils, PackedUserOperation} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
import {PaymasterERC20, IERC20} from "../../../account/paymaster/PaymasterERC20.sol";

/**
* NOTE: struct or the expected paymaster data is:
* * [0x00:0x14 ] token (IERC20)
* * [0x14:0x1a ] validAfter (uint48)
* * [0x1a:0x20 ] validUntil (uint48)
* * [0x20:0x40 ] tokenPrice (uint256)
* * [0x40:0x54 ] oracle (address)
* * [0x54:0x68 ] guarantor (address) (optional: 0 if no guarantor)
* * [0x68:0x6a ] oracleSignatureLength (uint16)
* * [0x6a:0x6a+oracleSignatureLength] oracleSignature (bytes)
* * [0x6a+oracleSignatureLength: ] guarantorSignature (bytes)
*/
abstract contract PaymasterERC20SignerMock is EIP712, PaymasterERC20, AccessControl {
using ERC4337Utils for PackedUserOperation;

bytes32 private constant ORACLE_ROLE = keccak256("ORACLE_ROLE");
bytes32 private constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE");
bytes32 private constant TOKEN_PRICE_TYPEHASH =
keccak256("TokenPrice(address token,uint48 validAfter,uint48 validUntil,uint256 tokenPrice)");
bytes32 private constant PACKED_USER_OPERATION_TYPEHASH =
keccak256(
"PackedUserOperation(address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterAndData)"
);

function _authorizeWithdraw() internal override onlyRole(WITHDRAWER_ROLE) {}

function _fetchDetails(
PackedUserOperation calldata userOp,
bytes32 /* userOpHash */
)
internal
view
virtual
override
returns (IERC20 token, uint48 validAfter, uint48 validUntil, uint256 tokenPrice, address guarantor)
{
(token, validAfter, validUntil, tokenPrice) = _fetchOracleDetails(userOp);
guarantor = _fetchGuarantorDetails(userOp);
}

function _fetchOracleDetails(
PackedUserOperation calldata userOp
) private view returns (IERC20 token, uint48 validAfter, uint48 validUntil, uint256 tokenPrice) {
bytes calldata paymasterData = userOp.paymasterData();

// parse repayment details
token = IERC20(address(bytes20(paymasterData[0x00:0x14])));
validAfter = uint48(bytes6(paymasterData[0x14:0x1a]));
validUntil = uint48(bytes6(paymasterData[0x1a:0x20]));
tokenPrice = uint256(bytes32(paymasterData[0x20:0x40]));

// parse oracle and oracle signature
address oracle = address(bytes20(paymasterData[0x40:0x54]));
uint16 oracleSignatureLength = uint16(bytes2(paymasterData[0x68:0x6a]));
bytes calldata oracleSignature = paymasterData[0x6c:0x6c + oracleSignatureLength];

// check oracle is registered
_checkRole(ORACLE_ROLE, oracle);

// check oracle signature is valid
require(
SignatureChecker.isValidSignatureNow(
oracle,
_hashTypedDataV4(
keccak256(abi.encode(TOKEN_PRICE_TYPEHASH, token, validAfter, validUntil, tokenPrice))
),
oracleSignature
)
);
}

function _fetchGuarantorDetails(PackedUserOperation calldata userOp) private view returns (address guarantor) {
bytes calldata paymasterData = userOp.paymasterData();

// parse guarantor details
guarantor = address(bytes20(paymasterData[0x54:0x68]));

if (guarantor != address(0)) {
// parse guarantor signature
uint16 oracleSignatureLength = uint16(bytes2(paymasterData[0x68:0x6a]));
bytes calldata guarantorSignature = paymasterData[0x6c + oracleSignatureLength:];

// check guarantor signature is valid
require(
SignatureChecker.isValidSignatureNow(
guarantor,
_hashTypedDataV4(_getStructHashWithoutOracleAndGuarantorSignature(userOp)),
guarantorSignature
)
);
}
}

function _getStructHashWithoutOracleAndGuarantorSignature(
PackedUserOperation calldata userOp
) private pure returns (bytes32) {
return
keccak256(
abi.encode(
PACKED_USER_OPERATION_TYPEHASH,
userOp.sender,
userOp.nonce,
keccak256(userOp.initCode),
keccak256(userOp.callData),
userOp.accountGasLimits,
userOp.preVerificationGas,
userOp.gasFees,
keccak256(userOp.paymasterAndData[:0x9c]) // 0x34 (paymasterDataOffset) + 0x68 (token + validAfter + validUntil + tokenPrice + oracle + guarantor)
)
);
}
}
Loading