diff --git a/src/test/mocks/MockERC20CustomDecimals.sol b/src/test/mocks/MockERC20CustomDecimals.sol new file mode 100644 index 000000000..65ad6a20a --- /dev/null +++ b/src/test/mocks/MockERC20CustomDecimals.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.23; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/draft-ERC20Permit.sol"; + +contract MockERC20CustomDecimals is ERC20PresetMinterPauser, ERC20Permit { + uint8 private immutable _decimals; + + constructor(uint8 decimals_) ERC20PresetMinterPauser("Mock Coin", "MOCK") ERC20Permit("Mock Coin") { + _decimals = decimals_; + } + + function mint(address to, uint256 amount) public override(ERC20PresetMinterPauser) { + _mint(to, amount); + } + + function decimals() public view override returns (uint8) { + return _decimals; + } + + function _beforeTokenTransfer( + address from, + address to, + uint256 amount + ) internal override(ERC20PresetMinterPauser, ERC20) { + super._beforeTokenTransfer(from, to, amount); + } +} diff --git a/src/test/mocks/TestOracle2.sol b/src/test/mocks/TestOracle2.sol new file mode 100644 index 000000000..77c180c5b --- /dev/null +++ b/src/test/mocks/TestOracle2.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.23; + +// source: https://github.com/eth-infinitism/account-abstraction/blob/develop/contracts/test/TestOracle2.sol + +interface IOracle { + function decimals() external view returns (uint8); + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); +} + +contract TestOracle2 is IOracle { + int256 public price; + uint8 private _decimals_; + + constructor(int256 _price, uint8 _decimals) { + price = _price; + _decimals_ = _decimals; + } + + function setPrice(int256 _price) external { + price = _price; + } + + function setDecimals(uint8 _decimals) external { + _decimals_ = _decimals; + } + + function decimals() external view override returns (uint8) { + return _decimals_; + } + + function latestRoundData() + external + view + override + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + // solhint-disable-next-line not-rely-on-time + return (73786976294838215802, price, 1680509051, block.timestamp, 73786976294838215802); + } +} diff --git a/src/test/mocks/TestUniswap.sol b/src/test/mocks/TestUniswap.sol new file mode 100644 index 000000000..4edfef65a --- /dev/null +++ b/src/test/mocks/TestUniswap.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.0; + +// source: https://github.com/eth-infinitism/account-abstraction/blob/develop/contracts/test/TestUniswap.sol + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol"; + +import "./WETH9.sol"; + +/// @notice Very basic simulation of what Uniswap does with the swaps for the unit tests on the TokenPaymaster +/// @dev Do not use to test any actual Uniswap interaction logic as this is way too simplistic +contract TestUniswap { + WETH9 public weth; + + constructor(WETH9 _weth) { + weth = _weth; + } + + event StubUniswapExchangeEvent(uint256 amountIn, uint256 amountOut, address tokenIn, address tokenOut); + + function exactOutputSingle(ISwapRouter.ExactOutputSingleParams calldata params) external returns (uint256) { + uint256 amountIn = params.amountInMaximum - 5; + emit StubUniswapExchangeEvent(amountIn, params.amountOut, params.tokenIn, params.tokenOut); + IERC20(params.tokenIn).transferFrom(msg.sender, address(this), amountIn); + IERC20(params.tokenOut).transfer(params.recipient, params.amountOut); + return amountIn; + } + + function exactInputSingle(ISwapRouter.ExactInputSingleParams calldata params) external returns (uint256) { + uint256 amountOut = params.amountOutMinimum + 5; + emit StubUniswapExchangeEvent(params.amountIn, amountOut, params.tokenIn, params.tokenOut); + IERC20(params.tokenIn).transferFrom(msg.sender, address(this), params.amountIn); + IERC20(params.tokenOut).transfer(params.recipient, amountOut); + return amountOut; + } + + /// @notice Simplified code copied from here: + /// https://github.com/Uniswap/v3-periphery/blob/main/contracts/base/PeripheryPayments.sol#L19 + function unwrapWETH9(uint256 amountMinimum, address recipient) public payable { + uint256 balanceWETH9 = weth.balanceOf(address(this)); + require(balanceWETH9 >= amountMinimum, "Insufficient WETH9"); + + if (balanceWETH9 > 0) { + weth.withdraw(balanceWETH9); + payable(recipient).transfer(balanceWETH9); + } + } + + // solhint-disable-next-line no-empty-blocks + receive() external payable {} +} diff --git a/src/test/smart-wallet/token-paymaster/TokenPaymaster.t.sol b/src/test/smart-wallet/token-paymaster/TokenPaymaster.t.sol new file mode 100644 index 000000000..fed3bf3d5 --- /dev/null +++ b/src/test/smart-wallet/token-paymaster/TokenPaymaster.t.sol @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +// Test utils +import "../../utils/BaseTest.sol"; +import { MockERC20CustomDecimals } from "../../mocks/MockERC20CustomDecimals.sol"; +import { TestUniswap } from "../../mocks/TestUniswap.sol"; +import { TestOracle2 } from "../../mocks/TestOracle2.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +// Account Abstraction setup for smart wallets. +import { EntryPoint, IEntryPoint } from "contracts/prebuilts/account/utils/EntryPoint.sol"; +import { PackedUserOperation } from "contracts/prebuilts/account/interfaces/PackedUserOperation.sol"; + +// Target +import { IAccountPermissions } from "contracts/extension/interface/IAccountPermissions.sol"; +import { AccountFactory } from "contracts/prebuilts/account/non-upgradeable/AccountFactory.sol"; +import { Account as SimpleAccount } from "contracts/prebuilts/account/non-upgradeable/Account.sol"; +import { TokenPaymaster, IERC20Metadata } from "contracts/prebuilts/account/token-paymaster/TokenPaymaster.sol"; +import { OracleHelper, IOracle } from "contracts/prebuilts/account/utils/OracleHelper.sol"; +import { UniswapHelper, ISwapRouter } from "contracts/prebuilts/account/utils/UniswapHelper.sol"; + +/// @dev This is a dummy contract to test contract interactions with Account. +contract Number { + uint256 public num; + + function setNum(uint256 _num) public { + num = _num; + } + + function doubleNum() public { + num *= 2; + } + + function incrementNum() public { + num += 1; + } +} + +contract TokenPaymasterTest is BaseTest { + EntryPoint private entrypoint; + AccountFactory private accountFactory; + SimpleAccount private account; + MockERC20CustomDecimals private token; + TestUniswap private testUniswap; + TestOracle2 private nativeAssetOracle; + TestOracle2 private tokenOracle; + TokenPaymaster private paymaster; + + Number private numberContract; + + int256 initialPriceToken = 100000000; // USD per TOK + int256 initialPriceEther = 500000000; // USD per ETH + + uint256 priceDenominator = 10 ** 26; + uint128 minEntryPointBalance = 1e17; + + address payable private beneficiary = payable(address(0x45654)); + + uint256 private accountAdminPKey = 100; + address private accountAdmin; + + uint256 private accountSignerPKey = 200; + address private accountSigner; + + uint256 private nonSignerPKey = 300; + address private nonSigner; + + uint256 private paymasterOwnerPKey = 400; + address private paymasterOwner; + address private paymasterAddress; + + function setUp() public override { + super.setUp(); + + // Setup signers. + accountAdmin = vm.addr(accountAdminPKey); + vm.deal(accountAdmin, 100 ether); + + accountSigner = vm.addr(accountSignerPKey); + nonSigner = vm.addr(nonSignerPKey); + paymasterOwner = vm.addr(paymasterOwnerPKey); + + // Setup contracts + entrypoint = new EntryPoint(); + testUniswap = new TestUniswap(weth); + accountFactory = new AccountFactory(deployer, IEntryPoint(payable(address(entrypoint)))); + account = SimpleAccount(payable(accountFactory.createAccount(accountAdmin, bytes("")))); + token = new MockERC20CustomDecimals(6); + nativeAssetOracle = new TestOracle2(initialPriceEther, 8); + tokenOracle = new TestOracle2(initialPriceToken, 8); + numberContract = new Number(); + + weth.deposit{ value: 1 ether }(); + weth.transfer(address(testUniswap), 1 ether); + + TokenPaymaster.TokenPaymasterConfig memory tokenPaymasterConfig = TokenPaymaster.TokenPaymasterConfig({ + priceMarkup: (priceDenominator * 15) / 10, // +50% + minEntryPointBalance: minEntryPointBalance, + refundPostopCost: 40000, + priceMaxAge: 86400 + }); + + OracleHelper.OracleHelperConfig memory oracleHelperConfig = OracleHelper.OracleHelperConfig({ + cacheTimeToLive: 0, + maxOracleRoundAge: 0, + nativeOracle: IOracle(address(nativeAssetOracle)), + nativeOracleReverse: false, + priceUpdateThreshold: (priceDenominator * 12) / 100, // 20% + tokenOracle: IOracle(address(tokenOracle)), + tokenOracleReverse: false, + tokenToNativeOracle: false + }); + + UniswapHelper.UniswapHelperConfig memory uniswapHelperConfig = UniswapHelper.UniswapHelperConfig({ + minSwapAmount: 1, + slippage: 5, + uniswapPoolFee: 3 + }); + + paymaster = new TokenPaymaster( + IERC20Metadata(address(token)), + entrypoint, + weth, + ISwapRouter(address(testUniswap)), + tokenPaymasterConfig, + oracleHelperConfig, + uniswapHelperConfig, + paymasterOwner + ); + paymasterAddress = address(paymaster); + + token.mint(paymasterOwner, 10_000 ether); + vm.deal(paymasterOwner, 10_000 ether); + + vm.startPrank(paymasterOwner); + token.transfer(address(paymaster), 100); + paymaster.updateCachedPrice(true); + entrypoint.depositTo{ value: 1000 ether }(address(paymaster)); + paymaster.addStake{ value: 2 ether }(1); + vm.stopPrank(); + } + + // test utils + function _packPaymasterStaticFields( + address paymaster, + uint128 validationGasLimit, + uint128 postOpGasLimit + ) internal pure returns (bytes memory) { + return abi.encodePacked(bytes20(paymaster), bytes16(validationGasLimit), bytes16(postOpGasLimit)); + } + + function _setupUserOpWithSenderAndPaymaster( + bytes memory _initCode, + bytes memory _callDataForEntrypoint, + address _sender, + address _paymaster, + uint128 _paymasterVerificationGasLimit, + uint128 _paymasterPostOpGasLimit + ) internal returns (PackedUserOperation[] memory ops) { + uint256 nonce = entrypoint.getNonce(_sender, 0); + PackedUserOperation memory op; + + { + uint128 verificationGasLimit = 500_000; + uint128 callGasLimit = 500_000; + bytes32 packedAccountGasLimits = (bytes32(uint256(verificationGasLimit)) << 128) | + bytes32(uint256(callGasLimit)); + bytes32 packedGasLimits = (bytes32(uint256(1e9)) << 128) | bytes32(uint256(1e9)); + + // Get user op fields + op = PackedUserOperation({ + sender: _sender, + nonce: nonce, + initCode: _initCode, + callData: _callDataForEntrypoint, + accountGasLimits: packedAccountGasLimits, + preVerificationGas: 500_000, + gasFees: packedGasLimits, + paymasterAndData: _packPaymasterStaticFields( + _paymaster, + _paymasterVerificationGasLimit, + _paymasterPostOpGasLimit + ), + signature: bytes("") + }); + } + + // Sign UserOp + bytes32 opHash = EntryPoint(entrypoint).getUserOpHash(op); + bytes32 msgHash = ECDSA.toEthSignedMessageHash(opHash); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(accountAdminPKey, msgHash); + bytes memory userOpSignature = abi.encodePacked(r, s, v); + + address recoveredSigner = ECDSA.recover(msgHash, v, r, s); + address expectedSigner = vm.addr(accountAdminPKey); + assertEq(recoveredSigner, expectedSigner); + + op.signature = userOpSignature; + + // Store UserOp + ops = new PackedUserOperation[](1); + ops[0] = op; + } + + // Should be able to sponsor the UserOp while charging correct amount of ERC-20 tokens + function test_validatePaymasterUserOp_correctERC20() public { + token.mint(address(account), 1 ether); + vm.prank(address(account)); + token.approve(address(paymaster), type(uint256).max); + + PackedUserOperation[] memory ops = _setupUserOpWithSenderAndPaymaster( + bytes(""), + abi.encodeWithSignature( + "execute(address,uint256,bytes)", + address(numberContract), + 0, + abi.encodeWithSignature("setNum(uint256)", 42) + ), + address(account), + address(paymaster), + 3e5, + 3e5 + ); + + entrypoint.handleOps(ops, beneficiary); + } +}