From 356c7358886ff04e5d7f70c34d0667a41ec5daed Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 31 Jul 2026 13:20:35 +1200 Subject: [PATCH] Add quota-controlled ERC-20 fee paymaster for NODL gas payments. ApprovalBased paymaster verified by EIP-712 fee-signer signatures, with periodic ETH spend capped via QuotaControl for erc20-fee-signer. Co-authored-by: Cursor --- src/paymasters/ERC20FeePaymaster.sol | 192 +++++++++++++++ test/paymasters/ERC20FeePaymaster.t.sol | 302 ++++++++++++++++++++++++ 2 files changed, 494 insertions(+) create mode 100644 src/paymasters/ERC20FeePaymaster.sol create mode 100644 test/paymasters/ERC20FeePaymaster.t.sol diff --git a/src/paymasters/ERC20FeePaymaster.sol b/src/paymasters/ERC20FeePaymaster.sol new file mode 100644 index 00000000..3670bc14 --- /dev/null +++ b/src/paymasters/ERC20FeePaymaster.sol @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import { + IPaymaster, + PAYMASTER_VALIDATION_SUCCESS_MAGIC +} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymaster.sol"; +import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymasterFlow.sol"; +import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; + +import {QuotaControl} from "../QuotaControl.sol"; +import {BasePaymaster, BOOTLOADER_FORMAL_ADDRESS} from "./BasePaymaster.sol"; + +/// @notice zkSync paymaster that lets users pay gas in a single ERC-20 fee token (NODL). +/// @dev ApprovalBased only. Off-chain `erc20-fee-signer` prices the fee, applies markup, and +/// EIP-712-signs `(from, to, token, amount, expirationTime, maxFeePerGas, gasLimit)`. +/// Inner paymaster input encoding matches ZyFi's shape: `abi.encode(uint64 expirationTime, bytes signature)`. +/// Periodic ETH spend is capped via `QuotaControl` (quota unit = wei paid to the bootloader). +contract ERC20FeePaymaster is BasePaymaster, QuotaControl, EIP712 { + using SafeERC20 for IERC20; + + /// @dev Keccak of FeeApproval(address from,address to,address token,uint256 amount,uint64 expirationTime,uint256 maxFeePerGas,uint256 gasLimit) + bytes32 public constant FEE_APPROVAL_TYPEHASH = keccak256( + "FeeApproval(address from,address to,address token,uint256 amount,uint64 expirationTime,uint256 maxFeePerGas,uint256 gasLimit)" + ); + + string public constant SIGNING_DOMAIN = "erc20-fee-paymaster.nodle"; + string public constant SIGNATURE_VERSION = "1"; + + /// @notice Hard cap on signed approval lifetime. Bounds damage if the fee-signer key is compromised. + uint64 public constant MAX_SIGNATURE_TTL = 15 minutes; + + IERC20 public immutable feeToken; + + /// @notice Off-chain fee signer whose EIP-712 signatures this paymaster accepts. + address public feeSigner; + + event FeeSignerUpdated(address indexed previousSigner, address indexed newSigner); + event FeeTokenCollected(address indexed from, address indexed token, uint256 amount); + event TokensWithdrawn(address indexed token, address indexed to, uint256 amount); + + error ZeroAddress(); + error InvalidFeeToken(); + error ZeroFeeAmount(); + error ApprovalExpired(uint64 expirationTime); + error ApprovalTtlTooLong(uint64 expirationTime); + error AllowanceTooLow(uint256 provided, uint256 required); + error PaymasterBalanceTooLow(); + + constructor( + address admin, + address withdrawer, + address feeToken_, + address feeSigner_, + uint256 initialQuota, + uint256 initialPeriod + ) + BasePaymaster(admin, withdrawer) + QuotaControl(initialQuota, initialPeriod, admin) + EIP712(SIGNING_DOMAIN, SIGNATURE_VERSION) + { + if (feeToken_ == address(0) || feeSigner_ == address(0)) { + revert ZeroAddress(); + } + feeToken = IERC20(feeToken_); + feeSigner = feeSigner_; + emit FeeSignerUpdated(address(0), feeSigner_); + } + + /// @notice Rotate the off-chain fee signer. + function setFeeSigner(address newSigner) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newSigner == address(0)) revert ZeroAddress(); + address previous = feeSigner; + feeSigner = newSigner; + emit FeeSignerUpdated(previous, newSigner); + } + + /// @notice Withdraw collected fee tokens (or any ERC-20) from this contract. + function withdrawTokens(address token, address to, uint256 amount) external { + _checkRole(WITHDRAWER_ROLE); + if (to == address(0)) revert ZeroAddress(); + IERC20(token).safeTransfer(to, amount); + emit TokensWithdrawn(token, to, amount); + } + + /// @notice EIP-712 digest the off-chain fee signer must sign. + function hashFeeApproval( + address from, + address to, + address token, + uint256 amount, + uint64 expirationTime, + uint256 maxFeePerGas, + uint256 gasLimit + ) public view returns (bytes32) { + return _hashTypedDataV4( + keccak256( + abi.encode(FEE_APPROVAL_TYPEHASH, from, to, token, amount, expirationTime, maxFeePerGas, gasLimit) + ) + ); + } + + /// @inheritdoc IPaymaster + /// @dev Overrides `BasePaymaster` so validation can bind the signature to `maxFeePerGas` / + /// `gasLimit` individually (ZyFi-compatible fields) and return `magic = 0` on a bad + /// signature for gas estimation without reverting. + function validateAndPayForPaymasterTransaction( + bytes32, /*_txHash*/ + bytes32, /*_suggestedSignedHash*/ + Transaction calldata transaction + ) + external + payable + override + returns (bytes4 magic, bytes memory context) + { + _mustBeBootloader(); + + magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC; + context = ""; + + if (transaction.paymasterInput.length < 4) { + revert InvalidPaymasterInput("The standard paymaster input must be at least 4 bytes long"); + } + + bytes4 paymasterInputSelector = bytes4(transaction.paymasterInput[0:4]); + if (paymasterInputSelector != IPaymasterFlow.approvalBased.selector) { + revert PaymasterFlowNotSupported(); + } + + (address token, uint256 amount, bytes memory data) = + abi.decode(transaction.paymasterInput[4:], (address, uint256, bytes)); + + if (token != address(feeToken)) revert InvalidFeeToken(); + if (amount == 0) revert ZeroFeeAmount(); + + (uint64 expirationTime, bytes memory signature) = abi.decode(data, (uint64, bytes)); + + if (block.timestamp > expirationTime) revert ApprovalExpired(expirationTime); + if (expirationTime > block.timestamp + MAX_SIGNATURE_TTL) { + revert ApprovalTtlTooLong(expirationTime); + } + + address userAddress = address(uint160(transaction.from)); + address destAddress = address(uint160(transaction.to)); + + bytes32 digest = hashFeeApproval( + userAddress, destAddress, token, amount, expirationTime, transaction.maxFeePerGas, transaction.gasLimit + ); + + // Invalid signature → magic = 0 so eth_estimateGas still explores this path on mainnet, + // while a real submission with a bad signature is rejected by the bootloader. + if (!SignatureChecker.isValidSignatureNow(feeSigner, digest, signature)) { + magic = bytes4(0); + } + + uint256 requiredETH = transaction.gasLimit * transaction.maxFeePerGas; + + uint256 providedAllowance = feeToken.allowance(userAddress, address(this)); + if (providedAllowance < amount) revert AllowanceTooLow(providedAllowance, amount); + + if (address(this).balance < requiredETH) revert PaymasterBalanceTooLow(); + + _checkedResetClaimed(); + _checkedUpdateClaimed(requiredETH); + + feeToken.safeTransferFrom(userAddress, address(this), amount); + emit FeeTokenCollected(userAddress, token, amount); + + (bool success,) = payable(BOOTLOADER_FORMAL_ADDRESS).call{value: requiredETH}(""); + if (!success) revert NotEnoughETHInPaymasterToPayForTransaction(); + } + + function _validateAndPayGeneralFlow(address, address, uint256, bytes memory) internal pure override { + revert PaymasterFlowNotSupported(); + } + + function _validateAndPayApprovalBasedFlow(address, address, address, uint256, bytes memory, uint256) + internal + pure + override + { + // ApprovalBased validation is implemented in `validateAndPayForPaymasterTransaction` + // so the signature can bind `maxFeePerGas` and `gasLimit`. This hook must not be reached. + revert PaymasterFlowNotSupported(); + } +} diff --git a/test/paymasters/ERC20FeePaymaster.t.sol b/test/paymasters/ERC20FeePaymaster.t.sol new file mode 100644 index 00000000..919582f2 --- /dev/null +++ b/test/paymasters/ERC20FeePaymaster.t.sol @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymasterFlow.sol"; +import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; +import {PAYMASTER_VALIDATION_SUCCESS_MAGIC} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymaster.sol"; + +import {AccessControlUtils} from "../__helpers__/AccessControlUtils.sol"; +import {QuotaControl} from "../../src/QuotaControl.sol"; +import {BasePaymaster, BOOTLOADER_FORMAL_ADDRESS} from "../../src/paymasters/BasePaymaster.sol"; +import {ERC20FeePaymaster} from "../../src/paymasters/ERC20FeePaymaster.sol"; + +contract MockFeeToken is ERC20 { + constructor() ERC20("Mock NODL", "mNODL") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract ERC20FeePaymasterTest is Test { + using AccessControlUtils for Vm; + + ERC20FeePaymaster private paymaster; + MockFeeToken private feeToken; + + address internal admin = address(0x1111); + address internal withdrawer = address(0x2222); + uint256 internal signerKey = 0xA11CE; + address internal feeSigner; + address internal user = address(0xA); + address internal dest = address(0xB); + address internal attacker = address(0xB33F); + + uint256 internal constant QUOTA = 10 ether; + uint256 internal constant PERIOD = 1 days; + uint256 internal constant GAS_LIMIT = 100_000; + uint256 internal constant MAX_FEE = 1 gwei; + uint256 internal constant FEE_AMOUNT = 5 ether; + + function setUp() public { + feeSigner = vm.addr(signerKey); + feeToken = new MockFeeToken(); + paymaster = new ERC20FeePaymaster(admin, withdrawer, address(feeToken), feeSigner, QUOTA, PERIOD); + vm.deal(address(paymaster), 100 ether); + + feeToken.mint(user, 1_000 ether); + vm.prank(user); + feeToken.approve(address(paymaster), type(uint256).max); + } + + function _buildApprovalInput(uint256 amount, uint64 expirationTime, bytes memory signature) + internal + view + returns (bytes memory) + { + bytes memory inner = abi.encode(expirationTime, signature); + return abi.encodeWithSelector(IPaymasterFlow.approvalBased.selector, address(feeToken), amount, inner); + } + + function _buildTransaction(address from, address to, uint256 gasLimit, uint256 maxFeePerGas, bytes memory pmInput) + internal + pure + returns (Transaction memory txn) + { + txn.from = uint256(uint160(from)); + txn.to = uint256(uint160(to)); + txn.gasLimit = gasLimit; + txn.maxFeePerGas = maxFeePerGas; + txn.paymasterInput = pmInput; + } + + function _signFeeApproval( + address from, + address to, + address token, + uint256 amount, + uint64 expirationTime, + uint256 maxFeePerGas, + uint256 gasLimit + ) internal view returns (bytes memory) { + bytes32 digest = paymaster.hashFeeApproval(from, to, token, amount, expirationTime, maxFeePerGas, gasLimit); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, digest); + return abi.encodePacked(r, s, v); + } + + function _validExpiration() internal view returns (uint64) { + return uint64(block.timestamp + 5 minutes); + } + + function test_defaultACLsAndImmutables() public view { + assertTrue(paymaster.hasRole(paymaster.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(paymaster.hasRole(paymaster.WITHDRAWER_ROLE(), withdrawer)); + assertEq(address(paymaster.feeToken()), address(feeToken)); + assertEq(paymaster.feeSigner(), feeSigner); + assertEq(paymaster.quota(), QUOTA); + assertEq(paymaster.period(), PERIOD); + } + + function test_setFeeSigner() public { + address newSigner = address(0xC0FFEE); + vm.prank(admin); + vm.expectEmit(true, true, false, true); + emit ERC20FeePaymaster.FeeSignerUpdated(feeSigner, newSigner); + paymaster.setFeeSigner(newSigner); + assertEq(paymaster.feeSigner(), newSigner); + } + + function test_RevertIf_setFeeSignerUnauthorized() public { + vm.expectRevert_AccessControlUnauthorizedAccount(attacker, paymaster.DEFAULT_ADMIN_ROLE()); + vm.prank(attacker); + paymaster.setFeeSigner(attacker); + } + + function test_RevertIf_setFeeSignerZero() public { + vm.prank(admin); + vm.expectRevert(ERC20FeePaymaster.ZeroAddress.selector); + paymaster.setFeeSigner(address(0)); + } + + function test_validateAndPay_success() public { + uint64 expiration = _validExpiration(); + bytes memory signature = + _signFeeApproval(user, dest, address(feeToken), FEE_AMOUNT, expiration, MAX_FEE, GAS_LIMIT); + bytes memory pmInput = _buildApprovalInput(FEE_AMOUNT, expiration, signature); + Transaction memory txn = _buildTransaction(user, dest, GAS_LIMIT, MAX_FEE, pmInput); + + uint256 requiredETH = GAS_LIMIT * MAX_FEE; + uint256 bootloaderBefore = BOOTLOADER_FORMAL_ADDRESS.balance; + uint256 userTokenBefore = feeToken.balanceOf(user); + uint256 paymasterTokenBefore = feeToken.balanceOf(address(paymaster)); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + (bytes4 magic,) = paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + + assertEq(magic, PAYMASTER_VALIDATION_SUCCESS_MAGIC); + assertEq(BOOTLOADER_FORMAL_ADDRESS.balance, bootloaderBefore + requiredETH); + assertEq(feeToken.balanceOf(user), userTokenBefore - FEE_AMOUNT); + assertEq(feeToken.balanceOf(address(paymaster)), paymasterTokenBefore + FEE_AMOUNT); + assertEq(paymaster.claimed(), requiredETH); + } + + function test_validateAndPay_invalidSignatureReturnsZeroMagic() public { + uint64 expiration = _validExpiration(); + // Sign with a different key + bytes32 digest = + paymaster.hashFeeApproval(user, dest, address(feeToken), FEE_AMOUNT, expiration, MAX_FEE, GAS_LIMIT); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(0xDEAD, digest); + bytes memory badSig = abi.encodePacked(r, s, v); + + bytes memory pmInput = _buildApprovalInput(FEE_AMOUNT, expiration, badSig); + Transaction memory txn = _buildTransaction(user, dest, GAS_LIMIT, MAX_FEE, pmInput); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + (bytes4 magic,) = paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + + assertEq(magic, bytes4(0)); + // Economic path still runs so eth_estimateGas observes realistic costs. + assertEq(paymaster.claimed(), GAS_LIMIT * MAX_FEE); + } + + function test_RevertIf_wrongFeeToken() public { + MockFeeToken other = new MockFeeToken(); + uint64 expiration = _validExpiration(); + bytes memory signature = + _signFeeApproval(user, dest, address(other), FEE_AMOUNT, expiration, MAX_FEE, GAS_LIMIT); + bytes memory inner = abi.encode(expiration, signature); + bytes memory pmInput = + abi.encodeWithSelector(IPaymasterFlow.approvalBased.selector, address(other), FEE_AMOUNT, inner); + Transaction memory txn = _buildTransaction(user, dest, GAS_LIMIT, MAX_FEE, pmInput); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(ERC20FeePaymaster.InvalidFeeToken.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_RevertIf_zeroFeeAmount() public { + uint64 expiration = _validExpiration(); + bytes memory signature = _signFeeApproval(user, dest, address(feeToken), 0, expiration, MAX_FEE, GAS_LIMIT); + bytes memory pmInput = _buildApprovalInput(0, expiration, signature); + Transaction memory txn = _buildTransaction(user, dest, GAS_LIMIT, MAX_FEE, pmInput); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(ERC20FeePaymaster.ZeroFeeAmount.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_RevertIf_approvalExpired() public { + uint64 expiration = uint64(block.timestamp - 1); + bytes memory signature = + _signFeeApproval(user, dest, address(feeToken), FEE_AMOUNT, expiration, MAX_FEE, GAS_LIMIT); + bytes memory pmInput = _buildApprovalInput(FEE_AMOUNT, expiration, signature); + Transaction memory txn = _buildTransaction(user, dest, GAS_LIMIT, MAX_FEE, pmInput); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(abi.encodeWithSelector(ERC20FeePaymaster.ApprovalExpired.selector, expiration)); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_RevertIf_approvalTtlTooLong() public { + uint64 expiration = uint64(block.timestamp + paymaster.MAX_SIGNATURE_TTL() + 1); + bytes memory signature = + _signFeeApproval(user, dest, address(feeToken), FEE_AMOUNT, expiration, MAX_FEE, GAS_LIMIT); + bytes memory pmInput = _buildApprovalInput(FEE_AMOUNT, expiration, signature); + Transaction memory txn = _buildTransaction(user, dest, GAS_LIMIT, MAX_FEE, pmInput); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(abi.encodeWithSelector(ERC20FeePaymaster.ApprovalTtlTooLong.selector, expiration)); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_RevertIf_allowanceTooLow() public { + vm.prank(user); + feeToken.approve(address(paymaster), FEE_AMOUNT - 1); + + uint64 expiration = _validExpiration(); + bytes memory signature = + _signFeeApproval(user, dest, address(feeToken), FEE_AMOUNT, expiration, MAX_FEE, GAS_LIMIT); + bytes memory pmInput = _buildApprovalInput(FEE_AMOUNT, expiration, signature); + Transaction memory txn = _buildTransaction(user, dest, GAS_LIMIT, MAX_FEE, pmInput); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(abi.encodeWithSelector(ERC20FeePaymaster.AllowanceTooLow.selector, FEE_AMOUNT - 1, FEE_AMOUNT)); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_RevertIf_quotaExceeded() public { + // Drain almost all quota with one large gas payment + uint256 gasLimit = QUOTA / MAX_FEE; + uint256 feeAmount = 1 ether; + uint64 expiration = _validExpiration(); + bytes memory signature = + _signFeeApproval(user, dest, address(feeToken), feeAmount, expiration, MAX_FEE, gasLimit); + bytes memory pmInput = _buildApprovalInput(feeAmount, expiration, signature); + Transaction memory txn = _buildTransaction(user, dest, gasLimit, MAX_FEE, pmInput); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + assertEq(paymaster.claimed(), QUOTA); + + // Any additional requiredETH exceeds quota + uint256 smallGas = 1; + signature = _signFeeApproval(user, dest, address(feeToken), feeAmount, expiration, MAX_FEE, smallGas); + pmInput = _buildApprovalInput(feeAmount, expiration, signature); + txn = _buildTransaction(user, dest, smallGas, MAX_FEE, pmInput); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(QuotaControl.QuotaExceeded.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_RevertIf_generalFlow() public { + bytes memory pmInput = abi.encodeWithSelector(IPaymasterFlow.general.selector, ""); + Transaction memory txn = _buildTransaction(user, dest, GAS_LIMIT, MAX_FEE, pmInput); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(BasePaymaster.PaymasterFlowNotSupported.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_RevertIf_notBootloader() public { + uint64 expiration = _validExpiration(); + bytes memory signature = + _signFeeApproval(user, dest, address(feeToken), FEE_AMOUNT, expiration, MAX_FEE, GAS_LIMIT); + bytes memory pmInput = _buildApprovalInput(FEE_AMOUNT, expiration, signature); + Transaction memory txn = _buildTransaction(user, dest, GAS_LIMIT, MAX_FEE, pmInput); + + vm.prank(attacker); + vm.expectRevert(BasePaymaster.AccessRestrictedToBootloader.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_withdrawTokens() public { + feeToken.mint(address(paymaster), 10 ether); + vm.prank(withdrawer); + vm.expectEmit(true, true, false, true); + emit ERC20FeePaymaster.TokensWithdrawn(address(feeToken), withdrawer, 3 ether); + paymaster.withdrawTokens(address(feeToken), withdrawer, 3 ether); + assertEq(feeToken.balanceOf(withdrawer), 3 ether); + } + + function test_RevertIf_withdrawTokensUnauthorized() public { + feeToken.mint(address(paymaster), 1 ether); + vm.expectRevert_AccessControlUnauthorizedAccount(attacker, paymaster.WITHDRAWER_ROLE()); + vm.prank(attacker); + paymaster.withdrawTokens(address(feeToken), attacker, 1 ether); + } + + function test_RevertIf_constructorZeroFeeToken() public { + vm.expectRevert(ERC20FeePaymaster.ZeroAddress.selector); + new ERC20FeePaymaster(admin, withdrawer, address(0), feeSigner, QUOTA, PERIOD); + } + + function test_RevertIf_constructorZeroFeeSigner() public { + vm.expectRevert(ERC20FeePaymaster.ZeroAddress.selector); + new ERC20FeePaymaster(admin, withdrawer, address(feeToken), address(0), QUOTA, PERIOD); + } +}