diff --git a/.changeset/dark-papers-call.md b/.changeset/dark-papers-call.md new file mode 100644 index 00000000000..a6b144355fd --- /dev/null +++ b/.changeset/dark-papers-call.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`PaymasterERC721Owner`: Extension of `Paymaster` that approves sponsoring of user operation based on ownership of an ERC-721 NFT. diff --git a/.changeset/polite-geckos-peel.md b/.changeset/polite-geckos-peel.md new file mode 100644 index 00000000000..51c26c1dd9e --- /dev/null +++ b/.changeset/polite-geckos-peel.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`PaymasterERC20`: Extension of `Paymaster` that sponsors user operations against payment in ERC-20 tokens. diff --git a/.changeset/shy-poets-look.md b/.changeset/shy-poets-look.md new file mode 100644 index 00000000000..44c2742ab8f --- /dev/null +++ b/.changeset/shy-poets-look.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`PaymasterSigner`: Extension of `Paymaster` that approves sponsoring of user operation based on a cryptographic signature verified by the paymaster. diff --git a/.changeset/whole-items-rule.md b/.changeset/whole-items-rule.md new file mode 100644 index 00000000000..6e741f35385 --- /dev/null +++ b/.changeset/whole-items-rule.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`Paymaster`: Add a simple ERC-4337 paymaster implementation with minimal logic. diff --git a/contracts/account/README.adoc b/contracts/account/README.adoc index dc3c9a010a7..5eb00e3b2e9 100644 --- a/contracts/account/README.adoc +++ b/contracts/account/README.adoc @@ -10,6 +10,11 @@ This directory includes contracts to build accounts for ERC-4337. These include: * {ERC7821}: Minimal batch executor implementation contracts. Useful to enable easy batch execution for smart contracts. * {ERC4337Utils}: Utility functions for working with ERC-4337 user operations. * {ERC7579Utils}: Utility functions for working with ERC-7579 modules and account modularity. + * {Paymaster}: An ERC-4337 paymaster implementation that includes the core logic to validate and pay for user operations. + * {PaymasterERC20}: A paymaster that allows users to pay for user operations using ERC-20 tokens. + * {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. + * {PaymasterERC721Owner}: A paymaster that sponsors user operations for ERC-721 token holders, covering gas costs based on NFT ownership. + * {PaymasterSigner}: A paymaster that allows users to pay for user operations using an authorized signature. == Core @@ -23,6 +28,18 @@ This directory includes contracts to build accounts for ERC-4337. These include: {{ERC7821}} +== Paymasters + +{{Paymaster}} + +{{PaymasterERC20}} + +{{PaymasterERC20Guarantor}} + +{{PaymasterERC721Owner}} + +{{PaymasterSigner}} + == Utilities {{ERC4337Utils}} diff --git a/contracts/account/paymaster/Paymaster.sol b/contracts/account/paymaster/Paymaster.sol new file mode 100644 index 00000000000..5371950e7bd --- /dev/null +++ b/contracts/account/paymaster/Paymaster.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC4337Utils} from "../utils/draft-ERC4337Utils.sol"; +import {IEntryPoint, IPaymaster, PackedUserOperation} from "../../interfaces/draft-IERC4337.sol"; + +/** + * @dev A simple ERC4337 paymaster implementation. This base implementation only includes the minimal logic to validate + * and pay for user operations. + * + * Developers must implement the {Paymaster-_validatePaymasterUserOp} function to define the paymaster's validation + * and payment logic. The `context` parameter is used to pass data between the validation and execution phases. + * + * The paymaster includes support to call the {IEntryPointStake} interface to manage the paymaster's deposits and stakes + * through the internal functions {deposit}, {withdraw}, {addStake}, {unlockStake} and {withdrawStake}. + * + * * Deposits are used to pay for user operations. + * * Stakes are used to guarantee the paymaster's reputation and obtain more flexibility in accessing storage. + * + * NOTE: See [Paymaster's unstaked reputation rules](https://eips.ethereum.org/EIPS/eip-7562#unstaked-paymasters-reputation-rules) + * for more details on the paymaster's storage access limitations. + */ +abstract contract Paymaster is IPaymaster { + /// @dev Unauthorized call to the paymaster. + error PaymasterUnauthorized(address sender); + + /// @dev Revert if the caller is not the entry point. + modifier onlyEntryPoint() { + _checkEntryPoint(); + _; + } + + modifier onlyWithdrawer() { + _authorizeWithdraw(); + _; + } + + /// @dev Canonical entry point for the account that forwards and validates user operations. + function entryPoint() public view virtual returns (IEntryPoint) { + return ERC4337Utils.ENTRYPOINT_V09; + } + + /// @inheritdoc IPaymaster + function validatePaymasterUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 maxCost + ) public virtual onlyEntryPoint returns (bytes memory context, uint256 validationData) { + return _validatePaymasterUserOp(userOp, userOpHash, maxCost); + } + + /// @inheritdoc IPaymaster + function postOp( + PostOpMode mode, + bytes calldata context, + uint256 actualGasCost, + uint256 actualUserOpFeePerGas + ) public virtual onlyEntryPoint { + _postOp(mode, context, actualGasCost, actualUserOpFeePerGas); + } + + /** + * @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. + * + * The `requiredPreFund` is the amount the paymaster has to pay (in native tokens). It's calculated + * as `requiredGas * userOp.maxFeePerGas`, where `required` gas can be calculated from the user operation + * as `verificationGasLimit + callGasLimit + paymasterVerificationGasLimit + paymasterPostOpGasLimit + preVerificationGas` + */ + function _validatePaymasterUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 requiredPreFund + ) internal virtual returns (bytes memory context, uint256 validationData); + + /** + * @dev Handles post user operation execution logic. The caller must be the entry point. + * + * It receives the `context` returned by `_validatePaymasterUserOp`. Function is not called if no context + * is returned by {validatePaymasterUserOp}. + * + * NOTE: The `actualUserOpFeePerGas` is not `tx.gasprice`. A user operation can be bundled with other transactions + * making the gas price of the user operation to differ. + */ + function _postOp( + PostOpMode /* mode */, + bytes calldata /* context */, + uint256 /* actualGasCost */, + uint256 /* actualUserOpFeePerGas */ + ) internal virtual {} + + /// @dev Calls {IEntryPointStake-depositTo}. + function deposit() public payable virtual { + entryPoint().depositTo{value: msg.value}(address(this)); + } + + /// @dev Calls {IEntryPointStake-withdrawTo}. + function withdraw(address payable to, uint256 value) public virtual onlyWithdrawer { + entryPoint().withdrawTo(to, value); + } + + /// @dev Calls {IEntryPointStake-addStake}. + function addStake(uint32 unstakeDelaySec) public payable virtual { + entryPoint().addStake{value: msg.value}(unstakeDelaySec); + } + + /// @dev Calls {IEntryPointStake-unlockStake}. + function unlockStake() public virtual onlyWithdrawer { + entryPoint().unlockStake(); + } + + /// @dev Calls {IEntryPointStake-withdrawStake}. + function withdrawStake(address payable to) public virtual onlyWithdrawer { + entryPoint().withdrawStake(to); + } + + /// @dev Ensures the caller is the {entrypoint}. + function _checkEntryPoint() internal view virtual { + address sender = msg.sender; + if (sender != address(entryPoint())) { + revert PaymasterUnauthorized(sender); + } + } + + /** + * @dev Checks whether `msg.sender` withdraw funds stake or deposit from the entrypoint on paymaster's behalf. + * + * Use of an xref:api:access.adoc[access control] modifier such as {Ownable-onlyOwner} is recommended. + * + * ```solidity + * function _authorizeWithdraw() internal onlyOwner {} + * ``` + */ + function _authorizeWithdraw() internal virtual; +} diff --git a/contracts/account/paymaster/PaymasterERC20.sol b/contracts/account/paymaster/PaymasterERC20.sol new file mode 100644 index 00000000000..fa0da097628 --- /dev/null +++ b/contracts/account/paymaster/PaymasterERC20.sol @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC4337Utils, PackedUserOperation} from "../utils/draft-ERC4337Utils.sol"; +import {IERC20, SafeERC20} from "../../token/ERC20/utils/SafeERC20.sol"; +import {Math} from "../../utils/math/Math.sol"; +import {Paymaster} from "./Paymaster.sol"; + +/** + * @dev Extension of {Paymaster} 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 + * } + * ``` + * + * The contract follows a pre-charge and refund model: + * 1. During validation, it pre-charges the maximum possible gas cost + * 2. After execution, it refunds any unused gas back to the user + */ +abstract contract PaymasterERC20 is Paymaster { + using ERC4337Utils for *; + using Math for *; + using SafeERC20 for IERC20; + + /** + * @dev Emitted when a user operation identified by `userOpHash` is sponsored by this paymaster + * using the specified ERC-20 `token`. The `tokenAmount` is the amount charged for the operation, + * and `tokenPrice` is the price of the token in native currency (e.g., ETH). + */ + event UserOperationSponsored( + bytes32 indexed userOpHash, + address indexed token, + uint256 tokenAmount, + uint256 tokenPrice + ); + + /** + * @dev Throws when the paymaster fails to refund the difference between the `prefundAmount` + * and the `actualAmount` of `token`. + */ + error PaymasterERC20FailedRefund(IERC20 token, uint256 prefundAmount, uint256 actualAmount, bytes prefundContext); + + /** + * @dev See {Paymaster-_validatePaymasterUserOp}. + * + * Attempts to retrieve the `token` and `tokenPrice` from the user operation (see {_fetchDetails}) + * and prefund the user operation using these values and the `maxCost` argument (see {_prefund}). + * + * Returns `abi.encodePacked(userOpHash, token, tokenPrice, prefundAmount, prefunder, prefundContext)` in + * `context` if the prefund is successful. Otherwise, it returns empty bytes. + */ + function _validatePaymasterUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 maxCost + ) internal virtual override returns (bytes memory context, uint256 validationData) { + IERC20 token; + uint256 tokenPrice; + (validationData, token, tokenPrice) = _fetchDetails(userOp, userOpHash); + context = abi.encodePacked(userOpHash, token, tokenPrice); + (bool prefunded, uint256 prefundAmount, address prefunder, bytes memory prefundContext) = _prefund( + userOp, + userOpHash, + token, + tokenPrice, + userOp.sender, + maxCost + ); + + if (validationData == ERC4337Utils.SIG_VALIDATION_FAILED || !prefunded) + return (bytes(""), ERC4337Utils.SIG_VALIDATION_FAILED); + + return (abi.encodePacked(context, prefundAmount, prefunder, prefundContext), validationData); + } + + /** + * @dev Prefunds the `userOp` by charging the maximum possible gas cost (`maxCost`) in ERC-20 `token`. + * + * The `token` and `tokenPrice` is obtained from the {_fetchDetails} function and are funded by the `prefunder_`, + * which is the user operation sender by default. The `prefundAmount` is calculated using {_erc20Cost}. + * + * Returns a `prefundContext` that's passed to the {_postOp} function through its `context` return value. + * + * NOTE: Consider not reverting if the prefund fails when overriding this function. This is to avoid reverting + * during the validation phase of the user operation, which may penalize the paymaster's reputation according + * to ERC-7562 validation rules. + */ + function _prefund( + PackedUserOperation calldata userOp, + bytes32 /* userOpHash */, + IERC20 token, + uint256 tokenPrice, + address prefunder_, + uint256 maxCost + ) internal virtual returns (bool prefunded, uint256 prefundAmount, address prefunder, bytes memory prefundContext) { + uint256 feePerGas = userOp.maxFeePerGas(); + uint256 _prefundAmount = _erc20Cost(maxCost, feePerGas, tokenPrice); + return (token.trySafeTransferFrom(prefunder_, address(this), _prefundAmount), _prefundAmount, prefunder_, ""); + } + + /** + * @dev Attempts to refund the user operation after execution. See {_refund}. + * + * Reverts with {PaymasterERC20FailedRefund} if the refund fails. + * + * IMPORTANT: This function may revert after the user operation has been executed without + * reverting the user operation itself. Consider implementing a mechanism to handle + * this case gracefully. + */ + 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 tokenPrice = uint256(bytes32(context[0x34:0x54])); + uint256 prefundAmount = uint256(bytes32(context[0x54:0x74])); + address prefunder = address(bytes20(context[0x74:0x88])); + bytes calldata prefundContext = context[0x88:]; + + (bool refunded, uint256 actualAmount) = _refund( + token, + tokenPrice, + actualGasCost, + actualUserOpFeePerGas, + prefunder, + prefundAmount, + prefundContext + ); + if (!refunded) { + revert PaymasterERC20FailedRefund(token, prefundAmount, actualAmount, prefundContext); + } + + emit UserOperationSponsored(userOpHash, address(token), actualAmount, tokenPrice); + } + + /** + * @dev Refunds any unused gas back to the user (i.e. `prefundAmount - actualAmount`) in `token`. + * + * The `actualAmount` is calculated using {_erc20Cost} and the `actualGasCost`, `actualUserOpFeePerGas`, `prefundContext` + * and the `tokenPrice` from the {_postOp}'s context. + */ + 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.trySafeTransfer(prefunder, prefundAmount - actualAmount_), 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: + * + * `( / 10**) / ( / 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" in {_erc20Cost}. + 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); + } +} diff --git a/contracts/account/paymaster/PaymasterERC20Guarantor.sol b/contracts/account/paymaster/PaymasterERC20Guarantor.sol new file mode 100644 index 00000000000..ae2ad59648b --- /dev/null +++ b/contracts/account/paymaster/PaymasterERC20Guarantor.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC4337Utils, PackedUserOperation} from "../utils/draft-ERC4337Utils.sol"; +import {IERC20, SafeERC20} from "../../token/ERC20/utils/SafeERC20.sol"; +import {PaymasterERC20} from "./PaymasterERC20.sol"; + +/** + * @dev Extension of {PaymasterERC20} that enables third parties to guarantee user operations. + * + * This contract allows a guarantor to pre-fund user operations on behalf of users. The guarantor + * pays the maximum possible gas cost upfront, and after execution: + * 1. If the user repays the guarantor, the guarantor gets their funds back + * 2. If the user fails to repay, the guarantor absorbs the cost + * + * A common use case is for guarantors to pay for the operations of users claiming airdrops. In this scenario: + * + * * The guarantor pays the gas fees upfront + * * The user claims their airdrop tokens + * * The user repays the guarantor from the claimed tokens + * * If the user fails to repay, the guarantor absorbs the cost + * + * The guarantor is identified through the {_fetchGuarantor} function, which must be implemented + * by developers to determine who can guarantee operations. This allows for flexible guarantor selection + * logic based on the specific requirements of the application. + */ +abstract contract PaymasterERC20Guarantor is PaymasterERC20 { + using SafeERC20 for IERC20; + + /// @dev Emitted when a user operation identified by `userOpHash` is guaranteed by a `guarantor` for `prefundAmount`. + event UserOperationGuaranteed(bytes32 indexed userOpHash, address indexed guarantor, uint256 prefundAmount); + + /** + * @dev Prefunds the user operation using either the guarantor or the default prefunder. + * See {PaymasterERC20-_prefund}. + * + * Returns `abi.encodePacked(..., userOp.sender)` in `prefundContext` to allow + * the refund process to identify the user operation sender. + */ + function _prefund( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + IERC20 token, + uint256 tokenPrice, + address prefunder_, + uint256 maxCost + ) + internal + virtual + override + returns (bool prefunded, uint256 prefundAmount, address prefunder, bytes memory prefundContext) + { + address guarantor = _fetchGuarantor(userOp); + bool isGuaranteed = guarantor != address(0); + (prefunded, prefundAmount, prefunder, prefundContext) = super._prefund( + userOp, + userOpHash, + token, + tokenPrice, + isGuaranteed ? guarantor : prefunder_, + maxCost + (isGuaranteed ? 0 : _guaranteedPostOpCost()) + ); + if (prefunder == guarantor) { + emit UserOperationGuaranteed(userOpHash, prefunder, prefundAmount); + } + return (prefunded, prefundAmount, prefunder, abi.encodePacked(prefundContext, userOp.sender)); + } + + /** + * @dev Handles the refund process for guaranteed operations. + * + * If the operation was guaranteed, it attempts to get repayment from the user first and then refunds the guarantor. + * Otherwise, fallback to {PaymasterERC20-_refund}. + * + * NOTE: For guaranteed user operations where the user paid the `actualGasCost` back, this function + * doesn't call `super._refund`. Consider whether there are side effects in the parent contract that need to be executed. + */ + function _refund( + IERC20 token, + uint256 tokenPrice, + uint256 actualGasCost, + uint256 actualUserOpFeePerGas, + address prefunder, + uint256 prefundAmount, + bytes calldata prefundContext + ) internal virtual override returns (bool refunded, uint256 actualAmount) { + address userOpSender = address(bytes20(prefundContext[0x00:0x14])); + + if (prefunder != userOpSender) { + actualAmount = _erc20Cost(actualGasCost, actualUserOpFeePerGas, tokenPrice); + if (token.trySafeTransferFrom(userOpSender, address(this), actualAmount)) { + // The paymaster gets the funds first, so in case of a failure, the guarantor absorbs the cost. + return (token.trySafeTransfer(prefunder, prefundAmount), actualAmount); + } + } + return + super._refund( + token, + tokenPrice, + actualGasCost, + actualUserOpFeePerGas, + prefunder, + prefundAmount, + prefundContext + ); + } + + /** + * @dev Fetches the guarantor address and validation data from the user operation. + * + * NOTE: Return `address(0)` to disable the guarantor feature. If supported, ensure + * explicit consent (e.g., signature verification) to prevent unauthorized use. + */ + function _fetchGuarantor(PackedUserOperation calldata userOp) internal view virtual returns (address guarantor); + + /// @dev Over-estimates the cost of the post-operation logic. Added on top of guaranteed userOps post-operation cost. + function _guaranteedPostOpCost() internal view virtual returns (uint256) { + return 15_000; + } +} diff --git a/contracts/account/paymaster/PaymasterERC721Owner.sol b/contracts/account/paymaster/PaymasterERC721Owner.sol new file mode 100644 index 00000000000..adc8f7d46d8 --- /dev/null +++ b/contracts/account/paymaster/PaymasterERC721Owner.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {IERC721} from "../../interfaces/IERC721.sol"; +import {ERC4337Utils, PackedUserOperation} from "../utils/draft-ERC4337Utils.sol"; +import {Paymaster} from "./Paymaster.sol"; + +/** + * @dev Extension of {Paymaster} that supports account based on ownership of an ERC-721 token. + * + * This paymaster will sponsor user operations if the user has at least 1 token of the token specified + * during construction (or via {_setToken}). + */ +abstract contract PaymasterERC721Owner is Paymaster { + IERC721 private _token; + + /// @dev Emitted when the paymaster token is set. + event PaymasterERC721OwnerTokenSet(IERC721 token); + + constructor(IERC721 token_) { + _setToken(token_); + } + + /// @dev ERC-721 token used to validate the user operation. + function token() public virtual returns (IERC721) { + return _token; + } + + /// @dev Sets the ERC-721 token used to validate the user operation. + function _setToken(IERC721 token_) internal virtual { + _token = token_; + emit PaymasterERC721OwnerTokenSet(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 default `context` is `bytes(0)`. Developers that add a context when overriding this function MUST + * also 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(""), + // balanceOf reverts if the `userOp.sender` is the address(0), so this becomes unreachable with address(0) + // assuming a compliant entrypoint (`_validatePaymasterUserOp` is called after `validateUserOp`), + token().balanceOf(userOp.sender) == 0 + ? ERC4337Utils.SIG_VALIDATION_FAILED + : ERC4337Utils.SIG_VALIDATION_SUCCESS + ); + } +} diff --git a/contracts/account/paymaster/PaymasterSigner.sol b/contracts/account/paymaster/PaymasterSigner.sol new file mode 100644 index 00000000000..e2c4f3b1f9e --- /dev/null +++ b/contracts/account/paymaster/PaymasterSigner.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {ERC4337Utils, PackedUserOperation} from "../utils/draft-ERC4337Utils.sol"; +import {AbstractSigner} from "../../utils/cryptography/signers/AbstractSigner.sol"; +import {EIP712} from "../../utils/cryptography/EIP712.sol"; +import {Paymaster} from "./Paymaster.sol"; + +/** + * @dev Extension of {Paymaster} that adds signature validation. See {SignerECDSA}, {SignerP256} or {SignerRSA}. + * + * Example of usage: + * + * ```solidity + * contract MyPaymasterECDSASigner is PaymasterSigner, SignerECDSA { + * constructor(address signerAddr) EIP712("MyPaymasterECDSASigner", "1") SignerECDSA(signerAddr) {} + * } + * ``` + */ +abstract contract PaymasterSigner is AbstractSigner, EIP712, Paymaster { + using ERC4337Utils for *; + + 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)" + ); + + /** + * @dev Virtual function that returns the signable hash for a user operations. Given the `userOpHash` + * contains the `paymasterAndData` itself, it's not possible to sign that value directly. Instead, + * this function must be used to provide a custom mechanism to authorize an user operation. + */ + function _signableUserOpHash( + PackedUserOperation calldata userOp, + uint48 validAfter, + uint48 validUntil + ) internal view virtual returns (bytes32) { + return + _hashTypedDataV4( + keccak256( + abi.encode( + USER_OPERATION_REQUEST_TYPEHASH, + userOp.sender, + userOp.nonce, + keccak256(userOp.initCode), + keccak256(userOp.callData), + userOp.accountGasLimits, + userOp.preVerificationGas, + userOp.gasFees, + userOp.paymasterVerificationGasLimit(), + userOp.paymasterPostOpGasLimit(), + validAfter, + validUntil + ) + ) + ); + } + + /** + * @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. + */ + function _validatePaymasterUserOp( + PackedUserOperation calldata userOp, + bytes32 /* userOpHash */, + uint256 /* maxCost */ + ) internal virtual override returns (bytes memory context, uint256 validationData) { + (uint48 validAfter, uint48 validUntil, bytes calldata signature) = _decodePaymasterUserOp(userOp); + return ( + bytes(""), + _rawSignatureValidation(_signableUserOpHash(userOp, validAfter, validUntil), signature).packValidationData( + validAfter, + validUntil + ) + ); + } + + /// @dev Decodes the user operation's data from `paymasterAndData`. + function _decodePaymasterUserOp( + PackedUserOperation calldata userOp + ) internal pure virtual returns (uint48 validAfter, uint48 validUntil, bytes calldata signature) { + bytes calldata paymasterData = userOp.paymasterData(); + return (uint48(bytes6(paymasterData[0:6])), uint48(bytes6(paymasterData[6:12])), paymasterData[12:]); + } +} diff --git a/contracts/mocks/account/paymaster/PaymasterERC20Mock.sol b/contracts/mocks/account/paymaster/PaymasterERC20Mock.sol new file mode 100644 index 00000000000..4c10b0a4e96 --- /dev/null +++ b/contracts/mocks/account/paymaster/PaymasterERC20Mock.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {AccessControl} from "../../../access/AccessControl.sol"; +import {ERC4337Utils, PackedUserOperation} from "../../../account/utils/draft-ERC4337Utils.sol"; +import {EIP712} from "../../../utils/cryptography/EIP712.sol"; +import {SignatureChecker} from "../../../utils/cryptography/SignatureChecker.sol"; +import {PaymasterERC20, IERC20} from "../../../account/paymaster/PaymasterERC20.sol"; +import {PaymasterERC20Guarantor} from "../../../account/paymaster/PaymasterERC20Guarantor.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:0x56 ] oracleSignatureLength (uint16) + * * [0x56:0x56+oracleSignatureLength] oracleSignature (bytes) + */ +abstract contract PaymasterERC20Mock is EIP712, PaymasterERC20, AccessControl { + using ERC4337Utils for *; + + 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)"); + + function _authorizeWithdraw() internal override onlyRole(WITHDRAWER_ROLE) {} + + function _fetchDetails( + PackedUserOperation calldata userOp, + bytes32 /* userOpHash */ + ) internal view virtual override returns (uint256 validationData, IERC20 token, uint256 tokenPrice) { + bytes calldata paymasterData = userOp.paymasterData(); + + // parse oracle and oracle signature + address oracle = address(bytes20(paymasterData[0x40:0x54])); + + // check oracle is registered + if (!hasRole(ORACLE_ROLE, oracle)) return (ERC4337Utils.SIG_VALIDATION_FAILED, IERC20(address(0)), 0); + + // parse repayment details + token = IERC20(address(bytes20(paymasterData[0x00:0x14]))); + uint48 validAfter = uint48(bytes6(paymasterData[0x14:0x1a])); + uint48 validUntil = uint48(bytes6(paymasterData[0x1a:0x20])); + tokenPrice = uint256(bytes32(paymasterData[0x20:0x40])); + + // verify signature + validationData = SignatureChecker + .isValidSignatureNow( + oracle, + _hashTypedDataV4( + keccak256(abi.encode(TOKEN_PRICE_TYPEHASH, token, validAfter, validUntil, tokenPrice)) + ), + paymasterData[0x56:0x56 + uint16(bytes2(paymasterData[0x54:0x56]))] + ) + .packValidationData(validAfter, validUntil); + } +} + +/** + * NOTE: struct or the expected guarantor data is (starts at 0x56+oracleSignatureLength): + * * [0x00:0x14 ] guarantor (address) (optional: 0 if no guarantor) + * * [0x14:0x16 ] guarantorSignatureLength (uint16) + * * [0x16+guarantorSignatureLength ] guarantorSignature (bytes) + */ +abstract contract PaymasterERC20GuarantorMock is PaymasterERC20Mock, PaymasterERC20Guarantor { + using ERC4337Utils for *; + + 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 _fetchGuarantor( + PackedUserOperation calldata userOp + ) internal view virtual override returns (address guarantor) { + bytes calldata paymasterData = userOp.paymasterData(); + + uint16 oracleSignatureLength = uint16(bytes2(paymasterData[0x54:0x56])); + bytes calldata guarantorData = paymasterData[0x56 + oracleSignatureLength:]; + + if (guarantorData.length < 0x16) return address(0); + address guarantorInput = address(bytes20(guarantorData[0x00:0x14])); + if (guarantorInput == address(0)) return guarantorInput; + + uint16 guarantorSignatureLength = uint16(bytes2(guarantorData[0x14:0x16])); + bytes calldata guarantorSignature = guarantorData[0x16:0x16 + guarantorSignatureLength]; + return + SignatureChecker.isValidSignatureNow( + guarantorInput, + _hashTypedDataV4(_getStructHashWithoutOracleAndGuarantorSignature(userOp)), + guarantorSignature + ) + ? guarantorInput + : address(0); + } + + function _refund( + IERC20 token, + uint256 tokenPrice, + uint256 actualGasCost, + uint256 actualUserOpFeePerGas, + address prefunder, + uint256 prefundAmount, + bytes calldata prefundContext + ) internal virtual override(PaymasterERC20, PaymasterERC20Guarantor) returns (bool refunded, uint256 actualAmount) { + return + super._refund( + token, + tokenPrice, + actualGasCost, + actualUserOpFeePerGas, + prefunder, + prefundAmount, + prefundContext + ); + } + + function _prefund( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + IERC20 token, + uint256 tokenPrice, + address prefunder_, + uint256 maxCost + ) + internal + virtual + override(PaymasterERC20, PaymasterERC20Guarantor) + returns (bool prefunded, uint256 prefundAmount, address prefunder, bytes memory prefundContext) + { + return super._prefund(userOp, userOpHash, token, tokenPrice, prefunder_, maxCost); + } + + 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[:0x88]) // 0x34 (paymasterDataOffset) + 0x54 (token + validAfter + validUntil + tokenPrice + oracle) + ) + ); + } +} diff --git a/contracts/mocks/account/paymaster/PaymasterERC721OwnerMock.sol b/contracts/mocks/account/paymaster/PaymasterERC721OwnerMock.sol new file mode 100644 index 00000000000..9c2b3293e85 --- /dev/null +++ b/contracts/mocks/account/paymaster/PaymasterERC721OwnerMock.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Ownable} from "../../../access/Ownable.sol"; +import {ERC4337Utils, PackedUserOperation} from "../../../account/utils/draft-ERC4337Utils.sol"; +import {PaymasterERC721Owner} from "../../../account/paymaster/PaymasterERC721Owner.sol"; + +abstract contract PaymasterERC721OwnerContextNoPostOpMock is PaymasterERC721Owner, Ownable { + using ERC4337Utils for *; + + function _validatePaymasterUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 requiredPreFund + ) internal override returns (bytes memory context, uint256 validationData) { + // use the userOp's callData as context; + context = userOp.callData; + // super call (PaymasterERC721Owner) for the validation data + (, validationData) = super._validatePaymasterUserOp(userOp, userOpHash, requiredPreFund); + } + + function _authorizeWithdraw() internal override onlyOwner {} +} + +abstract contract PaymasterERC721OwnerMock is PaymasterERC721OwnerContextNoPostOpMock { + event PaymasterDataPostOp(bytes paymasterData); + + function _postOp( + PostOpMode mode, + bytes calldata context, + uint256 actualGasCost, + uint256 actualUserOpFeePerGas + ) internal override { + emit PaymasterDataPostOp(context); + super._postOp(mode, context, actualGasCost, actualUserOpFeePerGas); + } +} diff --git a/contracts/mocks/account/paymaster/PaymasterSignerMock.sol b/contracts/mocks/account/paymaster/PaymasterSignerMock.sol new file mode 100644 index 00000000000..f2937fda5e4 --- /dev/null +++ b/contracts/mocks/account/paymaster/PaymasterSignerMock.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Ownable} from "../../../access/Ownable.sol"; +import {ERC4337Utils, PackedUserOperation} from "../../../account/utils/draft-ERC4337Utils.sol"; +import {SignerECDSA} from "../../../utils/cryptography/signers/SignerECDSA.sol"; +import {PaymasterSigner} from "../../../account/paymaster/PaymasterSigner.sol"; + +abstract contract PaymasterSignerContextNoPostOpMock is PaymasterSigner, SignerECDSA, Ownable { + using ERC4337Utils for *; + + function _validatePaymasterUserOp( + PackedUserOperation calldata userOp, + bytes32 userOpHash, + uint256 requiredPreFund + ) internal override returns (bytes memory context, uint256 validationData) { + // use the userOp's callData as context; + context = userOp.callData; + // super call (PaymasterSigner + SignerECDSA) for the validation data + (, validationData) = super._validatePaymasterUserOp(userOp, userOpHash, requiredPreFund); + } + + function _authorizeWithdraw() internal override onlyOwner {} +} + +abstract contract PaymasterSignerMock is PaymasterSignerContextNoPostOpMock { + event PaymasterDataPostOp(bytes paymasterData); + + function _postOp( + PostOpMode mode, + bytes calldata context, + uint256 actualGasCost, + uint256 actualUserOpFeePerGas + ) internal override { + emit PaymasterDataPostOp(context); + super._postOp(mode, context, actualGasCost, actualUserOpFeePerGas); + } +} diff --git a/contracts/mocks/docs/account/paymaster/PaymasterECDSASigner.sol b/contracts/mocks/docs/account/paymaster/PaymasterECDSASigner.sol new file mode 100644 index 00000000000..f5c87161681 --- /dev/null +++ b/contracts/mocks/docs/account/paymaster/PaymasterECDSASigner.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Ownable} from "../../../../access/Ownable.sol"; +import {PackedUserOperation} from "../../../../interfaces/draft-IERC4337.sol"; +import {SignerECDSA} from "../../../../utils/cryptography/signers/SignerECDSA.sol"; +import {PaymasterSigner, EIP712} from "../../../../account/paymaster/PaymasterSigner.sol"; + +contract PaymasterECDSASigner is PaymasterSigner, SignerECDSA, Ownable { + constructor(address signerAddr) EIP712("MyPaymasterECDSASigner", "1") Ownable(signerAddr) SignerECDSA(signerAddr) {} + + function _authorizeWithdraw() internal virtual override onlyOwner {} +} diff --git a/contracts/mocks/token/ERC20BlocklistMock.sol b/contracts/mocks/token/ERC20BlocklistMock.sol new file mode 100644 index 00000000000..64c8b928332 --- /dev/null +++ b/contracts/mocks/token/ERC20BlocklistMock.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC20} from "../../token/ERC20/ERC20.sol"; + +abstract contract ERC20BlocklistMock is ERC20 { + mapping(address user => bool) private _blocked; + + function _blockUser(address user) internal { + _blocked[user] = true; + } + + function _update(address from, address to, uint256 value) internal virtual override { + require(!_blocked[from] && !_blocked[to]); + super._update(from, to, value); + } +} diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 2b347c890c7..f4554c722b6 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -11,6 +11,7 @@ ** xref:accounts.adoc[Accounts] *** xref:eoa-delegation.adoc[EOA Delegation] *** xref:multisig.adoc[Multisig] +** xref:paymasters.adoc[Paymasters] * xref:tokens.adoc[Tokens] ** xref:erc20.adoc[ERC-20] diff --git a/docs/modules/ROOT/pages/account-abstraction.adoc b/docs/modules/ROOT/pages/account-abstraction.adoc index 01cfaf4abb1..a1dc7c59c5f 100644 --- a/docs/modules/ROOT/pages/account-abstraction.adoc +++ b/docs/modules/ROOT/pages/account-abstraction.adoc @@ -85,7 +85,7 @@ To build your own factory, see xref:accounts.adoc#accounts_factory[account facto A Paymaster is an optional entity that can sponsor gas fees for Accounts, or allow them to pay for those fees in ERC-20 instead of native currency. This abstracts gas away from the user experience in the same way that computational costs of cloud servers are abstracted away from end-users. -To build your own paymaster, see https://docs.openzeppelin.com/community-contracts/0.0.1/paymasters[paymasters]. +To build your own paymaster, see xref:paymasters.adoc[paymasters]. == Further notes diff --git a/docs/modules/ROOT/pages/eoa-delegation.adoc b/docs/modules/ROOT/pages/eoa-delegation.adoc index 05e8f396292..33402a8b52e 100644 --- a/docs/modules/ROOT/pages/eoa-delegation.adoc +++ b/docs/modules/ROOT/pages/eoa-delegation.adoc @@ -6,7 +6,7 @@ https://eips.ethereum.org/EIPS/eip-7702[EIP-7702] introduces a new transaction t * Sponsoring transactions for other users. * Implementing privilege de-escalation (e.g., sub-keys with limited permissions) -This section walks you through the process of delegating an EOA to a contract following https://eips.ethereum.org/EIPS/eip-7702[EIP-7702]. This allows you to use your EOA's private key to sign and execute operations with custom execution logic. Combined with https://eips.ethereum.org/EIPS/eip-4337[ERC-4337] infrastructure, users can achieve gas sponsoring through https://docs.openzeppelin.com/community-contracts/paymasters[paymasters]. +This section walks you through the process of delegating an EOA to a contract following https://eips.ethereum.org/EIPS/eip-7702[EIP-7702]. This allows you to use your EOA's private key to sign and execute operations with custom execution logic. Combined with https://eips.ethereum.org/EIPS/eip-4337[ERC-4337] infrastructure, users can achieve gas sponsoring through xref:paymasters.adoc[paymasters]. == Delegating execution diff --git a/docs/modules/ROOT/pages/paymasters.adoc b/docs/modules/ROOT/pages/paymasters.adoc new file mode 100644 index 00000000000..9a51ad53a03 --- /dev/null +++ b/docs/modules/ROOT/pages/paymasters.adoc @@ -0,0 +1,506 @@ += Paymasters + +In case you want to sponsor user operations for your users, ERC-4337 defines a special type of contract called _paymaster_, whose purpose is to pay the gas fees consumed by the user operation. + +In the context of account abstraction, sponsoring user operations allows a third party to pay for transaction gas fees on behalf of users. This can improve user experience by eliminating the need for users to hold native cryptocurrency (like ETH) to pay for transactions. + +To enable sponsorship, users sign their user operations including a special field called `paymasterAndData`, resulting from the concatenation of the paymaster address they're intending to use and the associated calldata that's going to be passed into xref:api:utils/cryptography.adoc#PaymasterCore-validatePaymasterUserOp[`validatePaymasterUserOp`]. The EntryPoint will use this field to determine whether it is willing to pay for the user operation or not. + +== Signed Sponsorship + +The xref:api:account.adoc#PaymasterSigner[`PaymasterSigner`] implements signature-based sponsorship via authorization signatures, allowing designated paymaster signers to authorize and sponsor specific user operations without requiring users to hold native ETH. + +TIP: Learn more about xref:accounts.adoc#selecting_a_signer[signers] to explore different approaches to user operation sponsorship via signatures. + +[source,solidity] +---- +include::api:example$account/paymaster/PaymasterECDSASigner.sol[] +---- + +TIP: xref:api:account.adoc#ERC4337Utils[`ERC4337Utils`] to facilitate the access to paymaster-related fields of the userOp (e.g. `paymasterData`, `paymasterVerificationGasLimit`) + +To implement signature-based sponsorship, you'll first need to deploy the paymaster contract. This contract will hold the ETH used to pay for user operations and verify signatures from your authorized signer. After deployment, you must fund the paymaster with ETH to cover gas costs for the operations it will sponsor: + +[source,typescript] +---- +// Fund the paymaster with ETH +await eoaClient.sendTransaction({ + to: paymasterECDSASigner.address, + value: parseEther("0.01"), + data: encodeFunctionData({ + abi: paymasterECDSASigner.abi, + functionName: "deposit", + args: [], + }), +}); +---- + +WARNING: Paymasters require sufficient ETH balance to pay for gas costs. If the paymaster runs out of funds, all operations it's meant to sponsor will fail. Consider implementing monitoring and automatic refilling of the paymaster's balance in production environments. + +When a user initiates an operation that requires sponsorship, your backend service (or other authorized entity) needs to sign the operation using EIP-712. This signature proves to the paymaster that it should cover the gas costs for this specific user operation: + +[source,typescript] +---- +// Set validation window +const now = Math.floor(Date.now() / 1000); +const validAfter = now - 60; // Valid from 1 minute ago +const validUntil = now + 3600; // Valid for 1 hour +const paymasterVerificationGasLimit = 100_000n; +const paymasterPostOpGasLimit = 300_000n; + +// Sign using EIP-712 typed data +const paymasterSignature = await signer.signTypedData({ + domain: { + chainId: await signerClient.getChainId(), + name: "MyPaymasterECDSASigner", + verifyingContract: paymasterECDSASigner.address, + version: "1", + }, + types: { + UserOperationRequest: [ + { name: "sender", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "initCode", type: "bytes" }, + { name: "callData", type: "bytes" }, + { name: "accountGasLimits", type: "bytes32" }, + { name: "preVerificationGas", type: "uint256" }, + { name: "gasFees", type: "bytes32" }, + { name: "paymasterVerificationGasLimit", type: "uint256" }, + { name: "paymasterPostOpGasLimit", type: "uint256" }, + { name: "validAfter", type: "uint48" }, + { name: "validUntil", type: "uint48" }, + ], + }, + primaryType: "UserOperationRequest", + message: { + sender: userOp.sender, + nonce: userOp.nonce, + initCode: userOp.initCode, + callData: userOp.callData, + accountGasLimits: userOp.accountGasLimits, + preVerificationGas: userOp.preVerificationGas, + gasFees: userOp.gasFees, + paymasterVerificationGasLimit, + paymasterPostOpGasLimit, + validAfter, + validUntil, + }, +}); +---- + +The time window (`validAfter` and `validUntil`) prevents replay attacks and allows you to limit how long the signature remains valid. Once signed, the paymaster data needs to be formatted and attached to the user operation: + +[source,typescript] +---- +userOp.paymasterAndData = encodePacked( + ["address", "uint128", "uint128", "bytes"], + [ + paymasterECDSASigner.address, + paymasterVerificationGasLimit, + paymasterPostOpGasLimit, + encodePacked( + ["uint48", "uint48", "bytes"], + [validAfter, validUntil, paymasterSignature] + ), + ] +); +---- + +TIP: The `paymasterVerificationGasLimit` and `paymasterPostOpGasLimit` values should be adjusted based on your paymaster's complexity. Higher values increase the gas cost but provide more execution headroom, reducing the risk of out-of-gas errors during validation or post-operation processing. + +With the paymaster data attached, the user operation can now be signed by the account signer and submitted to the EntryPoint contract: + +[source,typescript] +---- +// Sign the user operation with the account owner +const signedUserOp = await signUserOp(entrypoint, userOp); + +// Submit to the EntryPoint contract +const userOpReceipt = await eoaClient.writeContract({ + abi: EntrypointV09Abi, + address: entrypoint.address, + functionName: "handleOps", + args: [[signedUserOp], beneficiary.address], +}); +---- + +Behind the scenes, the EntryPoint will call the paymaster's `validatePaymasterUserOp` function, which verifies the signature and time window. If valid, the paymaster commits to paying for the operation's gas costs, and the EntryPoint executes the operation. + +== ERC20-based Sponsorship + +While signature-based sponsorship is useful for many applications, sometimes you want users to pay for their own transactions but using tokens instead of ETH. The xref:api:account.adoc#PaymasterERC20[`PaymasterERC20`] allows users to pay for gas fees using ERC-20 tokens. Developers must implement an xref:api:account.adoc#PaymasterERC20-_fetchDetails-struct-PackedUserOperation-bytes32-[`_fetchDetails`] to get the token price information from an oracle of their preference. + +[source,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 +} +---- + +=== Using Oracles + +==== Chainlink Price Feeds + +A popular approach to implement price oracles is to use https://docs.chain.link/data-feeds/using-data-feeds[Chainlink's price feeds]. By using their https://docs.chain.link/data-feeds/api-reference#aggregatorv3interface[`AggregatorV3Interface`] developers determine the token-to-ETH exchange rate dynamically for their paymasters. This ensures fair pricing even as market rates fluctuate. + +Consider the following contract: + +[source,solidity] +---- +// WARNING: Unaudited code. +// Consider performing a security review before going to production. +contract PaymasterUSDCChainlink is PaymasterERC20, Ownable { + // Values for sepolia + // See https://docs.chain.link/data-feeds/price-feeds/addresses + AggregatorV3Interface public constant USDC_USD_ORACLE = + AggregatorV3Interface(0xA2F78ab2355fe2f984D808B5CeE7FD0A93D5270E); + AggregatorV3Interface public constant ETH_USD_ORACLE = + AggregatorV3Interface(0x694AA1769357215DE4FAC081bf1f309aDC325306); + + // See https://sepolia.etherscan.io/token/0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 + IERC20 private constant USDC = + IERC20(0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238); + + constructor(address initialOwner) Ownable(initialOwner) {} + + function _authorizeWithdraw() internal virtual override onlyOwner {} + + function liveness() public view virtual returns (uint256) { + return 15 minutes; // Tolerate stale data + } + + function _fetchDetails( + PackedUserOperation calldata userOp, + bytes32 /* userOpHash */ + ) internal view virtual override returns (uint256 validationData, IERC20 token, uint256 tokenPrice) { + (uint256 validationData_, uint256 price) = _fetchOracleDetails(userOp); + return ( + validationData_, + USDC, + price + ); + } + + function _fetchOracleDetails( + PackedUserOperation calldata /* userOp */ + ) + internal + view + virtual + returns (uint256 validationData, uint256 tokenPrice) + { + // ... + } +} +---- + +NOTE: The `PaymasterUSDCChainlink` contract uses specific Chainlink price feeds (ETH/USD and USDC/USD) on Sepolia. For production use or other networks, you'll need to modify the contract to use the appropriate price feed addresses. + +As you can see, a `_fetchOracleDetails` function is specified to fetch the token price that will be used as a reference for calculating the final ERC-20 payment. One can fetch and process price data from Chainlink oracles to determine the exchange rate between the price of a concrete ERC-20 and ETH. An example with USDC would be: + +1. Fetch the current `ETH/USD` and `USDC/USD` prices from their respective oracles. +2. Calculate the `USDC/ETH` exchange rate using the formula: `USDC/ETH = (USDC/USD) / (ETH/USD)`. This gives us how many USDC tokens are needed to buy 1 ETH + +NOTE: The price of the ERC-20 must be scaled by xref:api:account.adoc#PaymasterERC20-_tokenPriceDenominator--[`_tokenPriceDenominator`]. + +Here's how an implementation of `_fetchOracleDetails` would look like using this approach: + +TIP: Use xref:api:account.adoc#ERC4337Utils-combineValidationData-uint256-uint256-[`ERC4337Utils.combineValidationData`] to merge two `validationData` values. + +[source,solidity] +---- +// WARNING: Unaudited code. +// Consider performing a security review before going to production. + +using SafeCast for *; +using ERC4337Utils for *; + +function _fetchOracleDetails( + PackedUserOperation calldata /* userOp */ +) + internal + view + virtual + returns (uint256 validationData, uint256 tokenPrice) +{ + (uint256 ETHUSDValidationData, int256 ETHUSD) = _fetchPrice( + ETH_USD_ORACLE + ); + (uint256 USDCUSDValidationData, int256 USDCUSD) = _fetchPrice( + USDC_USD_ORACLE + ); + + if (ETHUSD <= 0 || USDCUSD <= 0) { + // No negative prices + return (ERC4337Utils.SIG_VALIDATION_FAILED, 0); + } + + // eth / usdc = (usdc / usd) / (eth / usd) = usdc * usd / eth * usd = usdc / eth + int256 scale = _tokenPriceDenominator().toInt256(); + int256 scaledUSDCUSD = USDCUSD * scale * (10 ** ETH_USD_ORACLE.decimals()).toInt256(); + int256 scaledUSDCETH = scaledUSDCUSD / (ETHUSD * (10 ** USDC_USD_ORACLE.decimals()).toInt256()); + + return ( + ETHUSDValidationData.combineValidationData(USDCUSDValidationData), + uint256(scaledUSDCETH) // Safe upcast + ); +} + +function _fetchPrice( + AggregatorV3Interface oracle +) internal view virtual returns (uint256 validationData, int256 price) { + ( + uint80 roundId, + int256 price_, + , + uint256 timestamp, + uint80 answeredInRound + ) = oracle.latestRoundData(); + if ( + price_ == 0 || // No data + answeredInRound < roundId || // Not answered in round + timestamp == 0 || // Incomplete round + block.timestamp - timestamp > liveness() // Stale data + ) { + return (ERC4337Utils.SIG_VALIDATION_FAILED, 0); + } + return (ERC4337Utils.SIG_VALIDATION_SUCCESS, price_); +} +---- + +NOTE: An important difference with token-based sponsorship is that the user's smart account must first approve the paymaster to spend their tokens. You might want to incorporate this approval as part of your account initialization process, or check if approval is needed before executing an operation. + +The PaymasterERC20 contract follows a pre-charge and refund model: + +1. During validation, it pre-charges the maximum possible gas cost +2. After execution, it refunds any unused gas back to the user + +This model ensures the paymaster can always cover gas costs, while only charging users for the actual gas used. + +[source,typescript] +---- +const paymasterVerificationGasLimit = 150_000n; +const paymasterPostOpGasLimit = 300_000n; + +userOp.paymasterAndData = encodePacked( + ["address", "uint128", "uint128", "bytes"], + [ + paymasterUSDCChainlink.address, + paymasterVerificationGasLimit, + paymasterPostOpGasLimit, + "0x" // No additional data needed + ] +); +---- + +For the rest, you can sign the user operation as you would normally do once the `paymasterAndData` field has been set. + +[source,typescript] +---- +// Sign the user operation with the account owner +const signedUserOp = await signUserOp(entrypoint, userOp); + +// Submit to the EntryPoint contract +const userOpReceipt = await eoaClient.writeContract({ + abi: EntrypointV09Abi, + address: entrypoint.address, + functionName: "handleOps", + args: [[signedUserOp], beneficiary.address], +}); +---- + +WARNING: Oracle-based pricing relies on the accuracy and freshness of price feeds. The `PaymasterUSDCChainlink` includes safety checks for stale data, but you should still monitor for extreme market volatility that could affect your users. + +=== Using a Guarantor + +There are multiple valid cases where the user might not have enough tokens to pay for the transaction before it takes place. For example, if the user is claiming an airdrop, they might need their first transaction to be sponsored. For those cases, the xref:api:account.adoc#PaymasterERC20Guarantor[`PaymasterERC20Guarantor`] contract extends the standard PaymasterERC20 to allow a third party (guarantor) to back user operations. + +The guarantor pre-funds the maximum possible gas cost upfront, and after execution: + +1. If the user repays the guarantor, the guarantor gets their funds back +2. If the user fails to repay, the guarantor absorbs the cost + +[TIP] +==== +A common use case is for guarantors to pay for operations of users claiming airdrops: + +* The guarantor pays gas fees upfront +* The user claims their airdrop tokens +* The user repays the guarantor from the claimed tokens +* If the user fails to repay, the guarantor absorbs the cost +==== + +To implement guarantor functionality, your paymaster needs to extend the PaymasterERC20Guarantor class and implement the `_fetchGuarantor` function: + +[source,solidity] +---- +function _fetchGuarantor( + PackedUserOperation calldata userOp +) internal view override returns (address guarantor) { + // Implement logic to fetch and validate the guarantor from userOp +} +---- + +Let's create a guarantor-enabled paymaster by extending our previous example: + +```solidity +// WARNING: Unaudited code. +// Consider performing a security review before going to production. +contract PaymasterUSDCGuaranteed is EIP712, PaymasterERC20Guarantor, Ownable { + + // Keep the same oracle code as before... + + bytes32 private constant GUARANTEED_USER_OPERATION_TYPEHASH = + keccak256( + "GuaranteedUserOperation(address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterData)" + ); + + constructor( + address initialOwner + ) EIP712("PaymasterUSDCGuaranteed", "1") Ownable(initialOwner) {} + + // Other functions from PaymasterUSDCChainlink... + + function _fetchGuarantor( + PackedUserOperation calldata userOp + ) internal view override returns (address guarantor) { + bytes calldata paymasterData = userOp.paymasterData(); + + // Check guarantor data (should be at least 22 bytes: 20 for address + 2 for sig length) + // If no guarantor specified, return early + if (paymasterData.length < 22 || guarantor == address(0)) { + return address(0); + } + + guarantor = address(bytes20(paymasterData[:20])); + uint16 guarantorSigLength = uint16(bytes2(paymasterData[20:22])); + + // Ensure the signature fits in the data + if (paymasterData.length < 22 + guarantorSigLength) { + return address(0); + } + + bytes calldata guarantorSignature = paymasterData[22:22 + guarantorSigLength]; + + // Validate the guarantor's signature + bytes32 structHash = _getGuaranteedOperationStructHash(userOp); + bytes32 hash = _hashTypedDataV4(structHash); + + return SignatureChecker.isValidSignatureNow( + guarantor, + hash, + guarantorSignature + ) ? guarantor : address(0); + } + + function _getGuaranteedOperationStructHash( + PackedUserOperation calldata userOp + ) internal pure returns (bytes32) { + return keccak256( + abi.encode( + GUARANTEED_USER_OPERATION_TYPEHASH, + userOp.sender, + userOp.nonce, + keccak256(userOp.initCode), + keccak256(userOp.callData), + userOp.accountGasLimits, + userOp.preVerificationGas, + userOp.gasFees, + keccak256(bytes(userOp.paymasterData()[:20])) // Just the guarantor address part + ) + ); + } +} +``` + +With this implementation, a guarantor would sign a user operation to authorize backing it: + +[source,typescript] +---- +// Sign the user operation with the guarantor +const guarantorSignature = await guarantor.signTypedData({ + domain: { + chainId: await guarantorClient.getChainId(), + name: "PaymasterUSDCGuaranteed", + verifyingContract: paymasterUSDC.address, + version: "1", + }, + types: { + GuaranteedUserOperation: [ + { name: "sender", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "initCode", type: "bytes" }, + { name: "callData", type: "bytes" }, + { name: "accountGasLimits", type: "bytes32" }, + { name: "preVerificationGas", type: "uint256" }, + { name: "gasFees", type: "bytes32" }, + { name: "paymasterData", type: "bytes" } + ] + }, + primaryType: "GuaranteedUserOperation", + message: { + sender: userOp.sender, + nonce: userOp.nonce, + initCode: userOp.initCode, + callData: userOp.callData, + accountGasLimits: userOp.accountGasLimits, + preVerificationGas: userOp.preVerificationGas, + gasFees: userOp.gasFees, + paymasterData: guarantorAddress // Just the guarantor address + }, +}); +---- + +Then, we include the guarantor's address and its signature in the paymaster data: + +[source,typescript] +---- +const paymasterVerificationGasLimit = 150_000n; +const paymasterPostOpGasLimit = 300_000n; + +userOp.paymasterAndData = encodePacked( + ["address", "uint128", "uint128", "bytes"], + [ + paymasterUSDC.address, + paymasterVerificationGasLimit, + paymasterPostOpGasLimit, + encodePacked( + ["address", "bytes2", "bytes"], + [ + guarantorAddress, + toHex(guarantorSignature.replace("0x", "").length / 2, { size: 2 }), + guarantorSignature + ] + ) + ] +); +---- + +When the operation executes: + +1. During validation, the paymaster verifies the guarantor's signature and pre-funds from the guarantor's account +2. The user operation executes, potentially giving the user tokens (like in an airdrop claim) +3. During post-operation, the paymaster first tries to get repayment from the user +4. If the user can't pay, the guarantor's pre-funded amount is used +5. An event is emitted indicating who ultimately paid for the operation + +This approach enables novel use cases where users don't need tokens to start using a web3 app, and can cover costs after receiving value through their transaction. + +== Practical Considerations + +When implementing paymasters in production environments, keep these considerations in mind: + +1. **Balance management**: Regularly monitor and replenish your paymaster's ETH balance to ensure uninterrupted service. + +2. **Gas limits**: The verification and post-operation gas limits should be set carefully. Too low, and operations might fail; too high, and you waste resources. + +3. **Security**: For signature-based paymasters, protect your signing key as it controls who gets subsidized operations. + +4. **Price volatility**: For token-based paymasters, consider restricting which tokens are accepted, and implementing circuit breakers for extreme market conditions. + +5. **Spending limits**: Consider implementing daily or per-user limits to prevent abuse of your paymaster. + +TIP: For production deployments, it's often useful to implement a monitoring service that tracks paymaster usage, balances, and other metrics to ensure smooth operation. diff --git a/test/account/paymaster/Paymaster.behavior.js b/test/account/paymaster/Paymaster.behavior.js new file mode 100644 index 00000000000..338c5516b1e --- /dev/null +++ b/test/account/paymaster/Paymaster.behavior.js @@ -0,0 +1,246 @@ +const { ethers, predeploy } = require('hardhat'); +const { expect } = require('chai'); + +const { encodeBatch, encodeMode, CALL_TYPE_BATCH } = require('../../helpers/erc7579'); +const { MAX_UINT48 } = require('../../helpers/constants'); +const time = require('../../helpers/time'); + +const deposit = ethers.parseEther('1'); +const value = 42n; +const delay = time.duration.hours(10); + +function shouldBehaveLikePaymaster({ postOp, timeRange }) { + describe('entryPoint', function () { + it('should return the canonical entrypoint', async function () { + await expect(this.paymaster.entryPoint()).to.eventually.equal(predeploy.entrypoint.v09); + }); + }); + + describe('validatePaymasterUserOp', function () { + beforeEach(async function () { + await this.paymaster.deposit({ value: deposit }); + + this.userOp ??= {}; + this.userOp.paymaster = this.paymaster; + }); + + describe('validation (signature/token ownership/allowance)', function () { + it('approved user operation are sponsored', async function () { + const signedUserOp = await this.account + .createUserOp({ + ...this.userOp, + callData: this.account.interface.encodeFunctionData('execute', [ + encodeMode({ callType: CALL_TYPE_BATCH }), + encodeBatch({ + target: this.target, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + ]), + }) + .then(op => this.paymasterSignUserOp(op)) + .then(op => this.signUserOp(op)); + + // before + await expect(predeploy.entrypoint.v09.getNonce(this.account, 0n)).to.eventually.equal(0n); + await expect(predeploy.entrypoint.v09.balanceOf(this.paymaster)).to.eventually.equal(deposit); + + // execute sponsored user operation + const handleOpsTx = predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver); + await expect(handleOpsTx).to.changeEtherBalance(this.account, 0n); // no balance change + await expect(handleOpsTx).to.emit(this.target, 'MockFunctionCalledExtra').withArgs(this.account, 0n); + + if (postOp) + await expect(handleOpsTx).to.emit(this.paymaster, 'PaymasterDataPostOp').withArgs(signedUserOp.callData); + + // after + await expect(predeploy.entrypoint.v09.getNonce(this.account, 0n)).to.eventually.equal(1n); + await expect(predeploy.entrypoint.v09.balanceOf(this.paymaster)).to.eventually.be.lessThan(deposit); + }); + + it('revert if missing paymaster validation', async function () { + const signedUserOp = await this.account + .createUserOp({ + ...this.userOp, + callData: this.account.interface.encodeFunctionData('execute', [ + encodeMode({ callType: CALL_TYPE_BATCH }), + encodeBatch({ + target: this.target, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + ]), + }) + .then(op => this.paymasterSignUserOpInvalid(op, 0n, 0n)) + .then(op => this.signUserOp(op)); + + await expect(predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver)) + .to.be.revertedWithCustomError(predeploy.entrypoint.v09, 'FailedOp') + .withArgs(0n, 'AA34 signature error'); + }); + }); + + timeRange && + describe('time range', function () { + it('revert if validation data is too early', async function () { + const signedUserOp = await this.account + .createUserOp({ + ...this.userOp, + callData: this.account.interface.encodeFunctionData('execute', [ + encodeMode({ callType: CALL_TYPE_BATCH }), + encodeBatch({ + target: this.target, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + ]), + }) + .then(op => + this.paymasterSignUserOp(op, { + // validAfter MAX_UINT48 is in the future + // shr by 1 to remove the most significant bit, indicating timestamp ranges + validAfter: MAX_UINT48 >> 1n, + }), + ) + .then(op => this.signUserOp(op)); + + await expect(predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver)) + .to.be.revertedWithCustomError(predeploy.entrypoint.v09, 'FailedOp') + .withArgs(0n, 'AA32 paymaster expired or not due'); + }); + + it('revert if validation data is too late', async function () { + const signedUserOp = await this.account + .createUserOp({ + ...this.userOp, + callData: this.account.interface.encodeFunctionData('execute', [ + encodeMode({ callType: CALL_TYPE_BATCH }), + encodeBatch({ + target: this.target, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + ]), + }) + .then(op => this.paymasterSignUserOp(op, { validUntil: 1n })) // validUntil 1n is in the past + .then(op => this.signUserOp(op)); + + await expect(predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver)) + .to.be.revertedWithCustomError(predeploy.entrypoint.v09, 'FailedOp') + .withArgs(0n, 'AA32 paymaster expired or not due'); + }); + }); + + it('reverts if the caller is not the entrypoint', async function () { + const operation = await this.account.createUserOp(this.userOp); + + await expect( + this.paymaster.connect(this.other).validatePaymasterUserOp(operation.packed, ethers.ZeroHash, 100_000n), + ) + .to.be.revertedWithCustomError(this.paymaster, 'PaymasterUnauthorized') + .withArgs(this.other); + }); + }); + + describe('postOp', function () { + it('reverts if the caller is not the entrypoint', async function () { + await expect(this.paymaster.connect(this.other).postOp(0n, '0x', 0n, 0n)) + .to.be.revertedWithCustomError(this.paymaster, 'PaymasterUnauthorized') + .withArgs(this.other); + }); + }); + + describe('deposit lifecycle', function () { + it('deposits and withdraws effectively', async function () { + await expect(predeploy.entrypoint.v09.balanceOf(this.paymaster)).to.eventually.equal(0n); + + await expect(this.paymaster.connect(this.other).deposit({ value })).to.changeEtherBalances( + [this.other, predeploy.entrypoint.v09], + [-value, value], + ); + + await expect(predeploy.entrypoint.v09.balanceOf(this.paymaster)).to.eventually.equal(value); + + await expect(this.paymaster.connect(this.admin).withdraw(this.receiver, 1n)).to.changeEtherBalances( + [predeploy.entrypoint.v09, this.receiver], + [-1n, 1n], + ); + + await expect(predeploy.entrypoint.v09.balanceOf(this.paymaster)).to.eventually.equal(value - 1n); + }); + + it('reverts when an unauthorized caller tries to withdraw', async function () { + await this.paymaster.deposit({ value }); + + await expect(this.paymaster.connect(this.other).withdraw(this.receiver, value)).to.be.reverted; + }); + }); + + describe('stake lifecycle', function () { + it('adds and removes stake effectively', async function () { + await expect(predeploy.entrypoint.v09.getDepositInfo(this.paymaster)).to.eventually.deep.equal([ + 0n, + false, + 0n, + 0n, + 0n, + ]); + + // stake + await expect(this.paymaster.connect(this.other).addStake(delay, { value })).to.changeEtherBalances( + [this.other, predeploy.entrypoint.v09], + [-value, value], + ); + + await expect(predeploy.entrypoint.v09.getDepositInfo(this.paymaster)).to.eventually.deep.equal([ + 0n, + true, + 42n, + delay, + 0n, + ]); + + // unlock + const unlockTx = this.paymaster.connect(this.admin).unlockStake(); + + const timestamp = await time.clockFromReceipt.timestamp(unlockTx); + await expect(predeploy.entrypoint.v09.getDepositInfo(this.paymaster)).to.eventually.deep.equal([ + 0n, + false, + 42n, + delay, + timestamp + delay, + ]); + + await time.increaseBy.timestamp(delay); + + // withdraw stake + await expect(this.paymaster.connect(this.admin).withdrawStake(this.receiver)).to.changeEtherBalances( + [predeploy.entrypoint.v09, this.receiver], + [-value, value], + ); + + await expect(predeploy.entrypoint.v09.getDepositInfo(this.paymaster)).to.eventually.deep.equal([ + 0n, + false, + 0n, + 0n, + 0n, + ]); + }); + + it('reverts when an unauthorized caller tries to unlock stake', async function () { + await this.paymaster.addStake(delay, { value }); + + await expect(this.paymaster.connect(this.other).unlockStake()).to.be.reverted; + }); + + it('reverts when an unauthorized caller tries to withdraw stake', async function () { + await this.paymaster.addStake(delay, { value }); + await this.paymaster.connect(this.admin).unlockStake(); + await time.increaseBy.timestamp(delay); + + await expect(this.paymaster.connect(this.other).withdrawStake(this.receiver)).to.be.reverted; + }); + }); +} + +module.exports = { + shouldBehaveLikePaymaster, +}; diff --git a/test/account/paymaster/PaymasterERC20.test.js b/test/account/paymaster/PaymasterERC20.test.js new file mode 100644 index 00000000000..1627fef6dec --- /dev/null +++ b/test/account/paymaster/PaymasterERC20.test.js @@ -0,0 +1,312 @@ +const { ethers, predeploy } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); + +const { getDomain, formatType, PackedUserOperation } = require('../../helpers/eip712'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { encodeBatch, encodeMode, CALL_TYPE_BATCH } = require('../../helpers/erc7579'); + +const { shouldBehaveLikePaymaster } = require('./Paymaster.behavior'); + +const value = ethers.parseEther('1'); + +async function fixture() { + // EOAs and environment + const [admin, receiver, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + const token = await ethers.deployContract('$ERC20', ['Name', 'Symbol']); + + // signers + const accountSigner = ethers.Wallet.createRandom(); + const oracleSigner = ethers.Wallet.createRandom(); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const account = await helper.newAccount('$AccountECDSAMock', [accountSigner, 'AccountECDSA', '1']); + await account.deploy(); + + // ERC-4337 paymaster + const paymaster = await ethers.deployContract('$PaymasterERC20Mock', ['PaymasterERC20', '1']); + await paymaster.$_grantRole(ethers.id('ORACLE_ROLE'), oracleSigner); + await paymaster.$_grantRole(ethers.id('WITHDRAWER_ROLE'), admin); + + // Domains + const entrypointDomain = await getDomain(predeploy.entrypoint.v09); + const paymasterDomain = await getDomain(paymaster); + + const signUserOp = userOp => + accountSigner + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + + // [0x00:0x14 ] token (IERC20) + // [0x14:0x1a ] validAfter (uint48) + // [0x1a:0x20 ] validUntil (uint48) + // [0x20:0x40 ] tokenPrice (uint256) + // [0x40:0x54 ] oracle (address) + // [0x54:0x56 ] oracleSignatureLength (uint16) + // [0x56:0x56+oracleSignatureLength] oracleSignature (bytes) + const paymasterSignUserOp = + oracle => + (userOp, { validAfter = 0n, validUntil = 0n, tokenPrice = ethers.WeiPerEther, erc20 = token } = {}) => { + userOp.paymasterData = ethers.solidityPacked( + ['address', 'uint48', 'uint48', 'uint256', 'address'], + [ + erc20.target ?? erc20.address ?? erc20, + validAfter, + validUntil, + tokenPrice, + oracle.target ?? oracle.address ?? oracle, + ], + ); + return Promise.all([ + oracle.signTypedData( + paymasterDomain, + { + TokenPrice: formatType({ + token: 'address', + validAfter: 'uint48', + validUntil: 'uint48', + tokenPrice: 'uint256', + }), + }, + { + token: erc20.target ?? erc20.address ?? erc20, + validAfter, + validUntil, + tokenPrice, + }, + ), + ]).then(([oracleSignature]) => { + userOp.paymasterData = ethers.concat([ + userOp.paymasterData, + ethers.solidityPacked(['uint16', 'bytes'], [ethers.getBytes(oracleSignature).length, oracleSignature]), + ]); + return userOp; + }); + }; + + return { + admin, + receiver, + other, + target, + token, + account, + paymaster, + signUserOp, + paymasterSignUserOp: paymasterSignUserOp(oracleSigner), // sign using the correct key + paymasterSignUserOpInvalid: paymasterSignUserOp(other), // sign using the wrong key + }; +} + +describe('PaymasterERC20', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('core paymaster behavior', async function () { + beforeEach(async function () { + await this.token.$_mint(this.account, value); + await this.token.$_approve(this.account, this.paymaster, ethers.MaxUint256); + }); + + shouldBehaveLikePaymaster({ timeRange: true }); + }); + + describe('pays with ERC-20 tokens', function () { + beforeEach(async function () { + await this.paymaster.deposit({ value }); + this.userOp ??= {}; + this.userOp.paymaster = this.paymaster; + }); + + it('succeeds paying with ERC-20 tokens', async function () { + // fund account + await this.token.$_mint(this.account, value); + await this.token.$_approve(this.account, this.paymaster, ethers.MaxUint256); + + this.extraCalls = []; + this.tokenMovements = [ + { account: this.account, factor: -1n }, + { account: this.paymaster, factor: 1n }, + ]; + + const signedUserOp = await this.account + // prepare user operation, with paymaster data + .createUserOp({ + ...this.userOp, + callData: this.account.interface.encodeFunctionData('execute', [ + encodeMode({ callType: CALL_TYPE_BATCH }), + encodeBatch(...this.extraCalls, { + target: this.target, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + ]), + }) + .then(op => + this.paymasterSignUserOp(op, { + tokenPrice: 2n * ethers.WeiPerEther, + }), + ) + .then(op => this.signUserOp(op)); + + // send it to the entrypoint + const txPromise = predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver); + + // check main events (target call and sponsoring) + await expect(txPromise) + .to.emit(this.paymaster, 'UserOperationSponsored') + .withArgs(signedUserOp.hash(), this.token, anyValue, 2n * ethers.WeiPerEther) + .to.emit(this.target, 'MockFunctionCalledExtra') + .withArgs(this.account, 0n); + + // parse logs: + // - get tokenAmount repaid for the paymaster event + // - get the actual gas cost from the entrypoint event + const { logs } = await txPromise.then(tx => tx.wait()); + const { tokenAmount } = logs.map(ev => this.paymaster.interface.parseLog(ev)).find(Boolean).args; + const { actualGasCost } = logs.find(ev => ev.fragment?.name == 'UserOperationEvent').args; + // check token balances moved as expected + await expect(txPromise).to.changeTokenBalances( + this.token, + this.tokenMovements.map(({ account }) => account), + this.tokenMovements.map(({ factor = 0n, offset = 0n }) => offset + tokenAmount * factor), + ); + // check that ether moved as expected + await expect(txPromise).to.changeEtherBalances( + [predeploy.entrypoint.v09, this.receiver], + [-actualGasCost, actualGasCost], + ); + + // check token cost is within the expected values + // skip gas consumption tests when running coverage (significantly affects the postOp costs) + if (!process.env.COVERAGE) { + expect(tokenAmount) + .to.be.greaterThan(actualGasCost * 2n) + .to.be.lessThan((actualGasCost * 2n * 110n) / 100n); // covers costs with no more than 10% overcost + } + }); + + it('reverts with PaymasterERC20FailedRefund when token refund fails', async function () { + const erc20Blocklist = await ethers.deployContract('$ERC20BlocklistMock', ['Token', 'TKN']); + + // fund account with the malicious token + await erc20Blocklist.$_mint(this.account, value); + await erc20Blocklist.$_approve(this.account, this.paymaster, ethers.MaxUint256); + + const extraCalls = [ + // Set the token to block all transfers during postOp + { + target: erc20Blocklist, + data: erc20Blocklist.interface.encodeFunctionData('$_blockUser', [this.paymaster.target]), + }, + ]; + + const signedUserOp = await this.account + .createUserOp({ + ...this.userOp, + callData: this.account.interface.encodeFunctionData('execute', [ + encodeMode({ callType: CALL_TYPE_BATCH }), + encodeBatch(...extraCalls, { + target: this.target, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + ]), + }) + .then(op => + this.paymasterSignUserOp(op, { + tokenPrice: 2n * ethers.WeiPerEther, + erc20: erc20Blocklist, + }), + ) + .then(op => this.signUserOp(op)); + + const txPromise = predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver); + + // Reverted post op does not revert the operation + const { logs } = await txPromise.then(tx => tx.wait()); + const [, , , postOpRevertReason] = logs.find(v => v.fragment?.name === 'PostOpRevertReason').args; + const postOpError = predeploy.entrypoint.v09.interface.parseError(postOpRevertReason); + expect(postOpError.name).to.eq('PostOpReverted'); + const [paymasterRevertReason] = postOpError.args; + const { name, args } = this.paymaster.interface.parseError(paymasterRevertReason); + expect(name).to.eq('PaymasterERC20FailedRefund'); + const [token, prefundAmount] = args; + expect(token).to.eq(erc20Blocklist.target); + await expect(txPromise).changeTokenBalances( + erc20Blocklist, + [this.paymaster, signedUserOp.sender], + [prefundAmount, -prefundAmount], + ); + }); + + it('reverts with an invalid token', async function () { + // prepare user operation, with paymaster data + const signedUserOp = await this.account + .createUserOp(this.userOp) + .then(op => this.paymasterSignUserOp(op, { erc20: this.other })) // not a token + .then(op => this.signUserOp(op)); + + // send it to the entrypoint + await expect(predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver)) + .to.be.revertedWithCustomError(predeploy.entrypoint.v09, 'FailedOp') + .withArgs(0n, 'AA34 signature error'); + }); + + it('reverts with insufficient balance', async function () { + await this.token.$_mint(this.account, 1n); // not enough + await this.token.$_approve(this.account, this.paymaster, ethers.MaxUint256); + + // prepare user operation, with paymaster data + const signedUserOp = await this.account + .createUserOp(this.userOp) + .then(op => this.paymasterSignUserOp(op)) + .then(op => this.signUserOp(op)); + + // send it to the entrypoint + await expect(predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver)) + .to.be.revertedWithCustomError(predeploy.entrypoint.v09, 'FailedOp') + .withArgs(0n, 'AA34 signature error'); + }); + + it('reverts with insufficient approval', async function () { + await this.token.$_mint(this.account, value); + await this.token.$_approve(this.account, this.paymaster, 1n); + + // prepare user operation, with paymaster data + const signedUserOp = await this.account + .createUserOp(this.userOp) + .then(op => this.paymasterSignUserOp(op)) + .then(op => this.signUserOp(op)); + + // send it to the entrypoint + await expect(predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver)) + .to.be.revertedWithCustomError(predeploy.entrypoint.v09, 'FailedOp') + .withArgs(0n, 'AA34 signature error'); + }); + }); + + describe('withdraw ERC-20 tokens', function () { + beforeEach(async function () { + await this.token.$_mint(this.paymaster, value); + }); + + it('withdraw some token', async function () { + await expect( + this.paymaster.connect(this.admin).withdrawTokens(this.token, this.receiver, 10n), + ).to.changeTokenBalances(this.token, [this.paymaster, this.receiver], [-10n, 10n]); + }); + + it('withdraw all token', async function () { + await expect( + this.paymaster.connect(this.admin).withdrawTokens(this.token, this.receiver, ethers.MaxUint256), + ).to.changeTokenBalances(this.token, [this.paymaster, this.receiver], [-value, value]); + }); + + it('only admin can withdraw', async function () { + await expect(this.paymaster.connect(this.other).withdrawTokens(this.token, this.receiver, 10n)).to.be.reverted; + }); + }); +}); diff --git a/test/account/paymaster/PaymasterERC20Guarantor.test.js b/test/account/paymaster/PaymasterERC20Guarantor.test.js new file mode 100644 index 00000000000..620fd7fcb52 --- /dev/null +++ b/test/account/paymaster/PaymasterERC20Guarantor.test.js @@ -0,0 +1,392 @@ +const { ethers, predeploy } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); + +const { getDomain, formatType, PackedUserOperation } = require('../../helpers/eip712'); +const { ERC4337Helper } = require('../../helpers/erc4337'); +const { encodeBatch, encodeMode, CALL_TYPE_BATCH } = require('../../helpers/erc7579'); + +const { shouldBehaveLikePaymaster } = require('./Paymaster.behavior'); + +const value = ethers.parseEther('1'); + +async function fixture() { + // EOAs and environment + const [admin, receiver, guarantor, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + const token = await ethers.deployContract('$ERC20', ['Name', 'Symbol']); + + // signers + const accountSigner = ethers.Wallet.createRandom(); + const oracleSigner = ethers.Wallet.createRandom(); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const account = await helper.newAccount('$AccountECDSAMock', [accountSigner, 'AccountECDSA', '1']); + await account.deploy(); + + // ERC-4337 paymaster + const paymaster = await ethers.deployContract('$PaymasterERC20GuarantorMock', ['PaymasterERC20Guarantor', '1']); + await paymaster.$_grantRole(ethers.id('ORACLE_ROLE'), oracleSigner); + await paymaster.$_grantRole(ethers.id('WITHDRAWER_ROLE'), admin); + + // Domains + const entrypointDomain = await getDomain(predeploy.entrypoint.v09); + const paymasterDomain = await getDomain(paymaster); + + const signUserOp = userOp => + accountSigner + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + + // Paymaster data format: + // [0x00:0x14 ] token (IERC20) + // [0x14:0x1a ] validAfter (uint48) + // [0x1a:0x20 ] validUntil (uint48) + // [0x20:0x40 ] tokenPrice (uint256) + // [0x40:0x54 ] oracle (address) + // [0x54:0x56 ] oracleSignatureLength (uint16) + // [0x56:0x56+oracleSignatureLength] oracleSignature (bytes) + // [0x00:0x14 ] guarantor (address) (optional: 0 if no guarantor) + // [0x14:0x16 ] guarantorSignatureLength (uint16) + // [0x16:0x16+guarantorSignatureLn ] guarantorSignature (bytes) + + const paymasterSignUserOp = + oracle => + ( + userOp, + { validAfter = 0n, validUntil = 0n, tokenPrice = ethers.WeiPerEther, guarantor = undefined, erc20 = token } = {}, + ) => { + // First create main paymaster data without signatures + userOp.paymasterData = ethers.solidityPacked( + ['address', 'uint48', 'uint48', 'uint256', 'address'], + [ + erc20.target ?? erc20.address ?? erc20, + validAfter, + validUntil, + tokenPrice, + oracle.target ?? oracle.address ?? oracle, + ], + ); + + return Promise.all([ + oracle.signTypedData( + paymasterDomain, + { + TokenPrice: formatType({ + token: 'address', + validAfter: 'uint48', + validUntil: 'uint48', + tokenPrice: 'uint256', + }), + }, + { + token: erc20.target ?? erc20.address ?? erc20, + validAfter, + validUntil, + tokenPrice, + }, + ), + guarantor ? guarantor.signTypedData(paymasterDomain, { PackedUserOperation }, userOp.packed) : '0x', + ]).then(([oracleSignature, guarantorSignature]) => { + // Add oracle signature + const oracleSignatureWithLength = ethers.solidityPacked( + ['uint16', 'bytes'], + [ethers.getBytes(oracleSignature).length, oracleSignature], + ); + + userOp.paymasterData = ethers.concat([userOp.paymasterData, oracleSignatureWithLength]); + + // Add guarantor data if provided + if (guarantor) { + const guarantorData = ethers.solidityPacked( + ['address', 'uint16', 'bytes'], + [guarantor.address, ethers.getBytes(guarantorSignature).length, guarantorSignature], + ); + + userOp.paymasterData = ethers.concat([userOp.paymasterData, guarantorData]); + } + + return userOp; + }); + }; + + return { + admin, + receiver, + guarantor, + other, + target, + token, + account, + paymaster, + signUserOp, + paymasterSignUserOp: paymasterSignUserOp(oracleSigner), // sign using the correct key + paymasterSignUserOpInvalid: paymasterSignUserOp(other), // sign using the wrong key + }; +} + +describe('PaymasterERC20Guarantor', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('core paymaster behavior', async function () { + beforeEach(async function () { + await this.token.$_mint(this.account, value); + await this.token.$_approve(this.account, this.paymaster, ethers.MaxUint256); + }); + + shouldBehaveLikePaymaster({ timeRange: true }); + }); + + describe('guarantor functionality', function () { + beforeEach(async function () { + await this.paymaster.deposit({ value }); + this.userOp ??= {}; + this.userOp.paymaster = this.paymaster; + }); + + describe('succeeds paying with ERC20 tokens', function () { + it('user repays guarantor', async function () { + // fund guarantor. account has no asset to pay for at the beginning of the transaction, but will get them during execution. + await this.token.$_mint(this.guarantor, value); + await this.token.$_approve(this.guarantor, this.paymaster, ethers.MaxUint256); + + this.extraCalls = [ + { target: this.token, data: this.token.interface.encodeFunctionData('$_mint', [this.account.target, value]) }, + { + target: this.token, + data: this.token.interface.encodeFunctionData('approve', [this.paymaster.target, ethers.MaxUint256]), + }, + ]; + + this.tokenMovements = [ + { account: this.account, factor: -1n, offset: value }, + { account: this.guarantor, factor: 0n }, + { account: this.paymaster, factor: 1n }, + ]; + + const signedUserOp = await this.account + .createUserOp({ + ...this.userOp, + callData: this.account.interface.encodeFunctionData('execute', [ + encodeMode({ callType: CALL_TYPE_BATCH }), + encodeBatch(...this.extraCalls, { + target: this.target, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + ]), + }) + .then(op => + this.paymasterSignUserOp(op, { + tokenPrice: 2n * ethers.WeiPerEther, + guarantor: this.guarantor, + }), + ) + .then(op => this.signUserOp(op)); + + // send it to the entrypoint + const txPromise = predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver); + + // check main events (target call, guarantor event, and sponsoring) + await expect(txPromise) + .to.emit(this.paymaster, 'UserOperationGuaranteed') + .withArgs(signedUserOp.hash(), this.guarantor.address, anyValue) + .to.emit(this.paymaster, 'UserOperationSponsored') + .withArgs(signedUserOp.hash(), this.token, anyValue, 2n * ethers.WeiPerEther) + .to.emit(this.target, 'MockFunctionCalledExtra') + .withArgs(this.account, 0n); + + // parse logs: + // - get tokenAmount repaid for the paymaster event + // - get the actual gas cost from the entrypoint event + const { logs } = await txPromise.then(tx => tx.wait()); + const paymasterERC20 = await ethers.getContractFactory('$PaymasterERC20Mock'); + const { tokenAmount } = logs.map(ev => paymasterERC20.interface.parseLog(ev)).find(Boolean).args; + const { actualGasCost } = logs.find(ev => ev.fragment?.name == 'UserOperationEvent').args; + + // check token balances moved as expected + await expect(txPromise).to.changeTokenBalances( + this.token, + this.tokenMovements.map(({ account }) => account), + this.tokenMovements.map(({ factor = 0n, offset = 0n }) => offset + tokenAmount * factor), + ); + + // check that ether moved as expected + await expect(txPromise).to.changeEtherBalances( + [predeploy.entrypoint.v09, this.receiver], + [-actualGasCost, actualGasCost], + ); + }); + + it('guarantor pays when user fails to pay', async function () { + // fund guarantor. account has no asset to pay for at the beginning of the transaction, and will not get them. + await this.token.$_mint(this.guarantor, value); + await this.token.$_approve(this.guarantor, this.paymaster, ethers.MaxUint256); + + this.extraCalls = []; // No minting to the account, so it won't be able to repay + + this.tokenMovements = [ + { account: this.account, factor: 0n }, + { account: this.guarantor, factor: -1n }, + { account: this.paymaster, factor: 1n }, + ]; + + const signedUserOp = await this.account + .createUserOp({ + ...this.userOp, + callData: this.account.interface.encodeFunctionData('execute', [ + encodeMode({ callType: CALL_TYPE_BATCH }), + encodeBatch(...this.extraCalls, { + target: this.target, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + ]), + }) + .then(op => + this.paymasterSignUserOp(op, { + tokenPrice: 2n * ethers.WeiPerEther, + guarantor: this.guarantor, + }), + ) + .then(op => this.signUserOp(op)); + + // send it to the entrypoint + const txPromise = predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver); + + // check main events + await expect(txPromise) + .to.emit(this.paymaster, 'UserOperationGuaranteed') + .withArgs(signedUserOp.hash(), this.guarantor.address, anyValue) + .to.emit(this.paymaster, 'UserOperationSponsored') + .withArgs(signedUserOp.hash(), this.token, anyValue, 2n * ethers.WeiPerEther) + .to.emit(this.target, 'MockFunctionCalledExtra') + .withArgs(this.account, 0n); + + // parse logs + const { logs } = await txPromise.then(tx => tx.wait()); + const paymasterERC20 = await ethers.getContractFactory('$PaymasterERC20Mock'); + const { tokenAmount } = logs.map(ev => paymasterERC20.interface.parseLog(ev)).find(Boolean).args; + const { actualGasCost } = logs.find(ev => ev.fragment?.name == 'UserOperationEvent').args; + + // check token balances + await expect(txPromise).to.changeTokenBalances( + this.token, + this.tokenMovements.map(({ account }) => account), + this.tokenMovements.map(({ factor = 0n, offset = 0n }) => offset + tokenAmount * factor), + ); + + // check ether balances + await expect(txPromise).to.changeEtherBalances( + [predeploy.entrypoint.v09, this.receiver], + [-actualGasCost, actualGasCost], + ); + }); + + it('works with cold storage guarantor', async function () { + // fund guarantor and account beforehand - all balances and allowances are cold + await this.token.$_mint(this.account, value); + await this.token.$_mint(this.guarantor, value); + await this.token.$_approve(this.account, this.paymaster, ethers.MaxUint256); + await this.token.$_approve(this.guarantor, this.paymaster, ethers.MaxUint256); + + this.extraCalls = []; + this.tokenMovements = [ + { account: this.account, factor: -1n }, + { account: this.guarantor, factor: 0n }, + { account: this.paymaster, factor: 1n }, + ]; + + const signedUserOp = await this.account + .createUserOp({ + ...this.userOp, + callData: this.account.interface.encodeFunctionData('execute', [ + encodeMode({ callType: CALL_TYPE_BATCH }), + encodeBatch(...this.extraCalls, { + target: this.target, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + ]), + }) + .then(op => + this.paymasterSignUserOp(op, { + tokenPrice: 2n * ethers.WeiPerEther, + guarantor: this.guarantor, + }), + ) + .then(op => this.signUserOp(op)); + + // send it to the entrypoint + const txPromise = predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver); + + // check events and balances + await expect(txPromise) + .to.emit(this.paymaster, 'UserOperationGuaranteed') + .to.emit(this.paymaster, 'UserOperationSponsored') + .to.emit(this.target, 'MockFunctionCalledExtra'); + + // parse logs + const { logs } = await txPromise.then(tx => tx.wait()); + const paymasterERC20 = await ethers.getContractFactory('$PaymasterERC20Mock'); + const { tokenAmount } = logs.map(ev => paymasterERC20.interface.parseLog(ev)).find(Boolean).args; + + // check token balances + await expect(txPromise).to.changeTokenBalances( + this.token, + this.tokenMovements.map(({ account }) => account), + this.tokenMovements.map(({ factor = 0n, offset = 0n }) => offset + tokenAmount * factor), + ); + }); + }); + + it('reverts with invalid guarantor signature', async function () { + await this.token.$_mint(this.guarantor, value); + await this.token.$_approve(this.guarantor, this.paymaster, ethers.MaxUint256); + + // Create user op with incorrect guarantor signing + const signedUserOp = await this.account + .createUserOp(this.userOp) + .then(op => this.paymasterSignUserOpInvalid(op, { guarantor: this.guarantor })) + .then(op => this.signUserOp(op)); + + // send it to the entrypoint + await expect(predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver)) + .to.be.revertedWithCustomError(predeploy.entrypoint.v09, 'FailedOp') + .withArgs(0n, 'AA34 signature error'); + }); + + it('reverts when guarantor has insufficient balance', async function () { + await this.token.$_mint(this.guarantor, 1n); // not enough + await this.token.$_approve(this.guarantor, this.paymaster, ethers.MaxUint256); + + // Create user op + const signedUserOp = await this.account + .createUserOp(this.userOp) + .then(op => this.paymasterSignUserOp(op, { guarantor: this.guarantor })) + .then(op => this.signUserOp(op)); + + // send it to the entrypoint + await expect(predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver)) + .to.be.revertedWithCustomError(predeploy.entrypoint.v09, 'FailedOp') + .withArgs(0n, 'AA34 signature error'); + }); + + it('reverts when guarantor has insufficient approval', async function () { + await this.token.$_mint(this.guarantor, value); + await this.token.$_approve(this.guarantor, this.paymaster, 1n); // not enough + + // Create user op + const signedUserOp = await this.account + .createUserOp(this.userOp) + .then(op => this.paymasterSignUserOp(op, { guarantor: this.guarantor })) + .then(op => this.signUserOp(op)); + + // send it to the entrypoint + await expect(predeploy.entrypoint.v09.handleOps([signedUserOp.packed], this.receiver)) + .to.be.revertedWithCustomError(predeploy.entrypoint.v09, 'FailedOp') + .withArgs(0n, 'AA34 signature error'); + }); + }); +}); diff --git a/test/account/paymaster/PaymasterERC721Owner.test.js b/test/account/paymaster/PaymasterERC721Owner.test.js new file mode 100644 index 00000000000..c23c6b3978d --- /dev/null +++ b/test/account/paymaster/PaymasterERC721Owner.test.js @@ -0,0 +1,64 @@ +const { ethers, predeploy } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain, PackedUserOperation } = require('../../helpers/eip712'); +const { ERC4337Helper } = require('../../helpers/erc4337'); + +const { shouldBehaveLikePaymaster } = require('./Paymaster.behavior'); + +for (const [name, opts] of Object.entries({ + PaymasterERC721Owner: { postOp: true, timeRange: false }, + PaymasterERC721OwnerContextNoPostOp: { postOp: false, timeRange: false }, +})) { + async function fixture() { + // EOAs and environment + const [admin, receiver, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + const token = await ethers.deployContract('$ERC721Enumerable', ['Some NFT', 'SNFT']); + + // signers + const accountSigner = ethers.Wallet.createRandom(); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const account = await helper.newAccount('$AccountECDSAMock', [accountSigner, 'AccountECDSA', '1']); + await account.deploy(); + + // ERC-4337 paymaster + const paymaster = await ethers.deployContract(`$${name}Mock`, [token, admin]); + + // Domains + const entrypointDomain = await getDomain(predeploy.entrypoint.v09); + + const signUserOp = userOp => + accountSigner + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + + const paymasterSignUserOp = userOp => + token + .totalSupply() + .then(i => token.$_mint(userOp.sender, i)) + .then(() => userOp); + + return { + admin, + receiver, + other, + target, + account, + paymaster, + signUserOp, + paymasterSignUserOp, // mint a token for the userOp sender + paymasterSignUserOpInvalid: userOp => userOp, // don't do anything + }; + } + + describe(name, function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikePaymaster(opts); + }); +} diff --git a/test/account/paymaster/PaymasterSigner.test.js b/test/account/paymaster/PaymasterSigner.test.js new file mode 100644 index 00000000000..e805900e006 --- /dev/null +++ b/test/account/paymaster/PaymasterSigner.test.js @@ -0,0 +1,86 @@ +const { ethers, predeploy } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain, PackedUserOperation, UserOperationRequest } = require('../../helpers/eip712'); +const { ERC4337Helper } = require('../../helpers/erc4337'); + +const { shouldBehaveLikePaymaster } = require('./Paymaster.behavior'); + +for (const [name, opts] of Object.entries({ + PaymasterSigner: { postOp: true, timeRange: true }, + PaymasterSignerContextNoPostOp: { postOp: false, timeRange: true }, +})) { + async function fixture() { + // EOAs and environment + const [admin, receiver, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + + // signers + const accountSigner = ethers.Wallet.createRandom(); + const paymasterSigner = ethers.Wallet.createRandom(); + + // ERC-4337 account + const helper = new ERC4337Helper(); + const account = await helper.newAccount('$AccountECDSAMock', [accountSigner, 'AccountECDSA', '1']); + await account.deploy(); + + // ERC-4337 paymaster + const paymaster = await ethers.deployContract(`$${name}Mock`, [ + 'MyPaymasterECDSASigner', + '1', + paymasterSigner, + admin, + ]); + + // Domains + const entrypointDomain = await getDomain(predeploy.entrypoint.v09); + const paymasterDomain = await getDomain(paymaster); + + const signUserOp = userOp => + accountSigner + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + + const paymasterSignUserOp = + signer => + (userOp, { validAfter = 0n, validUntil = 0n } = {}) => + signer + .signTypedData( + paymasterDomain, + { UserOperationRequest }, + { + ...userOp.packed, + paymasterVerificationGasLimit: userOp.paymasterVerificationGasLimit, + paymasterPostOpGasLimit: userOp.paymasterPostOpGasLimit, + validAfter, + validUntil, + }, + ) + .then(signature => + Object.assign(userOp, { + paymasterData: ethers.solidityPacked(['uint48', 'uint48', 'bytes'], [validAfter, validUntil, signature]), + }), + ); + + return { + helper, + admin, + receiver, + other, + target, + account, + paymaster, + signUserOp, + paymasterSignUserOp: paymasterSignUserOp(paymasterSigner), // sign using the correct key + paymasterSignUserOpInvalid: paymasterSignUserOp(other), // sign using the wrong key + }; + } + + describe(name, function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikePaymaster(opts); + }); +}