├── .prettierignore ├── .prettierrc ├── .gitignore ├── slither.config.json ├── .env.example ├── remappings.txt ├── foundry.toml ├── script ├── Errors.sol ├── BundlerMock.s.sol ├── ERC20Mock.s.sol ├── BatchedWalletFactory.s.sol ├── BatchedWallet.s.sol ├── SponsorshipPaymaster.s.sol └── SignUserOpUtil.s.sol ├── .solhint.json ├── .gitmodules ├── contracts ├── helper │ └── Errors.sol ├── mock │ └── BundlerMock.sol ├── interface │ └── IBatchedWallet.sol ├── BatchedWalletFactory.sol ├── SponsorshipPaymaster.sol └── BatchedWallet.sol ├── package.json ├── LICENSE ├── Makefile ├── test ├── helpers │ └── TestHelpers.sol ├── batchedwallet │ ├── Bundler.sol │ ├── BatchedWalletIsValidateSingature.t.sol │ ├── BatchedWalletReceiveEther.t.sol │ ├── BatchedWallet.t.sol │ ├── BatchedWalletDeploy.t.sol │ └── BatchedWalletExecution.t.sol └── paymaster │ └── BatchedWalletWithSponsorshipPaymaster.t.sol └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | coverage 3 | out 4 | lib 5 | assets 6 | node_modules 7 | .next -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": false, 5 | "singleQuote": false 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cache/ 2 | out/ 3 | .gas-snapshot 4 | .vscode/ 5 | .env 6 | .encryptedKey 7 | broadcast/ 8 | 9 | node_modules 10 | package-lock.json 11 | 12 | .DS_Store 13 | src/.DS_Store 14 | -------------------------------------------------------------------------------- /slither.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "filter_paths": "lib", 3 | "solc_remaps": [ 4 | "ds-test/=lib/ds-test/src/", 5 | "forge-std/=lib/forge-std/src/", 6 | "@chainlink/=lib/chainlink-brownie-contracts/" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Deploy Properties 2 | PRIVATE_KEY=0xf0479bac32cc7bd127ab198a6df04885af9e9800ffd5fca800fadac10777294a 3 | SEPOLIA_RPC_URL=https://rpc.sepolia.org 4 | SEPOLIA_VERIFIER_URL=https://api-sepolia.etherscan.io/api 5 | ETHERSCAN_API_KEY== 6 | -------------------------------------------------------------------------------- /remappings.txt: -------------------------------------------------------------------------------- 1 | @source/=contracts/ 2 | @testing/=test/ 3 | @std=lib/forge-std/src/ 4 | @forge-std/=lib/forge-std/src/ 5 | @openzeppelin/=lib/openzeppelin-contracts/ 6 | @openzeppelin-upgradeable/=lib/openzeppelin-contracts-upgradeable/ 7 | @account-abstraction/=lib/account-abstraction/contracts/ 8 | @soulwallet/=lib/soul-wallet-contract/ 9 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = 'src' 3 | out = 'out' 4 | libs = ['lib'] 5 | gas_reports = ["*"] 6 | optimizer = true 7 | optimizer_runs = 20000 8 | 9 | solc_version='0.8.20' 10 | 11 | [rpc_endpoints] 12 | sepolia = "https://rpc.sepolia.org" 13 | 14 | [etherscan] 15 | sepolia = { key = "${ETHERSCAN_API_KEY}", chain = "sepolia", url = "https://api-sepolia.etherscan.io/api" } 16 | 17 | # Remappings in remappings.txt 18 | 19 | # See more config options https://github.com/gakonst/foundry/tree/master/config -------------------------------------------------------------------------------- /script/Errors.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | /** 5 | * @title Errors 6 | * @dev A library of the errors used for the scripts 7 | */ 8 | library Errors { 9 | error DEPLOY_BATCHED_WALLET_NO_OWNER_DEFINED(); 10 | error DEPLOY_BATCHED_WALLET_NO_FACTORY_ADDRESS_DEFINED(); 11 | error DEPLOY_SPONSORSHIP_PAYMASTER_PRIVATE_KEY_NOT_DEFINED(); 12 | error DEPLOY_SPONSORSHIP_PAYMASTER_NO_OWNER_DEFINED(); 13 | error DEPLOY_SPONSORSHIP_PAYMASTER_NO_FACTORY_ADDRESS_DEFINED(); 14 | error DEPLOY_SPONSORSHIP_PAYMASTER_FACTORY_CONTRACT_NOT_DEPLOYED(); 15 | } 16 | -------------------------------------------------------------------------------- /.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solhint:recommended", 3 | "rules": { 4 | "code-complexity": ["error", 8], 5 | "compiler-version": ["error", ">=0.5.8"], 6 | "const-name-snakecase": "off", 7 | "constructor-syntax": "error", 8 | "func-visibility": ["error", { "ignoreConstructors": true }], 9 | "max-line-length": ["error", 120], 10 | "not-rely-on-time": "off", 11 | "prettier/prettier": [ 12 | "error", 13 | { 14 | "endOfLine": "auto" 15 | } 16 | ], 17 | "reason-string": ["warn", { "maxLength": 64 }] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/openzeppelin-contracts-upgradeable"] 5 | path = lib/openzeppelin-contracts-upgradeable 6 | url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable 7 | [submodule "lib/openzeppelin-contracts"] 8 | path = lib/openzeppelin-contracts 9 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 10 | [submodule "lib/soul-wallet-contract"] 11 | path = lib/soul-wallet-contract 12 | url = https://github.com/SoulWallet/soul-wallet-contract 13 | [submodule "lib/account-abstraction"] 14 | path = lib/account-abstraction 15 | url = https://github.com/SoulWallet/account-abstraction 16 | -------------------------------------------------------------------------------- /script/BundlerMock.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Script} from "@forge-std/Script.sol"; 5 | import {BundlerMock} from "@source/mock/BundlerMock.sol"; 6 | 7 | /** 8 | * @title DeployBundlerMock 9 | * @notice The contract deploys a BundlerMock contract 10 | * @dev Note that in order to run this script the following environment variables 11 | * must be set: 12 | * PRIVATE_KEY: the private key used for deployment 13 | */ 14 | contract DeployBundlerMock is Script { 15 | 16 | function run() external { 17 | uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); 18 | vm.startBroadcast(deployerPrivateKey); 19 | new BundlerMock(); 20 | vm.stopBroadcast(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /script/ERC20Mock.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Script} from "@forge-std/Script.sol"; 5 | import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; 6 | 7 | /** 8 | * @title DeployERC20Mock 9 | * @notice The contract deploys a ERC20Mock contract 10 | * @dev Note that in order to run this script the following environment variables 11 | * must be set: 12 | * PRIVATE_KEY: the private key used for deployment 13 | */ 14 | contract DeployERC20Mock is Script { 15 | 16 | function run() external { 17 | uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); 18 | vm.startBroadcast(deployerPrivateKey); 19 | new ERC20Mock(); 20 | vm.stopBroadcast(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /contracts/helper/Errors.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | /** 5 | * @title Errors 6 | * @dev A library of all of the errors used throughout the BatchedWallet protocol. 7 | */ 8 | library Errors { 9 | error NON_ENTRY_POINT_CALLER(); 10 | error BATCH_EXECUTE_ARRAY_LENGTH_INVALID(); 11 | error SIGNATURE_LENGTH_LESS_THAN_65(); 12 | error SIGNATURE_NOT_SIGNED_BY_CONTRACT_OWNER(); 13 | error HASH_FOR_SIGNATURE_INVALID(); 14 | error EIP1271_VALIDATION_CALL_FAILED(); 15 | error PAYMASTER_ENTRY_POINT_ADDRESS_INVALID(); 16 | error PAYMASTER_GAS_TO_LOW_FOR_POSTOP(); 17 | error PAYMASTER_REQUIRED_PREFUND_TOO_LARGE(); 18 | error PAYMASTER_AND_DATA_LENGTH_INVALID(); 19 | error PAYMASTER_UNKNOWN_WALLET_FACTORY(); 20 | // Mock errors 21 | error MOCK_BUNDLER_SIMULATE_VALIDATION_FAILED(); 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "foundry-starter-kit", 3 | "version": "1.0.0", 4 | "description": "Minimal Template for Foundry Projects", 5 | "author": "Patrick Collins", 6 | "license": "MIT", 7 | "files": [ 8 | "src/**/*.sol" 9 | ], 10 | "devDependencies": { 11 | "prettier": "^2.7.1", 12 | "prettier-plugin-solidity": "^1.0.0-beta.19" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/smartcontractkit/foundry-starter-kit.git" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/smartcontractkit/foundry-starter-kit/issues" 20 | }, 21 | "scripts": { 22 | "setup": "make clean && make build", 23 | "sync": "make update", 24 | "test": "make test", 25 | "snapshot": "make snapshot", 26 | "format": "make format", 27 | "lint": "make lint", 28 | "all": "make all" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /script/BatchedWalletFactory.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Script} from "@forge-std/Script.sol"; 5 | import {BatchedWalletFactory} from "@source/BatchedWalletFactory.sol"; 6 | 7 | /** 8 | * @title DeployBatchedWalletFactory 9 | * @notice The contract deploys a BatchedWalletFactory connected to the Sepolia entry point 10 | * @dev Since the Sepolia entry point address is hardcoded, this script should only be ran 11 | * when connected to the Sepolia testnet. Note that in order to run this script the following 12 | * environment variables must be set: 13 | * PRIVATE_KEY: the private key used for deployment 14 | */ 15 | contract DeployBatchedWalletFactory is Script { 16 | // Address of the EntryPoint contract on Sepolia 17 | address private constant ENTRYPOINT = 0x0576a174D229E3cFA37253523E645A78A0C91B57; 18 | 19 | function run() external { 20 | uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); 21 | vm.startBroadcast(deployerPrivateKey); 22 | new BatchedWalletFactory(ENTRYPOINT); 23 | vm.stopBroadcast(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 2018 SmartContract ChainLink, Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /script/BatchedWallet.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Script} from "@forge-std/Script.sol"; 5 | import {BatchedWalletFactory} from "@source/BatchedWalletFactory.sol"; 6 | import {Errors} from "./Errors.sol"; 7 | 8 | /** 9 | * @title DeployBatchedWallet 10 | * @notice The contract deploys a BatchedWallet via an existing BatchedWalletFactory 11 | * @dev Note that in order to run this script the following environment variables 12 | * must be set: 13 | * PRIVATE_KEY: the private key used for deployment 14 | * OWNER_ADDRESS: the address to pass as the initial owner of the new BatchedWallet 15 | * FACTORY_ADDRESS: the address of the BatchedWalletFactory that is used to deploy the BatchedWallet 16 | * SALT: a bytes32 salt hash that is used to generate the new BatchedWallet address 17 | */ 18 | contract DeployBatchedWallet is Script { 19 | function run() external { 20 | uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); 21 | address owner = vm.envAddress("OWNER_ADDRESS"); 22 | if (owner == address(0)) { 23 | revert Errors.DEPLOY_BATCHED_WALLET_NO_OWNER_DEFINED(); 24 | } 25 | address bwFactoryAddr = vm.envAddress("FACTORY_ADDRESS"); 26 | if (bwFactoryAddr == address(0)) { 27 | revert Errors.DEPLOY_BATCHED_WALLET_NO_FACTORY_ADDRESS_DEFINED(); 28 | } 29 | bytes32 salt = vm.envBytes32("SALT"); 30 | vm.startBroadcast(deployerPrivateKey); 31 | 32 | BatchedWalletFactory(bwFactoryAddr).createWallet(owner, salt); 33 | 34 | vm.stopBroadcast(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | -include .env 2 | 3 | .PHONY: all test clean deploy-anvil 4 | 5 | all: clean remove install update build 6 | 7 | # Clean the repo 8 | clean :; forge clean 9 | 10 | # Remove modules 11 | remove :; rm -rf .gitmodules && rm -rf .git/modules/* && rm -rf lib && touch .gitmodules && git add . && git commit -m "modules" 12 | 13 | install :; forge install foundry-rs/forge-std && forge install OpenZeppelin/openzeppelin-contracts-upgradeable && forge install OpenZeppelin/openzeppelin-contracts && forge install SoulWallet/soul-wallet-contract && forge install SoulWallet/account-abstraction@v0.6.0-with-openzeppelin-v5 14 | 15 | # Update Dependencies 16 | update:; forge update 17 | 18 | build:; forge build 19 | 20 | test :; forge test 21 | 22 | snapshot :; forge snapshot 23 | 24 | slither :; slither ./contracts 25 | 26 | format :; prettier --write src/**/*.sol && prettier --write src/*.sol 27 | 28 | # solhint should be installed globally 29 | lint :; solhint contracts/**/*.sol contracts/*.sol script/*.sol test/**/*.sol test/*.sol 30 | 31 | anvil :; anvil -m 'test test test test test test test test test test test junk' 32 | 33 | # use the "@" to hide the command from your shell 34 | deploy-sepolia :; @forge script script/${contract}.s.sol:Deploy${contract} --fork-url ${SEPOLIA_RPC_URL} --ffi --private-key ${PRIVATE_KEY} --broadcast --verify --etherscan-api-key ${ETHERSCAN_API_KEY} --verifier-url ${SEPOLIA_VERIFIER_URL} -vvvv 35 | 36 | sign-user-op :; @forge script script/SignUserOpUtil.s.sol:SignUserOpUtil --fork-url ${SEPOLIA_RPC_URL} --ffi --private-key ${PRIVATE_KEY} -vvvv 37 | 38 | # This is the private key of account from the mnemonic from the "make anvil" command 39 | deploy-anvil :; @forge script script/${contract}.s.sol:Deploy${contract} --rpc-url http://localhost:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 --broadcast 40 | 41 | deploy-all :; make deploy-${network} contract=APIConsumer && make deploy-${network} contract=KeepersCounter && make deploy-${network} contract=PriceFeedConsumer && make deploy-${network} contract=VRFConsumerV2 42 | 43 | -include ${FCT_PLUGIN_PATH}/makefile-external 44 | -------------------------------------------------------------------------------- /script/SponsorshipPaymaster.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Script} from "@forge-std/Script.sol"; 5 | import {SponsorshipPaymaster} from "@source/SponsorshipPaymaster.sol"; 6 | import {Errors} from "./Errors.sol"; 7 | 8 | /** 9 | * @title DeploySponsorshipPaymaster 10 | * @notice The contract deploys a SponsorshipPaymaster connected to the Sepolia entry point 11 | * @dev Since the Sepolia entry point address is hardcoded, this script should only be ran 12 | * when connected to the Sepolia testnet. Note that in order to run this script the following 13 | * environment variables must be set: 14 | * PRIVATE_KEY: the private key used for deployment 15 | * OWNER_ADDRESS: the address to pass as the initial owner of the new SponsorshipPaymaster 16 | * WALLET_FACTORY_ADDRESS: the address of the BatchedWalletFactory who's BatchedWallets should 17 | * have the costs of their fees sponsored by this paymaster 18 | */ 19 | contract DeploySponsorshipPaymaster is Script { 20 | // Address of the EntryPoint contract on Sepolia 21 | address private constant ENTRYPOINT = 0x0576a174D229E3cFA37253523E645A78A0C91B57; 22 | 23 | function run() external { 24 | uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); 25 | if (deployerPrivateKey == 0) { 26 | revert Errors.DEPLOY_SPONSORSHIP_PAYMASTER_PRIVATE_KEY_NOT_DEFINED(); 27 | } 28 | address owner = vm.envAddress("OWNER_ADDRESS"); 29 | if (owner == address(0)) { 30 | revert Errors.DEPLOY_SPONSORSHIP_PAYMASTER_NO_OWNER_DEFINED(); 31 | } 32 | address walletFactory = vm.envAddress("FACTORY_ADDRESS"); 33 | if (walletFactory == address(0)) { 34 | revert Errors.DEPLOY_SPONSORSHIP_PAYMASTER_NO_FACTORY_ADDRESS_DEFINED(); 35 | } 36 | if (walletFactory.code.length == 0) { 37 | revert Errors.DEPLOY_SPONSORSHIP_PAYMASTER_FACTORY_CONTRACT_NOT_DEPLOYED(); 38 | } 39 | vm.startBroadcast(deployerPrivateKey); 40 | new SponsorshipPaymaster(ENTRYPOINT, owner, walletFactory); 41 | vm.stopBroadcast(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /contracts/mock/BundlerMock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | // import "@forge-std/Test.sol"; 5 | import {IEntryPoint} from "@account-abstraction/interfaces/IEntryPoint.sol"; 6 | import {UserOperation} from "@account-abstraction/interfaces/UserOperation.sol"; 7 | import {DecodeCalldata} from "@soulwallet/contracts/libraries/DecodeCalldata.sol"; 8 | import {Errors} from "@source/helper/Errors.sol"; 9 | 10 | /** 11 | * @title BundlerMock 12 | * @notice A simple Bundler mock contract for testing 13 | * @dev Obviously in a production environment the bundler would collect 14 | * UserOperations from an alternate mempool. Note also that the beneficiary 15 | * address is hardcoded in for testing purposes. 16 | */ 17 | contract BundlerMock { 18 | using DecodeCalldata for bytes; 19 | 20 | function post(IEntryPoint entryPoint, UserOperation calldata userOp) external { 21 | { 22 | // solhint-disable-next-line avoid-low-level-calls 23 | (bool success, bytes memory data) = address(entryPoint).call( // Note that staticcall cannot be used 24 | abi.encodeWithSignature( 25 | // solhint-disable-next-line max-line-length 26 | "simulateValidation((address,uint256,bytes,bytes,uint256,uint256,uint256,uint256,uint256,bytes,bytes))", 27 | userOp 28 | ) 29 | ); 30 | 31 | if (!success) { 32 | bytes4 methodId = data.decodeMethodId(); 33 | // solhint-disable-next-line no-empty-blocks 34 | if (methodId == IEntryPoint.ValidationResult.selector) { 35 | // Success case 36 | } else { 37 | // solhint-disable-next-line no-inline-assembly 38 | assembly { 39 | revert(add(data, 0x20), mload(data)) 40 | } 41 | } 42 | } else { 43 | revert Errors.MOCK_BUNDLER_SIMULATE_VALIDATION_FAILED(); 44 | } 45 | } 46 | 47 | UserOperation[] memory userOperations = new UserOperation[](1); 48 | userOperations[0] = userOp; 49 | address payable beneficiary = payable(address(0x111)); 50 | entryPoint.handleOps(userOperations, beneficiary); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /contracts/interface/IBatchedWallet.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {IEntryPoint} from "@account-abstraction/interfaces/IEntryPoint.sol"; 5 | import {IAccount} from "@account-abstraction/interfaces/IAccount.sol"; 6 | import {IERC721Receiver} from "@openzeppelin/contracts/interfaces/IERC721Receiver.sol"; 7 | import {IERC1155Receiver} from "@openzeppelin/contracts/interfaces/IERC1155Receiver.sol"; 8 | import {IERC1822Proxiable} from "@openzeppelin/contracts/interfaces/draft-IERC1822.sol"; 9 | import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; 10 | 11 | /** 12 | * @title IBatchedWallet 13 | * @dev Inherits functionality from IAccount, IERC721Receiver, IERC1155Receiver, IERC1822Proxiable and IERC1271 14 | */ 15 | interface IBatchedWallet is 16 | IAccount, 17 | IERC721Receiver, 18 | IERC1155Receiver, 19 | IERC1822Proxiable, 20 | IERC1271 21 | { 22 | /** 23 | * @dev Emitted when the BatchedWallet is initialized 24 | */ 25 | event BatchedWalletInitialized(IEntryPoint indexed entryPoint, address owners); 26 | 27 | /** 28 | * @dev Emitted when wallet signs a message 29 | */ 30 | event BatchedWalletMessageSigned(bytes32 hash); 31 | 32 | /** 33 | * @notice Marks a message (`hash`) as signed. 34 | * @dev Verification via the EIP-1271 validation method is possible by passing the pre-image of the 35 | * message hash and empty bytes as the signature. 36 | * @param hash Hash of the data to be marked as signed on the behalf of this BatchedWallet 37 | */ 38 | function signMessage(bytes32 hash) external; 39 | 40 | /** 41 | * @notice Executes an operation received from the entry point. 42 | * @param dest The destination address for this execution 43 | * @param value The value (Ether) to be included with this execution 44 | * @param data The encoded call-data for this execution 45 | */ 46 | function execute(address dest, uint256 value, bytes calldata data) external; 47 | 48 | /** 49 | * @notice Executes a batch of operations (without ETH value) received from the entry point. 50 | * @param dest The array of destination addresses for each execution 51 | * @param data The array of encoded call-data bytes for each execution 52 | */ 53 | function executeBatch(address[] calldata dest, bytes[] calldata data) external; 54 | 55 | /** 56 | * @notice Executes a batch of operations (with ETH value) received from the entry point. 57 | * @param dest The array of destination addresses for each execution 58 | * @param value The array of value (ETH amounts) to be included with each execution 59 | * @param data The array of encoded call-data bytes for each execution 60 | */ 61 | function executeBatch(address[] calldata dest, uint256[] calldata value, bytes[] calldata data) external; 62 | } 63 | -------------------------------------------------------------------------------- /contracts/BatchedWalletFactory.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; 5 | import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; 6 | import {BatchedWallet} from "@source/BatchedWallet.sol"; 7 | 8 | /** 9 | * @title BatchedWalletFactory 10 | * @notice Manages the creation / deployment and address generation of BatchedWallet contracts 11 | * @dev Complies with the ERC-1967 standard for the proxy storage location 12 | */ 13 | contract BatchedWalletFactory{ 14 | BatchedWallet public immutable BATCHED_WALLET_IMPLEMENTATION; 15 | 16 | /** 17 | * @dev Constructs the BatchedWalletFactory contract 18 | * @param entryPoint The address of the entryPoint to be associated with this BatchedWalletFactory 19 | */ 20 | constructor(address entryPoint) { 21 | BATCHED_WALLET_IMPLEMENTATION = new BatchedWallet(entryPoint); 22 | } 23 | 24 | /** 25 | * @dev Emitted when a BatchedWallet is created 26 | */ 27 | event BatchedWalletCreation(address indexed proxy); 28 | 29 | /** 30 | * @notice Creates a BatchedWallet and returns it 31 | * @dev The address is returned even if the wallet is deployed already, this is so that the 32 | * entryPoint.getSenderAddress() function would work even after the wallet has been created. 33 | * @param owner The address that should be the initial owner of the new BatchedWallet 34 | * @param salt A bytes32 salt hash used to generate the new (or existing) wallet address 35 | * @return bw The newly created BatchWallet (or the existing one if it already exists) 36 | */ 37 | function createWallet(address owner, bytes32 salt) public returns (BatchedWallet bw) { 38 | address walletAddress = getAddress(owner, salt); 39 | if (walletAddress.code.length > 0) { 40 | return BatchedWallet(payable(walletAddress)); 41 | } 42 | emit BatchedWalletCreation(address(bw)); 43 | bw = BatchedWallet(payable(new ERC1967Proxy{salt: salt}( 44 | address(BATCHED_WALLET_IMPLEMENTATION), 45 | abi.encodeCall(BatchedWallet.initialize, (owner)) 46 | ))); 47 | } 48 | 49 | /** 50 | * @notice Returns the address of a BatchedWallet (existing or future) 51 | * @dev The owner address and salt hash are used to compute the counterfactual (existing or future) 52 | * address of a BatchedWallet contract, as it would be returned by the createWallet() function above. 53 | * @param owner The address that should be the initial owner of the new BatchedWallet 54 | * @param salt A bytes32 salt hash used to generate the new (or existing) wallet address 55 | * @return bw The address of the BatchWallet (regardless of whether it already exists) 56 | */ 57 | function getAddress(address owner, bytes32 salt) public view returns (address bw) { 58 | bw = Create2.computeAddress(salt, keccak256(abi.encodePacked( 59 | type(ERC1967Proxy).creationCode, 60 | abi.encode( 61 | address(BATCHED_WALLET_IMPLEMENTATION), 62 | abi.encodeCall(BatchedWallet.initialize, (owner)) 63 | ) 64 | ))); 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /test/helpers/TestHelpers.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Test} from "@forge-std/Test.sol"; 5 | import {IEntryPoint} from "@account-abstraction/interfaces/IEntryPoint.sol"; 6 | import {UserOperation} from "@account-abstraction/interfaces/UserOperation.sol"; 7 | import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; 8 | 9 | /** 10 | * @title TestHelpers 11 | * @notice A collection of helper functions to be inherited by test contracts 12 | */ 13 | abstract contract TestHelpers is Test { 14 | using MessageHashUtils for bytes32; 15 | 16 | /** 17 | * @notice Generates the bytes32 hash representing the provided UserOperation 18 | * @param entryPoint The EntryPoint to use for the generation 19 | * @param userOp The UserOperation from which to generate the hash 20 | * @return hash Resulting bytes32 hash 21 | */ 22 | function getUserOpHash(IEntryPoint entryPoint, UserOperation memory userOp) public view returns (bytes32 hash) { 23 | hash = entryPoint.getUserOpHash(userOp).toEthSignedMessageHash(); 24 | } 25 | 26 | /** 27 | * @notice Signs a UserOperation using the provided private key to generate a signature 28 | * @param entryPoint The EntryPoint to use for the generation 29 | * @param userOp The UserOperation from which to generate the hash 30 | * @param privateKey The private key to use for signing 31 | * @return signature The resulting bytes signature 32 | */ 33 | function signUserOp(IEntryPoint entryPoint, UserOperation memory userOp, uint256 privateKey) 34 | public 35 | view 36 | returns (bytes memory signature) 37 | { 38 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, getUserOpHash(entryPoint, userOp)); 39 | bytes memory signatureData = abi.encodePacked(r, s, v); 40 | signature = signatureData; 41 | } 42 | 43 | /** 44 | * @notice Populates a UserOperation using the input fields 45 | * @param sender The sender for the new UserOperation 46 | * @param nonce The nonce for the new UserOperation 47 | * @param initCode The initCode for the new UserOperation 48 | * @param callData The callData for the new UserOperation 49 | * @param paymasterAndData The paymasterAndData for the new UserOperation 50 | * @param signature The signature for the new UserOperation 51 | * @return userOperation The resulting UserOperation 52 | */ 53 | function populateUserOp( 54 | address payable sender, 55 | uint256 nonce, 56 | bytes memory initCode, 57 | bytes memory callData, 58 | bytes memory paymasterAndData, 59 | bytes memory signature 60 | ) public pure returns (UserOperation memory userOperation) { 61 | uint256 callGasLimit = 1000000; 62 | uint256 verificationGasLimit = 1000000; 63 | uint256 preVerificationGas = 100000; 64 | uint256 maxFeePerGas = 10 gwei; 65 | uint256 maxPriorityFeePerGas = 10 gwei; 66 | userOperation = UserOperation( 67 | sender, 68 | nonce, 69 | initCode, 70 | callData, 71 | callGasLimit, 72 | verificationGasLimit, 73 | preVerificationGas, 74 | maxFeePerGas, 75 | maxPriorityFeePerGas, 76 | paymasterAndData, 77 | signature 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/batchedwallet/Bundler.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Test, console} from "@forge-std/Test.sol"; 5 | import {IEntryPoint} from "@account-abstraction/interfaces/IEntryPoint.sol"; 6 | import {UserOperation} from "@account-abstraction/interfaces/UserOperation.sol"; 7 | import {DecodeCalldata} from "@soulwallet/contracts/libraries/DecodeCalldata.sol"; 8 | import {Errors} from "@source/helper/Errors.sol"; 9 | 10 | /** 11 | * @title Bundler 12 | * @notice A simple Bundler mock contract for testing 13 | * @dev Obviously in a production environment the bundler would collect 14 | * UserOperations from an alternate mempool. Note also that the beneficiary 15 | * address is hardcoded in for testing purposes. 16 | */ 17 | contract Bundler is Test { 18 | using DecodeCalldata for bytes; 19 | 20 | /** 21 | * @dev Posts a UserOperation to the mock bundler 22 | * @param entryPoint The address of the entryPoint that the bundler should call handleOps() on 23 | * @param userOp The UserOperation that the bundler should send to the entryPoint 24 | */ 25 | function post(IEntryPoint entryPoint, UserOperation calldata userOp) external { 26 | { 27 | uint256 snapshotId = vm.snapshot(); 28 | 29 | // solhint-disable-next-line avoid-low-level-calls 30 | (bool success, bytes memory data) = address(entryPoint).call( // Note that staticcall cannot be used 31 | abi.encodeWithSignature( 32 | // solhint-disable-next-line max-line-length 33 | "simulateValidation((address,uint256,bytes,bytes,uint256,uint256,uint256,uint256,uint256,bytes,bytes))", 34 | userOp 35 | ) 36 | ); 37 | 38 | vm.revertTo(snapshotId); 39 | 40 | if (!success) { 41 | bytes4 methodId = data.decodeMethodId(); 42 | // solhint-disable-next-line no-empty-blocks 43 | if (methodId == IEntryPoint.ValidationResult.selector) { 44 | // Success case 45 | } else { 46 | if (methodId == IEntryPoint.FailedOp.selector) { 47 | // Error: FailedOp(uint256 opIndex, string reason); 48 | bytes memory innerData = data.decodeMethodCalldata(); 49 | (uint256 opIndex, string memory reason) = abi.decode(innerData, (uint256, string)); 50 | // solhint-disable-next-line no-console 51 | console.log("FailedOp:", opIndex, reason); 52 | } 53 | // solhint-disable-next-line no-inline-assembly 54 | assembly { 55 | revert(add(data, 0x20), mload(data)) 56 | } 57 | } 58 | } else { 59 | revert Errors.MOCK_BUNDLER_SIMULATE_VALIDATION_FAILED(); 60 | } 61 | } 62 | 63 | UserOperation[] memory userOperations = new UserOperation[](1); 64 | userOperations[0] = userOp; 65 | address payable beneficiary = payable(address(0x111)); 66 | uint256 gasBefore = gasleft(); 67 | entryPoint.handleOps(userOperations, beneficiary); 68 | uint256 gasAfter = gasleft(); 69 | // solhint-disable-next-line no-console 70 | console.log("entryPoint.handleOps => gas:", gasBefore - gasAfter); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /test/batchedwallet/BatchedWalletIsValidateSingature.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Test} from "@forge-std/Test.sol"; 5 | import {Bundler} from "./Bundler.sol"; 6 | import {EntryPoint} from "@account-abstraction/core/EntryPoint.sol"; 7 | import {UserOperation} from "@account-abstraction/interfaces/UserOperation.sol"; 8 | import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; 9 | import {BatchedWallet} from "@source/BatchedWallet.sol"; 10 | import {BatchedWalletFactory} from "@source/BatchedWalletFactory.sol"; 11 | import {TestHelpers} from "@testing/helpers/TestHelpers.sol"; 12 | import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; 13 | 14 | /** 15 | * @title BatchedWalletIsValidateSingatureTest 16 | * @notice The contract tests the correct operation of the BatchedWallet's EIP-1271 compliant 17 | * isValidateSingature() function implementation. 18 | */ 19 | contract BatchedWalletIsValidateSingatureTest is Test, TestHelpers { 20 | using MessageHashUtils for bytes32; 21 | 22 | Bundler public bundler; 23 | EntryPoint public entryPoint; 24 | BatchedWalletFactory public bwFactory; 25 | address public walletOwner; 26 | uint256 public ownerPrivateKey; 27 | address payable public sender; 28 | bytes4 internal constant MAGICVALUE = 0x1626ba7e; 29 | bytes4 internal constant INVALID_ID = 0xffffffff; 30 | 31 | function setUp() public { 32 | (walletOwner, ownerPrivateKey) = makeAddrAndKey("owner1"); 33 | bytes32 salt = bytes32(0); 34 | 35 | entryPoint = new EntryPoint(); 36 | bwFactory = new BatchedWalletFactory(address(entryPoint)); 37 | bundler = new Bundler(); 38 | 39 | bytes memory initCode; 40 | { 41 | sender = payable(bwFactory.getAddress(walletOwner, salt)); 42 | 43 | bytes memory bwFactoryCall = 44 | abi.encodeWithSignature("createWallet(address,bytes32)", walletOwner, salt); 45 | initCode = abi.encodePacked(address(bwFactory), bwFactoryCall); 46 | } 47 | 48 | bytes memory callData; 49 | bytes memory paymasterAndData; 50 | bytes memory signature; 51 | UserOperation memory userOperation = populateUserOp( 52 | sender, 53 | 0, 54 | initCode, 55 | callData, 56 | paymasterAndData, 57 | signature 58 | ); 59 | 60 | userOperation.signature = signUserOp(entryPoint, userOperation, ownerPrivateKey); 61 | 62 | vm.deal(userOperation.sender, 10 ether); 63 | bundler.post(entryPoint, userOperation); 64 | assertEq(sender.code.length > 0, true, "A2:sender.code.length == 0"); 65 | assertEq(BatchedWallet(sender).owner(), walletOwner); 66 | } 67 | 68 | function testIsValidateSignauture() public { 69 | bytes32 hash = keccak256(abi.encodePacked("hello world")); 70 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash.toEthSignedMessageHash()); 71 | bytes memory sig = abi.encodePacked(r, s, v); 72 | assertEq(sig.length, 65); 73 | 74 | bytes4 validResult = IERC1271(sender).isValidSignature(hash, sig); 75 | assertEq(validResult, MAGICVALUE); 76 | } 77 | 78 | function testIsValidateSignautureInvalidCase() public { 79 | bytes32 hash = keccak256(abi.encodePacked("hello world")); 80 | bytes32 otherHash = keccak256(abi.encodePacked("hello world other")); 81 | (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash.toEthSignedMessageHash()); 82 | bytes memory sig = abi.encodePacked(r, s, v); 83 | assertEq(sig.length, 65); 84 | 85 | bytes4 validResult = IERC1271(sender).isValidSignature(otherHash, sig); 86 | assertEq(validResult, INVALID_ID); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /test/batchedwallet/BatchedWalletReceiveEther.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Test} from "@forge-std/Test.sol"; 5 | import {BatchedWallet} from "@source/BatchedWallet.sol"; 6 | import {BatchedWalletFactory} from "@source/BatchedWalletFactory.sol"; 7 | import {IEntryPoint} from "@account-abstraction/interfaces/IEntryPoint.sol"; 8 | import {EntryPoint} from "@account-abstraction/core/EntryPoint.sol"; 9 | import {UserOperation} from "@account-abstraction/interfaces/UserOperation.sol"; 10 | import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; 11 | import {Bundler} from "./Bundler.sol"; 12 | import {TestHelpers} from "@testing/helpers/TestHelpers.sol"; 13 | 14 | /** 15 | * @title BatchedWalletReceiveEtherTest 16 | * @notice The contract tests that the BatchedWallet can effectively receive Ether 17 | */ 18 | contract BatchedWalletReceiveEtherTest is Test, TestHelpers { 19 | using MessageHashUtils for bytes32; 20 | 21 | BatchedWallet public bw; 22 | BatchedWalletFactory public bwFactory; 23 | IEntryPoint public entryPoint; 24 | Bundler public bundler; 25 | address public user = address(12345); 26 | bytes32 public salt = bytes32(0); 27 | 28 | function setUp() public { 29 | entryPoint = new EntryPoint(); 30 | bwFactory = new BatchedWalletFactory(address(entryPoint)); 31 | bundler = new Bundler(); 32 | } 33 | 34 | function deploy(string memory addrKeyString) 35 | private 36 | returns (address payable sender, address walletOwner, uint256 walletOwnerPrivateKey) 37 | { 38 | (walletOwner, walletOwnerPrivateKey) = makeAddrAndKey(addrKeyString); 39 | 40 | bytes memory initCode; 41 | { 42 | sender = payable(bwFactory.getAddress(walletOwner, salt)); 43 | 44 | bytes memory bwFactoryCall = 45 | abi.encodeWithSignature("createWallet(address,bytes32)", walletOwner, salt); 46 | initCode = abi.encodePacked(address(bwFactory), bwFactoryCall); 47 | } 48 | 49 | bytes memory callData; 50 | bytes memory paymasterAndData; 51 | bytes memory signature; 52 | UserOperation memory userOperation = populateUserOp( 53 | sender, 54 | 0, 55 | initCode, 56 | callData, 57 | paymasterAndData, 58 | signature 59 | ); 60 | 61 | userOperation.signature = signUserOp(entryPoint, userOperation, walletOwnerPrivateKey); 62 | 63 | vm.expectRevert(abi.encodeWithSelector(IEntryPoint.FailedOp.selector, 0, "AA21 didn't pay prefund")); 64 | bundler.post(entryPoint, userOperation); 65 | assertEq(sender.code.length, 0, "A1:sender.code.length != 0"); 66 | 67 | vm.deal(userOperation.sender, 1 ether); 68 | bundler.post(entryPoint, userOperation); 69 | assertEq(sender.code.length > 0, true, "A2:sender.code.length == 0"); 70 | assertEq(BatchedWallet(sender).owner(), walletOwner); 71 | 72 | return (sender, walletOwner, walletOwnerPrivateKey); 73 | } 74 | 75 | function populateTransferUserOp( 76 | address payable sender, 77 | uint256 walletOwnerPrivateKey, 78 | uint256 nonce, 79 | bytes memory callData 80 | ) internal view returns (UserOperation memory userOperation) { 81 | bytes memory initCode; 82 | bytes memory paymasterAndData; 83 | bytes memory signature; 84 | userOperation = populateUserOp( 85 | sender, 86 | nonce, 87 | initCode, 88 | callData, 89 | paymasterAndData, 90 | signature 91 | ); 92 | 93 | userOperation.signature = signUserOp(entryPoint, userOperation, walletOwnerPrivateKey); 94 | } 95 | 96 | function testDeployByFactory() public { 97 | (address payable sender1, , uint256 walletOwnerPrivateKey1) = deploy("walletOwner1"); 98 | (address sender2, , ) = deploy("walletOwner2"); 99 | 100 | uint256 balBefore1 = address(sender1).balance; 101 | uint256 balBefore2 = address(sender2).balance; 102 | 103 | // function executeBatch(address[] calldata dest, uint256[] calldata value, bytes[] calldata data) 104 | { 105 | vm.deal(sender1, 8 ether); 106 | bytes memory data; 107 | UserOperation memory userOperation = populateTransferUserOp( 108 | sender1, 109 | walletOwnerPrivateKey1, 110 | 1, 111 | abi.encodeWithSignature( 112 | "execute(address,uint256,bytes)", address(sender2), 3 ether, data 113 | ) 114 | ); 115 | bundler.post(entryPoint, userOperation); 116 | 117 | uint256 balChange1 = address(sender1).balance - balBefore1; 118 | uint256 balChange2 = address(sender2).balance - balBefore2; 119 | 120 | assertEq((balChange1 / 1 ether), 4, "Incorrect ETH balance remaining in contract #1!"); 121 | assertEq(balChange2, 3 ether, "Incorrect ETH balance in contract #2!"); 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /contracts/SponsorshipPaymaster.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.18; 4 | 5 | import {BasePaymaster} from "@account-abstraction/core/BasePaymaster.sol"; 6 | import {UserOperation, UserOperationLib} from "@account-abstraction/interfaces/UserOperation.sol"; 7 | import {IEntryPoint} from "@account-abstraction/interfaces/IEntryPoint.sol"; 8 | import {Errors} from "./helper/Errors.sol"; 9 | 10 | /** 11 | * @title SponsorshipPaymaster 12 | * @notice A mock paymaster that simply freely covers the cost of all operations associated with BatchedWallets. 13 | * @dev Note that this paymaster only sponsors operations from BatchedWallets that were generated from the 14 | * BatchedWalletFactory address that is passed into the constructor. This contract inherits functionality from 15 | * BasePaymaster. This paymaster should NOT be used in production as it can be easily drained and abused. 16 | */ 17 | contract SponsorshipPaymaster is BasePaymaster { 18 | using UserOperationLib for UserOperation; 19 | 20 | // The cost of the postOp 21 | uint256 public constant COST_OF_POST = 40000; 22 | 23 | // This constant is used for the threshold check (of the maximum sponsorship amount) in the validation of 24 | // wallet construction operations. It prevents a malicious user from draining the paymaster's deposit in a 25 | // single user operation. 26 | uint256 public constant MAX_ALLOWED_SPONSOR_AMOUNT = 0.1 ether; 27 | 28 | address public immutable WALLET_FACTORY; 29 | 30 | /** 31 | * @dev Emitted when the costs of a UserOperation has been sponsored 32 | */ 33 | event UserOperationSponsored(address indexed user, uint256 actualGasCost); 34 | 35 | /** 36 | * @dev Constructs the SponsorshipPaymaster contract 37 | * @param entryPoint The address of the entryPoint to be associated with this paymaster 38 | * @param owner The owner of this SponsorshipPaymaster contract 39 | * @param walletFactory The address of the BatchedWalletFactory to be associated with this 40 | * paymaster. Note that ONLY BatchedWallets created via this factory will be able to be sponsored. 41 | */ 42 | constructor(address entryPoint, address owner, address walletFactory) 43 | BasePaymaster(IEntryPoint(entryPoint), owner) 44 | { 45 | if (address(walletFactory) == address(0)) { 46 | revert Errors.PAYMASTER_ENTRY_POINT_ADDRESS_INVALID(); 47 | } 48 | WALLET_FACTORY = walletFactory; 49 | } 50 | 51 | /** 52 | * @notice Validates that a provided UserOperation is valid to have its fees sponsored by this paymaster 53 | * @dev Checks that the gas consumption requirements of the UserOperation are within a resonable range 54 | * (to help avoid this paymaster getting quickly drained). 55 | * @param userOp The UserOperation to be validated 56 | * @param maxCost The amount of prefunded ETH (in wei) required for the operation 57 | * @return context Bytes encoded address of the UserOperation sender 58 | * @return validationResult An integer representing the outcome of the validation (0 represents success) 59 | */ 60 | function _validatePaymasterUserOp(UserOperation calldata userOp, bytes32, uint256 maxCost) 61 | internal 62 | view 63 | override 64 | returns (bytes memory context, uint256 validationResult) 65 | { 66 | // This checks ensures that enough gas has been provided for the postOp function call 67 | if (userOp.verificationGasLimit <= 30000) { 68 | revert Errors.PAYMASTER_GAS_TO_LOW_FOR_POSTOP(); 69 | } 70 | 71 | // The length check below prevents the user from add excessive calldata, which could drain 72 | // the paymaster's entrypoint deposit 73 | if (userOp.paymasterAndData.length != 20) { // paymasterAndData: [paymaster] 74 | revert Errors.PAYMASTER_AND_DATA_LENGTH_INVALID(); 75 | } 76 | 77 | if (userOp.initCode.length != 0) { 78 | // This check prevents a malicious user from draining the Paymaster's deposit in a single wallet 79 | // creation UserOperation. However, a user could still send many wallet creation UserOperations 80 | // to drain the Paymaster's deposit. This check simply adds overhead for the attacker. 81 | if (maxCost >= MAX_ALLOWED_SPONSOR_AMOUNT) { 82 | revert Errors.PAYMASTER_REQUIRED_PREFUND_TOO_LARGE(); 83 | } 84 | if (address(bytes20(userOp.initCode)) != WALLET_FACTORY) { 85 | revert Errors.PAYMASTER_UNKNOWN_WALLET_FACTORY(); 86 | } 87 | } 88 | 89 | return (abi.encode(userOp.getSender()), 0); 90 | } 91 | 92 | /** 93 | * @notice Executes this paymaster's postOp operations 94 | * @dev Currently does nothing except emit an event, since no post-operation accounting state 95 | * changes are needed for the current mock use-case of the SponsorshipPaymaster contract. 96 | * @param mode An enum representing if the UserOperation succeeded, reverted, or postOp reverted 97 | * @param context Bytes encoded address of the UserOperation sender 98 | * @param actualGasCost The actual amount of gas used so far (prior to this postOp call) 99 | */ 100 | function _postOp(PostOpMode mode, bytes calldata context, uint256 actualGasCost) internal override { 101 | if (mode == PostOpMode.postOpReverted) { 102 | return; // Do nothing in this case (do NOT revert the whole bundle and harm reputation) 103 | } 104 | (address sender) = abi.decode(context, (address)); 105 | emit UserOperationSponsored(sender, actualGasCost); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /test/batchedwallet/BatchedWallet.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Test} from "@forge-std/Test.sol"; 5 | import {BatchedWallet} from "@source/BatchedWallet.sol"; 6 | import {BatchedWalletFactory} from "@source/BatchedWalletFactory.sol"; 7 | import {IEntryPoint} from "@account-abstraction/interfaces/IEntryPoint.sol"; 8 | import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; 9 | import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 10 | 11 | /** 12 | * @title BatchedWalletTest 13 | * @notice The contract tests the functionalites of the BatchedWallet directly (without 14 | * relying directions via an EntryPoint contract) 15 | */ 16 | contract BatchedWalletTest is Test { 17 | BatchedWallet public bw; 18 | IEntryPoint public entryPoint; 19 | address public globalUser = makeAddr("globalUser"); 20 | ERC20Mock public erc20mock; 21 | 22 | function setUp() public { 23 | bytes32 salt = bytes32(0); 24 | entryPoint = IEntryPoint(makeAddr("entryPoint")); 25 | 26 | BatchedWalletFactory bwFactory = new BatchedWalletFactory(address(entryPoint)); 27 | 28 | vm.startPrank(globalUser); 29 | bw = bwFactory.createWallet(globalUser, salt); 30 | 31 | vm.deal(address(bw), 1 ether); 32 | vm.stopPrank(); 33 | 34 | erc20mock = new ERC20Mock(); 35 | } 36 | 37 | function testOwner() public{ 38 | assertEq(bw.owner(), globalUser); 39 | } 40 | 41 | function testEntryPoint() public{ 42 | assertEq(address(bw.entryPoint()), address(entryPoint)); 43 | } 44 | 45 | function testExecute() public { 46 | address user = makeAddr("user"); 47 | 48 | vm.startPrank(address(entryPoint)); 49 | bw.execute(user, 0.2 ether, ""); 50 | assertEq(user.balance, 0.2 ether); 51 | assertEq(address(bw).balance, 0.8 ether); 52 | vm.stopPrank(); 53 | } 54 | 55 | function testExecuteFailWithWrongOwner() public { 56 | address user = makeAddr("user"); 57 | 58 | vm.startPrank(user); 59 | vm.expectRevert(); 60 | 61 | bw.execute(user, 0.5 ether, ""); 62 | 63 | vm.stopPrank(); 64 | } 65 | 66 | function testDepositETH() public { 67 | vm.deal(address(bw), 5 ether); 68 | assertEq(address(bw).balance, 5 ether); 69 | } 70 | 71 | function testERC20Mint() public { 72 | vm.startPrank(address(entryPoint)); 73 | bytes memory funcData = abi.encodeWithSelector(ERC20Mock.mint.selector, address(bw), 2 ether); 74 | bw.execute(address(erc20mock), 0, funcData); 75 | assertEq(erc20mock.balanceOf(address(bw)), 2 ether); 76 | vm.stopPrank(); 77 | } 78 | 79 | function testERC20Transfer() public { 80 | vm.startPrank(address(entryPoint)); 81 | 82 | bytes memory funcData = abi.encodeWithSelector(ERC20Mock.mint.selector, address(bw), 3 ether); 83 | bw.execute(address(erc20mock), 0, funcData); 84 | assertEq(erc20mock.balanceOf(address(bw)), 3 ether); 85 | 86 | address user = makeAddr("user"); 87 | bytes memory transferFuncData = abi.encodeWithSelector(IERC20.transfer.selector, user, 2 ether); 88 | bw.execute(address(erc20mock), 0, transferFuncData); 89 | assertEq(erc20mock.balanceOf(user), 2 ether); 90 | vm.stopPrank(); 91 | } 92 | 93 | function testBatchERC20MintAndTransfer() public{ 94 | address user = makeAddr("user"); 95 | 96 | address [] memory dest = new address[](2); 97 | bytes [] memory data = new bytes[](2); 98 | dest[0] = address(erc20mock); 99 | dest[1] = address(erc20mock); 100 | data[0] = abi.encodeWithSelector( 101 | ERC20Mock.mint.selector, 102 | address(bw), 103 | 3 ether); 104 | data[1] = abi.encodeWithSelector( 105 | IERC20.transfer.selector, 106 | user, 107 | 2 ether); 108 | 109 | vm.startPrank(address(entryPoint)); 110 | bw.executeBatch(dest, data); 111 | assertEq(erc20mock.balanceOf(address(bw)), 1 ether); 112 | assertEq(erc20mock.balanceOf(user), 2 ether); 113 | vm.stopPrank(); 114 | } 115 | 116 | function testExecuteBatchWithValuesSet() public{ 117 | address user = makeAddr("user"); 118 | address user2 = makeAddr("user2"); 119 | 120 | vm.deal(address(bw), 4 ether); 121 | assertEq(address(bw).balance, 4 ether); 122 | 123 | address [] memory dest = new address[](2); 124 | uint256 [] memory value = new uint256[](2); 125 | bytes [] memory data = new bytes[](2); 126 | dest[0] = user; 127 | dest[1] = user2; 128 | value[0] = 1.5 ether; 129 | value[1] = 1.5 ether; 130 | 131 | vm.startPrank(address(entryPoint)); 132 | bw.executeBatch(dest, value, data); 133 | assertEq(address(bw).balance, 1 ether); 134 | assertEq(user.balance, 1.5 ether); 135 | assertEq(user2.balance, 1.5 ether); 136 | vm.stopPrank(); 137 | } 138 | 139 | function testWithdrawERC20() public{ 140 | vm.startPrank(address(entryPoint)); 141 | bytes memory funcData = abi.encodeWithSelector(ERC20Mock.mint.selector, address(bw), 2.5 ether); 142 | bw.execute(address(erc20mock), 0, funcData); 143 | assertEq(erc20mock.balanceOf(address(bw)), 2.5 ether); 144 | 145 | bytes memory withdrawERC20FuncData = abi.encodeWithSelector(IERC20.transfer.selector, globalUser, 2.5 ether); 146 | bw.execute(address(erc20mock), 0, withdrawERC20FuncData); 147 | assertEq(erc20mock.balanceOf(globalUser), 2.5 ether); 148 | 149 | vm.stopPrank(); 150 | } 151 | 152 | function testWithdrawETH() public{ 153 | vm.startPrank(address(entryPoint)); 154 | 155 | bw.execute(address(globalUser), 0.3 ether, ""); 156 | assertEq(address(globalUser).balance, 0.3 ether); 157 | 158 | vm.stopPrank(); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /test/paymaster/BatchedWalletWithSponsorshipPaymaster.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Test} from "@forge-std/Test.sol"; 5 | import {Bundler} from "@testing/batchedwallet/Bundler.sol"; 6 | import {EntryPoint} from "@account-abstraction/core/EntryPoint.sol"; 7 | import {IEntryPoint} from "@account-abstraction/interfaces/IEntryPoint.sol"; 8 | import {UserOperation} from "@account-abstraction/interfaces/UserOperation.sol"; 9 | import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; 10 | import {BatchedWallet} from "@source/BatchedWallet.sol"; 11 | import {BatchedWalletFactory} from "@source/BatchedWalletFactory.sol"; 12 | import {TestHelpers} from "@testing/helpers/TestHelpers.sol"; 13 | import {SponsorshipPaymaster} from "@source/SponsorshipPaymaster.sol"; 14 | 15 | /** 16 | * @title BatchedWalletWithSponsorshipPaymasterTest 17 | * @notice The contract tests the functionalites of the BatchedWallet when using a 18 | * SponsorshipPaymaster to cover the fee costs. 19 | */ 20 | contract BatchedWalletWithSponsorshipPaymasterTest is Test, TestHelpers { 21 | Bundler public bundler; 22 | EntryPoint public entryPoint; 23 | BatchedWalletFactory public bwFactory; 24 | bytes32 public salt = bytes32(0); 25 | 26 | function setUp() public { 27 | entryPoint = new EntryPoint(); 28 | bwFactory = new BatchedWalletFactory(address(entryPoint)); 29 | bundler = new Bundler(); 30 | } 31 | 32 | function testDeployUsingSponsorshipPaymaster() public { 33 | address paymasterOwner = makeAddr("paymasterOwner"); 34 | SponsorshipPaymaster paymaster 35 | = new SponsorshipPaymaster(address(entryPoint), paymasterOwner, address(bwFactory)); 36 | 37 | (address walletOwner, uint256 walletOwnerPrivateKey) = makeAddrAndKey("walletOwner"); 38 | 39 | bytes memory initCode; 40 | address payable sender; 41 | { 42 | sender = payable(bwFactory.getAddress(walletOwner, salt)); 43 | 44 | bytes memory bwFactoryCall = abi.encodeWithSignature("createWallet(address,bytes32)", walletOwner, salt); 45 | initCode = abi.encodePacked(address(bwFactory), bwFactoryCall); 46 | } 47 | 48 | bytes memory callData; 49 | bytes memory paymasterAndData = abi.encodePacked(address(paymaster)); 50 | bytes memory signature; 51 | UserOperation memory userOperation = populateUserOp( 52 | sender, 53 | 0, 54 | initCode, 55 | callData, 56 | paymasterAndData, 57 | signature 58 | ); 59 | 60 | userOperation.signature = signUserOp(entryPoint, userOperation, walletOwnerPrivateKey); 61 | 62 | // UserOperation should fail since the sender has no ETH and the paymaster hasn't 63 | // deposited ETH into the entry point yet 64 | vm.expectRevert(abi.encodeWithSelector(IEntryPoint.FailedOp.selector, 0, "AA31 paymaster deposit too low")); 65 | bundler.post(entryPoint, userOperation); 66 | assertEq(sender.code.length, 0, "A1:sender.code.length != 0"); 67 | 68 | // Deposit Ether into the entry point 69 | vm.deal(paymasterOwner, 1 ether); 70 | vm.prank(paymasterOwner); 71 | entryPoint.depositTo{value: 0.5 ether}(address(paymaster)); 72 | assertEq(entryPoint.balanceOf(address(paymaster)), 0.5 ether, "deposit on the entryPoint should be 0.5 ether!"); 73 | 74 | // The UserOperation should now work and get paid for by the paymaster 75 | assertEq(address(userOperation.sender).balance, 0, "userOp sender balance should be zero!"); 76 | bundler.post(entryPoint, userOperation); 77 | assertEq(sender.code.length > 0, true, "A2:sender.code.length == 0"); 78 | assertEq(BatchedWallet(sender).owner(), walletOwner); 79 | } 80 | 81 | function populateTransferUserOp( 82 | address payable sender, 83 | uint256 walletOwnerPrivateKey, 84 | uint256 nonce, 85 | bytes memory callData, 86 | bytes memory paymasterAndData 87 | ) internal view returns (UserOperation memory userOperation) { 88 | bytes memory initCode; 89 | bytes memory signature; 90 | userOperation = populateUserOp( 91 | sender, 92 | nonce, 93 | initCode, 94 | callData, 95 | paymasterAndData, 96 | signature 97 | ); 98 | 99 | userOperation.signature = signUserOp(entryPoint, userOperation, walletOwnerPrivateKey); 100 | } 101 | 102 | function testTransferERC20UsingSponsorshipPaymaster() public { 103 | address paymasterOwner = makeAddr("paymasterOwner"); 104 | SponsorshipPaymaster paymaster 105 | = new SponsorshipPaymaster(address(entryPoint), paymasterOwner, address(bwFactory)); 106 | 107 | (address walletOwner, uint256 walletOwnerPrivateKey) = makeAddrAndKey("walletOwner"); 108 | 109 | bytes memory initCode; 110 | address payable sender; 111 | { 112 | sender = payable(bwFactory.getAddress(walletOwner, salt)); 113 | 114 | bytes memory bwFactoryCall = abi.encodeWithSignature("createWallet(address,bytes32)", walletOwner, salt); 115 | initCode = abi.encodePacked(address(bwFactory), bwFactoryCall); 116 | } 117 | 118 | bytes memory callData; 119 | bytes memory paymasterAndData = abi.encodePacked(address(paymaster)); 120 | bytes memory signature; 121 | UserOperation memory userOperation = populateUserOp( 122 | sender, 123 | 0, 124 | initCode, 125 | callData, 126 | paymasterAndData, 127 | signature 128 | ); 129 | 130 | userOperation.signature = signUserOp(entryPoint, userOperation, walletOwnerPrivateKey); 131 | 132 | // Deposit Ether into the entry point 133 | vm.deal(paymasterOwner, 1 ether); 134 | vm.prank(paymasterOwner); 135 | entryPoint.depositTo{value: 0.5 ether}(address(paymaster)); 136 | 137 | // The UserOperation should now work and get paid for by the paymaster 138 | assertEq(address(userOperation.sender).balance, 0, "userOp sender balance should be zero!"); 139 | bundler.post(entryPoint, userOperation); 140 | assertEq(sender.code.length > 0, true, "A2:sender.code.length == 0"); 141 | assertEq(BatchedWallet(sender).owner(), walletOwner); 142 | 143 | ERC20Mock tokenERC20 = new ERC20Mock(); 144 | tokenERC20.mint(sender, 10 ether); 145 | 146 | bytes memory tokenCallData = abi.encodeWithSelector(tokenERC20.transfer.selector, address(0x111), 0.6 ether); 147 | 148 | UserOperation memory newUserOperation = populateTransferUserOp( 149 | sender, 150 | walletOwnerPrivateKey, 151 | 1, 152 | abi.encodeWithSignature( 153 | "execute(address,uint256,bytes)", address(tokenERC20), 0, tokenCallData 154 | ), 155 | paymasterAndData 156 | ); 157 | 158 | assertEq(address(userOperation.sender).balance, 0, "userOp sender balance should be zero!"); 159 | bundler.post(entryPoint, newUserOperation); 160 | assertEq(ERC20Mock(tokenERC20).balanceOf(address(0x111)), 0.6 ether, "Transfer of token amount failed!"); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /test/batchedwallet/BatchedWalletDeploy.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Test} from "@forge-std/Test.sol"; 5 | import {BatchedWallet} from "@source/BatchedWallet.sol"; 6 | import {BatchedWalletFactory} from "@source/BatchedWalletFactory.sol"; 7 | import {IEntryPoint} from "@account-abstraction/interfaces/IEntryPoint.sol"; 8 | import {EntryPoint} from "@account-abstraction/core/EntryPoint.sol"; 9 | import {UserOperation} from "@account-abstraction/interfaces/UserOperation.sol"; 10 | import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; 11 | import {Bundler} from "./Bundler.sol"; 12 | import {TestHelpers} from "@testing/helpers/TestHelpers.sol"; 13 | 14 | /** 15 | * @title BatchedWalletDeployTest 16 | * @notice The contract tests the deployment of the BatchedWallet 17 | */ 18 | contract BatchedWalletDeployTest is Test, TestHelpers { 19 | using MessageHashUtils for bytes32; 20 | 21 | BatchedWallet public bw; 22 | BatchedWalletFactory public bwFactory; 23 | IEntryPoint public entryPoint; 24 | Bundler public bundler; 25 | address public user = address(12345); 26 | bytes32 public salt = bytes32(0); 27 | 28 | function setUp() public { 29 | entryPoint = new EntryPoint(); 30 | bwFactory = new BatchedWalletFactory(address(entryPoint)); 31 | bundler = new Bundler(); 32 | } 33 | 34 | function testBatchedWalletDeployment() public{ 35 | vm.prank(user); 36 | BatchedWallet batchedWallet = bwFactory.createWallet(user, salt); 37 | assertEq(address(batchedWallet), bwFactory.getAddress(user, salt)); 38 | } 39 | 40 | function testBatchedWalletOwner() public{ 41 | vm.prank(user); 42 | BatchedWallet batchedWallet = bwFactory.createWallet(user, salt); 43 | assertEq(batchedWallet.owner(), user); 44 | } 45 | 46 | function testBatchedWalletEntryPoint() public{ 47 | vm.prank(user); 48 | BatchedWallet batchedWallet = bwFactory.createWallet(user, salt); 49 | assertEq(address(batchedWallet.entryPoint()), address(entryPoint)); 50 | } 51 | 52 | function testDeployByFactory() public { 53 | address payable sender; 54 | bytes memory initCode; 55 | 56 | (address walletOwner, uint256 walletOwnerPrivateKey) = makeAddrAndKey("walletOwner"); 57 | { 58 | sender = payable(bwFactory.getAddress(walletOwner, salt)); 59 | 60 | bytes memory bwFactoryCall = 61 | abi.encodeWithSignature("createWallet(address,bytes32)", walletOwner, salt); 62 | initCode = abi.encodePacked(address(bwFactory), bwFactoryCall); 63 | } 64 | 65 | bytes memory callData; 66 | bytes memory paymasterAndData; 67 | bytes memory signature; 68 | UserOperation memory userOperation = populateUserOp( 69 | sender, 70 | 0, 71 | initCode, 72 | callData, 73 | paymasterAndData, 74 | signature 75 | ); 76 | 77 | userOperation.signature = signUserOp(entryPoint, userOperation, walletOwnerPrivateKey); 78 | 79 | vm.expectRevert(abi.encodeWithSelector(IEntryPoint.FailedOp.selector, 0, "AA21 didn't pay prefund")); 80 | bundler.post(entryPoint, userOperation); 81 | assertEq(sender.code.length, 0, "A1:sender.code.length != 0"); 82 | 83 | vm.deal(userOperation.sender, 10 ether); 84 | bundler.post(entryPoint, userOperation); 85 | assertEq(sender.code.length > 0, true, "A2:sender.code.length == 0"); 86 | assertEq(BatchedWallet(sender).owner(), walletOwner); 87 | } 88 | 89 | function populateSignMessageUserOp( 90 | address payable sender, 91 | bytes32 hashToSign, 92 | uint256 walletOwnerPrivateKey, 93 | uint256 nonce 94 | ) internal view returns (UserOperation memory userOperation) { 95 | bytes memory initCode; 96 | bytes memory callData = abi.encodeWithSignature( 97 | "signMessage(bytes32)", hashToSign 98 | ); 99 | bytes memory paymasterAndData; 100 | bytes memory signature; 101 | userOperation = populateUserOp( 102 | sender, 103 | nonce, 104 | initCode, 105 | callData, 106 | paymasterAndData, 107 | signature 108 | ); 109 | 110 | userOperation.signature = signUserOp(entryPoint, userOperation, walletOwnerPrivateKey); 111 | } 112 | 113 | function testDeployByFactoryWithContractAsOwner() public { 114 | address payable sender; 115 | bytes memory initCode; 116 | 117 | (address walletOwner, uint256 walletOwnerPrivateKey) = makeAddrAndKey("walletOwner"); 118 | { 119 | sender = payable(bwFactory.getAddress(walletOwner, salt)); 120 | 121 | bytes memory bwFactoryCall = 122 | abi.encodeWithSignature("createWallet(address,bytes32)", walletOwner, salt); 123 | initCode = abi.encodePacked(address(bwFactory), bwFactoryCall); 124 | } 125 | 126 | bytes memory callData; 127 | bytes memory paymasterAndData; 128 | bytes memory signature; 129 | UserOperation memory userOperation = populateUserOp( 130 | sender, 131 | 0, 132 | initCode, 133 | callData, 134 | paymasterAndData, 135 | signature 136 | ); 137 | 138 | userOperation.signature = signUserOp(entryPoint, userOperation, walletOwnerPrivateKey); 139 | 140 | vm.deal(userOperation.sender, 10 ether); 141 | bundler.post(entryPoint, userOperation); 142 | assertEq(sender.code.length > 0, true, "A2:sender.code.length == 0"); 143 | assertEq(BatchedWallet(sender).owner(), walletOwner); 144 | 145 | // Now we test creating another new BatchedWallet that is owned by the 146 | // one above (a BatchedWallet with another BatchedWallet as its owner) 147 | 148 | address payable newSender; 149 | bytes memory newInitCode; 150 | 151 | { 152 | newSender = payable(bwFactory.getAddress(sender, salt)); 153 | 154 | bytes memory bwFactoryCall = 155 | abi.encodeWithSignature("createWallet(address,bytes32)", sender, salt); 156 | newInitCode = abi.encodePacked(address(bwFactory), bwFactoryCall); 157 | } 158 | 159 | UserOperation memory newUserOperation = populateUserOp( 160 | newSender, 161 | 0, 162 | newInitCode, 163 | callData, 164 | paymasterAndData, 165 | signature 166 | ); 167 | 168 | bytes32 hashToSign = getUserOpHash(entryPoint, newUserOperation); 169 | UserOperation memory signContractMessageUserOp 170 | = populateSignMessageUserOp(sender, hashToSign, walletOwnerPrivateKey, 1); 171 | 172 | bundler.post(entryPoint, signContractMessageUserOp); 173 | assertEq(BatchedWallet(sender).signedMessages(hashToSign), true, "hash should already be signed"); 174 | 175 | vm.expectRevert(abi.encodeWithSelector(IEntryPoint.FailedOp.selector, 0, "AA21 didn't pay prefund")); 176 | bundler.post(entryPoint, newUserOperation); 177 | assertEq(newSender.code.length, 0, "A1:newSender.code.length != 0"); 178 | 179 | vm.deal(newUserOperation.sender, 10 ether); 180 | bundler.post(entryPoint, newUserOperation); 181 | assertEq(newSender.code.length > 0, true, "A2:newSender.code.length == 0"); 182 | assertEq(BatchedWallet(newSender).owner(), sender); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /script/SignUserOpUtil.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Script, console} from "@forge-std/Script.sol"; 5 | import {TestHelpers} from "@testing/helpers/TestHelpers.sol"; 6 | import {IEntryPoint} from "@account-abstraction/interfaces/IEntryPoint.sol"; 7 | import {UserOperation} from "@account-abstraction/interfaces/UserOperation.sol"; 8 | import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; 9 | 10 | /** 11 | * @title DeployBatchedWalletFactory 12 | * @notice The contract deploys a BatchedWalletFactory connected to the Sepolia entry point 13 | * @dev Since the Sepolia entry point address is hardcoded, this script should only be ran 14 | * when connected to the Sepolia testnet. Note that in order to run this script the following 15 | * environment variables must be set: 16 | * PRIVATE_KEY: the private key used for deployment 17 | * USEROP_SENDER: the sender for the UserOperation 18 | * USEROP_NONCE: the nonce for the UserOperation 19 | * 20 | * The following environment variables may OPTIONALLY be set: 21 | * USEROP_INIT_CODE: the encoded initCode for the UserOperation 22 | * USEROP_CALL_DATA_ADDRESS: the address for the callData of the UserOperation 23 | * USEROP_CALL_DATA_VALUE: the value (ETH in wei) for the callData of the UserOperation 24 | * USEROP_CALL_DATA_CALL_DATA: the callData for the callData of the UserOperation 25 | * USEROP_CALL_DATA_ERC20_TO_ADDRESS: the receiver address to make an ERC-20 transfer to 26 | * USEROP_CALL_DATA_ERC20_AMOUNT: the amount to send to send in an ERC-20 transfer 27 | * USEROP_CALL_GAS_LIMIT: the gasLimit for the UserOperation 28 | * USEROP_VERIFICATION_GAS_LIMIT: the verificationGasLimit for the UserOperation 29 | * USEROP_PREVERIFICATION_GAS: the preverificationGas for the UserOperation 30 | * USEROP_MAX_FEE_PER_GAS: the maxFeePerGas for the UserOperation 31 | * USEROP_MAX_PRIORITY_FEE_PER_GAS: the priorityFeePerGas for the UserOperation 32 | * USEROP_PAYMASTER_ADDRESS: the paymaster address for inclusion in the paymasterAndData of the UserOperation 33 | */ 34 | contract SignUserOpUtil is Script, TestHelpers { 35 | // Address of the EntryPoint contract on Sepolia 36 | address private constant ENTRYPOINT = 0x0576a174D229E3cFA37253523E645A78A0C91B57; 37 | 38 | function getOptionalUint256EnvVar(string memory envVar) private view returns (uint256 result) { 39 | try vm.envUint(envVar) returns (uint256 _result) { 40 | result = _result; 41 | // solhint-disable-next-line no-empty-blocks 42 | } catch {} 43 | } 44 | 45 | function getOptionalBytesEnvVar(string memory envVar) private view returns (bytes memory result) { 46 | try vm.envBytes(envVar) returns (bytes memory _result) { 47 | result = _result; 48 | // solhint-disable-next-line no-empty-blocks 49 | } catch {} 50 | } 51 | 52 | function getOptionalAddressEnvVar(string memory envVar) private view returns (address result) { 53 | try vm.envAddress(envVar) returns (address _result) { 54 | result = _result; 55 | // solhint-disable-next-line no-empty-blocks 56 | } catch {} 57 | } 58 | 59 | function run() external view { 60 | uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); 61 | 62 | UserOperation memory userOp; 63 | 64 | { 65 | address callDataToken = getOptionalAddressEnvVar("USEROP_CALL_DATA_ADDRESS"); 66 | uint256 callDataValue = getOptionalUint256EnvVar("USEROP_CALL_DATA_VALUE"); 67 | bytes memory callDataCallData = getOptionalBytesEnvVar("USEROP_CALL_DATA_CALL_DATA"); 68 | 69 | bytes memory tokenCallDataERC20; 70 | if (callDataCallData.length == 0) { 71 | address callData2Address = getOptionalAddressEnvVar("USEROP_CALL_DATA_ERC20_TO_ADDRESS"); 72 | uint256 callData2Amount = getOptionalUint256EnvVar("USEROP_CALL_DATA_ERC20_AMOUNT"); 73 | if (callData2Address != address(0) && callData2Amount > 0) { 74 | tokenCallDataERC20 = abi.encodeWithSelector( 75 | ERC20Mock(callDataToken).transfer.selector, 76 | callData2Address, 77 | callData2Amount 78 | ); 79 | } 80 | } 81 | 82 | bytes memory callData = abi.encodeWithSignature( 83 | "execute(address,uint256,bytes)", 84 | callDataToken, 85 | callDataValue, 86 | ((tokenCallDataERC20.length > 0) ? tokenCallDataERC20 : callDataCallData) 87 | ); 88 | 89 | uint256 callGasLimit = getOptionalUint256EnvVar("USEROP_CALL_GAS_LIMIT"); 90 | uint256 verificationGasLimit = getOptionalUint256EnvVar("USEROP_VERIFICATION_GAS_LIMIT"); 91 | uint256 preVerificationGas = getOptionalUint256EnvVar("USEROP_PREVERIFICATION_GAS"); 92 | uint256 maxFeePerGas = getOptionalUint256EnvVar("USEROP_MAX_FEE_PER_GAS"); 93 | uint256 maxPriorityFeePerGas = getOptionalUint256EnvVar("USEROP_MAX_PRIORITY_FEE_PER_GAS"); 94 | 95 | if (callGasLimit == 0) { 96 | callGasLimit = 100000; 97 | } 98 | if (verificationGasLimit == 0) { 99 | verificationGasLimit = 100000; 100 | } 101 | if (preVerificationGas == 0) { 102 | preVerificationGas = 10000; 103 | } 104 | if (maxFeePerGas == 0) { 105 | maxFeePerGas = 2 gwei; 106 | } 107 | if (maxPriorityFeePerGas == 0) { 108 | maxPriorityFeePerGas = 2 gwei; 109 | } 110 | 111 | bytes memory paymasterAndData; 112 | { 113 | address paymasterAddress = getOptionalAddressEnvVar("USEROP_PAYMASTER_ADDRESS"); 114 | if (paymasterAddress != address(0)) { 115 | paymasterAndData = abi.encodePacked(paymasterAddress); 116 | } 117 | } 118 | 119 | { 120 | bytes memory signature; 121 | userOp = UserOperation( 122 | vm.envAddress("USEROP_SENDER"), 123 | vm.envUint("USEROP_NONCE"), 124 | getOptionalBytesEnvVar("USEROP_INIT_CODE"), 125 | callData, 126 | callGasLimit, 127 | verificationGasLimit, 128 | preVerificationGas, 129 | maxFeePerGas, 130 | maxPriorityFeePerGas, 131 | paymasterAndData, 132 | signature 133 | ); 134 | } 135 | } 136 | 137 | /* solhint-disable no-console */ 138 | console.log("SIGNATURE RESULTING FROM USEROP:"); 139 | console.logBytes(signUserOp(IEntryPoint(ENTRYPOINT), userOp, deployerPrivateKey)); 140 | console.log(""); 141 | console.log("USER OP DETAILS:"); 142 | console.log("sender:"); 143 | console.log(userOp.sender); 144 | console.log("nonce:"); 145 | console.log(userOp.nonce); 146 | console.log("initCode:"); 147 | console.logBytes(userOp.initCode); 148 | console.log("callData:"); 149 | console.logBytes(userOp.callData); 150 | console.log("callGasLimit:"); 151 | console.log(userOp.callGasLimit); 152 | console.log("verificationGasLimit:"); 153 | console.log(userOp.verificationGasLimit); 154 | console.log("preVerificationGas:"); 155 | console.log(userOp.preVerificationGas); 156 | console.log("maxFeePerGas:"); 157 | console.log(userOp.maxFeePerGas); 158 | console.log("maxPriorityFeePerGas:"); 159 | console.log(userOp.maxPriorityFeePerGas); 160 | console.log("paymasterAndData:"); 161 | console.logBytes(userOp.paymasterAndData); 162 | /* solhint-enable no-console */ 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /test/batchedwallet/BatchedWalletExecution.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {Test} from "@forge-std/Test.sol"; 5 | import {Bundler} from "./Bundler.sol"; 6 | import {EntryPoint} from "@account-abstraction/core/EntryPoint.sol"; 7 | import {IEntryPoint} from "@account-abstraction/interfaces/IEntryPoint.sol"; 8 | import {UserOperation} from "@account-abstraction/interfaces/UserOperation.sol"; 9 | import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; 10 | import {EtherReceiverMock} from "@openzeppelin/contracts/mocks/EtherReceiverMock.sol"; 11 | import {IBatchedWallet} from "@source/interface/IBatchedWallet.sol"; 12 | import {BatchedWallet} from "@source/BatchedWallet.sol"; 13 | import {BatchedWalletFactory} from "@source/BatchedWalletFactory.sol"; 14 | import {Errors} from "@source/helper/Errors.sol"; 15 | import {TestHelpers} from "@testing/helpers/TestHelpers.sol"; 16 | 17 | /** 18 | * @title BatchedWalletExecutionTest 19 | * @notice The contract tests all of the execution functions of the BatchedWallet. 20 | */ 21 | contract BatchedWalletExecutionTest is Test, TestHelpers { 22 | Bundler public bundler; 23 | EntryPoint public entryPoint; 24 | BatchedWalletFactory public bwFactory; 25 | bytes32 public salt = bytes32(0); 26 | 27 | function setUp() public { 28 | entryPoint = new EntryPoint(); 29 | bwFactory = new BatchedWalletFactory(address(entryPoint)); 30 | bundler = new Bundler(); 31 | } 32 | 33 | function deploy() public returns (address payable sender, address walletOwner, uint256 walletOwnerPrivateKey) { 34 | (walletOwner, walletOwnerPrivateKey) = makeAddrAndKey("walletOwner"); 35 | 36 | bytes memory initCode; 37 | { 38 | sender = payable(bwFactory.getAddress(walletOwner, salt)); 39 | 40 | bytes memory bwFactoryCall = 41 | abi.encodeWithSignature("createWallet(address,bytes32)", walletOwner, salt); 42 | initCode = abi.encodePacked(address(bwFactory), bwFactoryCall); 43 | } 44 | 45 | bytes memory callData; 46 | bytes memory paymasterAndData; 47 | bytes memory signature; 48 | UserOperation memory userOperation = populateUserOp( 49 | sender, 50 | 0, 51 | initCode, 52 | callData, 53 | paymasterAndData, 54 | signature 55 | ); 56 | 57 | userOperation.signature = signUserOp(entryPoint, userOperation, walletOwnerPrivateKey); 58 | 59 | vm.expectRevert(abi.encodeWithSelector(IEntryPoint.FailedOp.selector, 0, "AA21 didn't pay prefund")); 60 | bundler.post(entryPoint, userOperation); 61 | assertEq(sender.code.length, 0, "A1:sender.code.length != 0"); 62 | 63 | vm.deal(userOperation.sender, 10 ether); 64 | bundler.post(entryPoint, userOperation); 65 | assertEq(sender.code.length > 0, true, "A2:sender.code.length == 0"); 66 | assertEq(BatchedWallet(sender).owner(), walletOwner); 67 | 68 | return (sender, walletOwner, walletOwnerPrivateKey); 69 | } 70 | 71 | function populateTransferUserOp( 72 | address payable sender, 73 | uint256 walletOwnerPrivateKey, 74 | uint256 nonce, 75 | bytes memory callData 76 | ) internal view returns (UserOperation memory userOperation) { 77 | bytes memory initCode; 78 | bytes memory paymasterAndData; 79 | bytes memory signature; 80 | userOperation = populateUserOp( 81 | sender, 82 | nonce, 83 | initCode, 84 | callData, 85 | paymasterAndData, 86 | signature 87 | ); 88 | 89 | userOperation.signature = signUserOp(entryPoint, userOperation, walletOwnerPrivateKey); 90 | } 91 | 92 | function testExecute() public { 93 | (address payable sender, address walletOwner, uint256 walletOwnerPrivateKey) = deploy(); 94 | 95 | ERC20Mock tokenERC20 = new ERC20Mock(); 96 | ERC20Mock tokenERC20A = new ERC20Mock(); 97 | 98 | tokenERC20.mint(sender, 10 ether); 99 | tokenERC20A.mint(sender, 10 ether); 100 | 101 | IBatchedWallet bw = IBatchedWallet(sender); 102 | 103 | { 104 | address tokenCallAddress = address(tokenERC20); 105 | uint256 tokenCallValue = 0; 106 | bytes memory tokenCallData 107 | = abi.encodeWithSelector(tokenERC20.transfer.selector, address(0x111), 0.6 ether); 108 | 109 | { 110 | vm.prank(address(0x111)); 111 | vm.expectRevert(Errors.NON_ENTRY_POINT_CALLER.selector); 112 | bw.execute( 113 | tokenCallAddress, 114 | tokenCallValue, 115 | tokenCallData 116 | ); 117 | } 118 | { 119 | vm.expectRevert(Errors.NON_ENTRY_POINT_CALLER.selector); 120 | vm.prank(sender); 121 | bw.execute( 122 | tokenCallAddress, 123 | tokenCallValue, 124 | tokenCallData 125 | ); 126 | } 127 | { 128 | vm.prank(walletOwner); 129 | vm.expectRevert(Errors.NON_ENTRY_POINT_CALLER.selector); 130 | bw.execute( 131 | tokenCallAddress, 132 | tokenCallValue, 133 | tokenCallData 134 | ); 135 | } 136 | { 137 | UserOperation memory userOperation = populateTransferUserOp( 138 | sender, 139 | walletOwnerPrivateKey, 140 | 1, 141 | abi.encodeWithSignature( 142 | "execute(address,uint256,bytes)", tokenCallAddress, tokenCallValue, tokenCallData 143 | ) 144 | ); 145 | 146 | bundler.post(entryPoint, userOperation); 147 | assertEq( 148 | ERC20Mock(tokenERC20).balanceOf(address(0x111)), 149 | 0.6 ether, 150 | "Transfer of token amount failed!" 151 | ); 152 | } 153 | } 154 | } 155 | 156 | function testExecuteBatchWithoutValue() public { 157 | (address payable sender, , uint256 walletOwnerPrivateKey) = deploy(); 158 | 159 | ERC20Mock tokenERC20 = new ERC20Mock(); 160 | ERC20Mock tokenERC20A = new ERC20Mock(); 161 | 162 | tokenERC20.mint(sender, 10 ether); 163 | tokenERC20A.mint(sender, 10 ether); 164 | 165 | IBatchedWallet bw = IBatchedWallet(sender); 166 | 167 | // function executeBatch(address[] calldata dest, bytes[] calldata data) 168 | { 169 | address[] memory dest = new address[](2); 170 | dest[0] = address(tokenERC20); 171 | dest[1] = address(tokenERC20A); 172 | 173 | bytes[] memory data = new bytes[](2); 174 | data[0] = abi.encodeWithSelector(tokenERC20.transfer.selector, address(0x111), 0.6 ether); 175 | data[1] = abi.encodeWithSelector(tokenERC20A.transfer.selector, address(0x112), 0.7 ether); 176 | { 177 | vm.prank(address(0x111)); 178 | vm.expectRevert(Errors.NON_ENTRY_POINT_CALLER.selector); 179 | bw.executeBatch(dest, data); 180 | } 181 | { 182 | UserOperation memory userOperation = populateTransferUserOp( 183 | sender, 184 | walletOwnerPrivateKey, 185 | 1, 186 | abi.encodeWithSignature( 187 | "executeBatch(address[],bytes[])", dest, data 188 | ) 189 | ); 190 | 191 | bundler.post(entryPoint, userOperation); 192 | assertEq( 193 | ERC20Mock(tokenERC20).balanceOf(address(0x111)), 194 | 0.6 ether, 195 | "Transfer of token amount to address #1 failed!" 196 | ); 197 | assertEq( 198 | ERC20Mock(tokenERC20A).balanceOf(address(0x112)), 199 | 0.7 ether, 200 | "Transfer of token amount to address #2 failed!" 201 | ); 202 | } 203 | } 204 | } 205 | 206 | function testExecuteBatchWithZeroValuesSet() public { 207 | (address payable sender, , uint256 walletOwnerPrivateKey) = deploy(); 208 | 209 | ERC20Mock tokenERC20 = new ERC20Mock(); 210 | ERC20Mock tokenERC20A = new ERC20Mock(); 211 | 212 | tokenERC20.mint(sender, 10 ether); 213 | tokenERC20A.mint(sender, 10 ether); 214 | 215 | IBatchedWallet bw = IBatchedWallet(sender); 216 | 217 | // function executeBatch(address[] calldata dest, uint256[] calldata value, bytes[] calldata data) 218 | { 219 | address[] memory dest = new address[](2); 220 | dest[0] = address(tokenERC20); 221 | dest[1] = address(tokenERC20A); 222 | 223 | uint256[] memory value = new uint256[](2); 224 | value[0] = 3 ether; 225 | value[1] = 4 ether; 226 | 227 | bytes[] memory data = new bytes[](2); 228 | data[0] = abi.encodeWithSelector(tokenERC20.transfer.selector, address(0x111), 3 ether); 229 | data[1] = abi.encodeWithSelector(tokenERC20A.transfer.selector, address(0x112), 4 ether); 230 | { 231 | vm.prank(address(0x111)); 232 | vm.expectRevert(Errors.NON_ENTRY_POINT_CALLER.selector); 233 | bw.executeBatch(dest, data); 234 | } 235 | { 236 | UserOperation memory userOperation = populateTransferUserOp( 237 | sender, 238 | walletOwnerPrivateKey, 239 | 1, 240 | abi.encodeWithSignature( 241 | "executeBatch(address[],bytes[])", dest, data 242 | ) 243 | ); 244 | bundler.post(entryPoint, userOperation); 245 | 246 | assertEq( 247 | ERC20Mock(tokenERC20).balanceOf(address(0x111)), 248 | 3 ether, 249 | "Transfer of token amount to address #1 failed!" 250 | ); 251 | assertEq( 252 | ERC20Mock(tokenERC20A).balanceOf(address(0x112)), 253 | 4 ether, 254 | "Transfer of token amount to address #2 failed!" 255 | ); 256 | } 257 | } 258 | } 259 | 260 | function testExecuteBatchWithValuesSet() public { 261 | (address payable sender, , uint256 walletOwnerPrivateKey) = deploy(); 262 | 263 | EtherReceiverMock ethReceiverMock1 = new EtherReceiverMock(); 264 | ethReceiverMock1.setAcceptEther(true); 265 | EtherReceiverMock ethReceiverMock2 = new EtherReceiverMock(); 266 | ethReceiverMock2.setAcceptEther(true); 267 | 268 | // function executeBatch(address[] calldata dest, uint256[] calldata value, bytes[] calldata data) 269 | { 270 | address[] memory dest = new address[](2); 271 | dest[0] = address(ethReceiverMock1); 272 | dest[1] = address(ethReceiverMock2); 273 | 274 | uint256[] memory value = new uint256[](2); 275 | value[0] = 3 ether; 276 | value[1] = 4 ether; 277 | 278 | bytes[] memory data = new bytes[](2); 279 | 280 | vm.deal(sender, 8 ether); 281 | 282 | UserOperation memory userOperation = populateTransferUserOp( 283 | sender, 284 | walletOwnerPrivateKey, 285 | 1, 286 | abi.encodeWithSignature( 287 | "executeBatch(address[],uint256[],bytes[])", dest, value, data 288 | ) 289 | ); 290 | bundler.post(entryPoint, userOperation); 291 | 292 | assertEq(address(ethReceiverMock1).balance, 3 ether, "Transfer of ETH to contract #1 failed!"); 293 | assertEq(address(ethReceiverMock2).balance, 4 ether, "Transfer of ETH to contract #2 failed!"); 294 | } 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /contracts/BatchedWallet.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.18; 3 | 4 | import {IEntryPoint} from "@account-abstraction/interfaces/IEntryPoint.sol"; 5 | import {BaseAccount} from "@account-abstraction/core/BaseAccount.sol"; 6 | import {UserOperation} from "@account-abstraction/interfaces/UserOperation.sol"; 7 | import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; 8 | import {TokenCallbackHandler} from "@account-abstraction/samples/callback/TokenCallbackHandler.sol"; 9 | import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; 10 | import {OwnableUpgradeable} from "@openzeppelin-upgradeable/contracts/access/OwnableUpgradeable.sol"; 11 | import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; 12 | import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; 13 | import {IBatchedWallet} from "@source/interface/IBatchedWallet.sol"; 14 | import {Errors} from "@source/helper/Errors.sol"; 15 | 16 | /** 17 | * @title BatchedWallet 18 | * @notice Manages all smart wallet functionality, including execution, validation and signing 19 | * @dev Inherits functionality from IBatchedWallet, OwnableUpgradeable, BaseAccount, UUPSUpgradeable 20 | * and TokenCallbackHandler 21 | */ 22 | contract BatchedWallet is 23 | IBatchedWallet, 24 | OwnableUpgradeable, 25 | BaseAccount, 26 | UUPSUpgradeable, 27 | TokenCallbackHandler 28 | { 29 | using MessageHashUtils for bytes32; 30 | using ECDSA for bytes32; 31 | 32 | IEntryPoint private immutable ENTRY_POINT; 33 | 34 | // Mapping keeping track of all message hashes that have been approved ("signed") 35 | mapping(bytes32 => bool) public signedMessages; 36 | 37 | // Magic value indicating a valid signature (for ERC-1271 contracts) 38 | // bytes4(keccak256("isValidSignature(bytes32,bytes)") 39 | bytes4 internal constant EIP1271_SELECTOR = 0x1626ba7e; 40 | // Constant indicating invalid state (for ERC-1271 contracts) 41 | bytes4 internal constant EIP1271_INVALID_ID = 0xffffffff; 42 | 43 | modifier onlyEntryPoint() { 44 | if (msg.sender != address(ENTRY_POINT)) { 45 | revert Errors.NON_ENTRY_POINT_CALLER(); 46 | } 47 | _; 48 | } 49 | 50 | /** 51 | * @dev Constructs the BatchedWallet contract 52 | * @param bwEntryPoint The address of the entryPoint to be associated with this BatchedWallet 53 | */ 54 | constructor(address bwEntryPoint) { 55 | ENTRY_POINT = IEntryPoint(bwEntryPoint); 56 | _disableInitializers(); 57 | } 58 | 59 | // solhint-disable-next-line no-empty-blocks 60 | receive() external payable {} 61 | 62 | /** 63 | * @dev To reduce gas cost, the ENTRY_POINT member is immutable. To upgrade the ENTRY_POINT, 64 | * a new implementation of BatchedWallet must be deployed with the new ENTRY_POINT, then the 65 | * implementation may be upgraded by calling `upgradeToAndCall()`. 66 | * @param walletOwner Initial owner of this BatchedWallet 67 | */ 68 | function initialize(address walletOwner) public initializer { 69 | __Ownable_init(walletOwner); 70 | emit BatchedWalletInitialized(ENTRY_POINT, owner()); 71 | } 72 | 73 | /** 74 | * @notice Marks a message (`hash`) as signed. 75 | * @dev Verification via the EIP-1271 validation method is possible by passing the pre-image of the 76 | * message hash and empty bytes as the signature. 77 | * @param hash Hash of the data to be marked as signed on the behalf of this BatchedWallet 78 | */ 79 | function signMessage(bytes32 hash) external override onlyEntryPoint { 80 | signedMessages[hash] = true; 81 | emit BatchedWalletMessageSigned(hash); 82 | } 83 | 84 | /** 85 | * @notice Executes an operation received from the entry point. 86 | * @param dest The destination address for this execution 87 | * @param value The value (Ether) to be included with this execution 88 | * @param data The encoded call-data for this execution 89 | */ 90 | function execute(address dest, uint256 value, bytes calldata data) external override onlyEntryPoint { 91 | _call(dest, value, data); 92 | } 93 | 94 | /** 95 | * @notice Executes a batch of operations (without ETH value) received from the entry point. 96 | * @param dest The array of destination addresses for each execution 97 | * @param data The array of encoded call-data bytes for each execution 98 | */ 99 | function executeBatch(address[] calldata dest, bytes[] calldata data) external override onlyEntryPoint { 100 | _executeBatch(dest, new uint256[](0), data); 101 | } 102 | 103 | /** 104 | * @notice Executes a batch of operations (with ETH value) received from the entry point. 105 | * @param dest The array of destination addresses for each execution 106 | * @param value The array of value (ETH amounts) to be included with each execution 107 | * @param data The array of encoded call-data bytes for each execution 108 | */ 109 | function executeBatch(address[] calldata dest, uint256[] calldata value, bytes[] calldata data) 110 | external 111 | override 112 | onlyEntryPoint 113 | { 114 | _executeBatch(dest, value, data); 115 | } 116 | 117 | /** 118 | * @notice Private function to execute a batch of arbitrary function calls on this contract. 119 | * @param dest The array of destination addresses for each execution 120 | * @param value The array of value (ETH amounts) to be included with each execution 121 | * @param data The array of encoded call-data bytes for each execution 122 | */ 123 | function _executeBatch(address[] calldata dest, uint256[] memory value, bytes[] calldata data) private { 124 | if (dest.length != data.length || (value.length != 0 && value.length != data.length)) { 125 | revert Errors.BATCH_EXECUTE_ARRAY_LENGTH_INVALID(); 126 | } 127 | uint256 i = 0; 128 | if (value.length == 0) { 129 | for (; i < dest.length;) { 130 | _call(dest[i], 0, data[i]); 131 | unchecked { // Since this will never overflow, we can optimise gas 132 | i++; 133 | } 134 | } 135 | } else { 136 | for (; i < dest.length;) { 137 | _call(dest[i], value[i], data[i]); 138 | unchecked { // Since this will never overflow, we can optimise gas 139 | i++; 140 | } 141 | } 142 | } 143 | } 144 | 145 | /** 146 | * @notice Private function to execute arbitrary function calls on this contract. 147 | * @param dest The destination address for the execution 148 | * @param value The value (ETH amount) to be included with the execution 149 | * @param data The encoded call-data for the execution 150 | */ 151 | function _call(address dest, uint256 value, bytes memory data) private { 152 | // solhint-disable-next-line no-inline-assembly 153 | assembly ("memory-safe") { // Gas savings by switching to assembly 154 | let result := call(gas(), dest, value, add(data, 0x20), mload(data), 0, 0) 155 | if iszero(result) { 156 | let ptr := mload(0x40) 157 | returndatacopy(ptr, 0, returndatasize()) 158 | revert(ptr, returndatasize()) 159 | } 160 | } 161 | } 162 | 163 | /** 164 | * @notice Entry point getter function. 165 | * @return _entryPoint The IEntryPoint representing the entry point associaated with this BatchedWallet contract 166 | */ 167 | function entryPoint() public view override returns (IEntryPoint _entryPoint) { 168 | _entryPoint = ENTRY_POINT; 169 | } 170 | 171 | /** 172 | * @notice Internal function that verifies if a UserOperation has been legitimately signed 173 | * @dev This function is called by the (EIP-4337 compliant) validateUserOp() function 174 | * located in the (parent) BaseAccount contract. 175 | * @param userOp The UserOperation struct to be validated 176 | * @param userOpHash The hash of the UserOperation to be validated 177 | * @return validationData An integer with a value of 0 for valid signatures and 1 for signature 178 | * failure (returned via the SIG_VALIDATION_FAILED constant defined in the BaseAccount contract), 179 | * see the BaseAccount documentation for additional details 180 | */ 181 | function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash) 182 | internal 183 | override 184 | virtual 185 | returns (uint256 validationData) 186 | { 187 | try this.checkSignature( 188 | userOpHash, 189 | userOp 190 | ){ 191 | return 0; 192 | } catch { 193 | return SIG_VALIDATION_FAILED; 194 | } 195 | } 196 | 197 | /** 198 | * @notice Verifies if a signature is legitimate 199 | * @dev This function is required to make BatchedWallet compliant with EIP-1271. 200 | * @param hash The hash of the UserOpeation data (used to validate the signature) 201 | * @param signature The signature resulting from signing the hash (to be validated), it can 202 | * be a packed ECDSA signature or a contract signature (EIP-1271) (in this case the hash is approved 203 | * and the signature should be empty) 204 | * @return magicValue A bytes4 value of EIP1271_SELECTOR for valid signatures and EIP1271_INVALID_ID for invalid 205 | * signatures (these constants are defined above and are in compliance with the EIP-1271 spec), 206 | * see the EIP-1271 spec for more details: https://eips.ethereum.org/EIPS/eip-1271 207 | */ 208 | function isValidSignature(bytes32 hash, bytes calldata signature) 209 | external 210 | view 211 | override 212 | returns (bytes4 magicValue) 213 | { 214 | if (signature.length == 0) { 215 | if(signedMessages[hash.toEthSignedMessageHash()]) { 216 | return EIP1271_SELECTOR; 217 | } 218 | } else { 219 | try this.checkSignature( 220 | hash, 221 | signature 222 | ){ 223 | return EIP1271_SELECTOR; 224 | // solhint-disable-next-line no-empty-blocks 225 | } catch {} 226 | } 227 | return EIP1271_INVALID_ID; 228 | } 229 | 230 | /** 231 | * @notice Verifies if the provided signature is valid for the provided data hash 232 | * @dev This function reverts when the check fails, and it is external to enable the option 233 | * of calling it (from other functions in this contract) using a try-catch block. It's 234 | * externality is not required by any inherited interfaces or other contracts (however there 235 | * is no danger if it is called from an external contract). 236 | * @param hash The hash of the UserOpeation data (used to validate the signature) 237 | * @param signature The signature resulting from signing the hash (to be validated), it must 238 | * be a packed ECDSA signature 239 | */ 240 | function checkSignature(bytes32 hash, bytes memory signature) external view { 241 | if (signature.length < 65) { 242 | revert Errors.SIGNATURE_LENGTH_LESS_THAN_65(); 243 | } 244 | 245 | address currentOwner = hash.toEthSignedMessageHash().recover(signature); 246 | if (currentOwner != owner()) { 247 | revert Errors.SIGNATURE_NOT_SIGNED_BY_CONTRACT_OWNER(); 248 | } 249 | } 250 | 251 | /** 252 | * @notice Verifies if the provided UserOperation is legitimately signed based on the provided hash 253 | * @dev This function reverts when the check fails, and it is external to enable the option 254 | * of calling it (from other functions in this contract) using a try-catch block. It's 255 | * externality is not required by any inherited interfaces or other contracts (however there 256 | * is no danger if it is called from an external contract). 257 | * @param hash The hash of the UserOpeation data (used to validate the signature) 258 | * @param userOp The UserOperation struct to be validated, in the case that it is a contract 259 | * signature (EIP-1271) then the signature field should be empty (and the hash approved) 260 | */ 261 | function checkSignature(bytes32 hash, UserOperation calldata userOp) external view { 262 | bytes memory signature = userOp.signature; 263 | if (signature.length == 0) { 264 | // Contract signature (EIP-1271) 265 | if (getUserOpHash(userOp) != hash.toEthSignedMessageHash()) { 266 | revert Errors.HASH_FOR_SIGNATURE_INVALID(); 267 | } 268 | if (IERC1271(owner()).isValidSignature(hash, signature) != EIP1271_SELECTOR) { 269 | revert Errors.EIP1271_VALIDATION_CALL_FAILED(); 270 | } 271 | } else { 272 | // Packed ECDSA signature 273 | this.checkSignature(hash, signature); 274 | } 275 | } 276 | 277 | /** 278 | * @notice Private helper to get the hash of a UserOperation 279 | * @param userOp The UserOperation struct to generate the hash from (the signature field is ignored) 280 | * @return hash The resulting bytes32 hash of the combined UserOperation fields 281 | */ 282 | function getUserOpHash(UserOperation memory userOp) private view returns (bytes32 hash) { 283 | hash = ENTRY_POINT.getUserOpHash(userOp).toEthSignedMessageHash(); 284 | } 285 | 286 | /** 287 | * @notice Verifies if msg.sender is authorized to upgrade this contract 288 | * @dev See the UUPSUpgradeable contract for more details on this. 289 | */ 290 | // solhint-disable-next-line no-empty-blocks 291 | function _authorizeUpgrade(address) internal view override onlyOwner {} 292 | } 293 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BatchedWallet: A Simple ERC-4337-Compliant Smart Wallet Implementation 2 | 3 | BatchedWallet is a simple implementation of an ERC-4337-compliant smart wallet, additionally it complies with the [ERC-1271](https://eips.ethereum.org/EIPS/eip-1271) (contract signing) and is also upgradable. For a complete outline of the ERC-4337 spec please read the [EIP here](https://eips.ethereum.org/EIPS/eip-4337). 4 | 5 | - [Design Decisions](#design-decisions) 6 | - [Improvements](#improvements) 7 | - [Getting Started](#getting-started) 8 | - [Requirements](#requirements) 9 | - [Quickstart](#quickstart) 10 | - [Testing](#testing) 11 | - [Deploying to an EVM Testnet](#deploying-to-an-evm-testnet) 12 | - [Setup](#setup) 13 | - [Deploying](#deploying) 14 | - [Sepolia Deployment Addresses](#sepolia-deployment-addresses) 15 | - [Interacting with the Testnet Deployment](#interacting-with-the-testnet-deployment) 16 | - [1. Fund the Paymaster's Deposit](#1-Fund-the-Paymasters-Deposit) 17 | - [2. Mint ERC20Mock Tokens](#2-Mint-ERC20Mock-Tokens) 18 | - [3. Make a Feeless Transfer of the ERC20 Token Out of the BatchedWallet](#3-Make-a-Feeless-Transfer-of-the-ERC20-Token-Out-of-the-BatchedWallet) 19 | - [Security](#security) 20 | - [Contributing](#contributing) 21 | - [Thank You!](#thank-you) 22 | - [Resources](#resources) 23 | 24 | # Design Decisions 25 | 26 | The BatchedWallet design is focused on simplicity while still including the most essential features for an ERC-4337-compliant smart wallet. Here are some of the considerations that were made: 27 | 28 | - The wallet supports both EOA (ECDSA signatures) and smart contract signing (ERC-1271 compliant). 29 | - For simplicity the wallet currently only supports one owner address (this obviously limits the potential use-cases markedly). 30 | - The wallet is upgradable, as [recommended by the EIP-4337 spec](https://eips.ethereum.org/EIPS/eip-4337#entry-point-upgrading). 31 | - The EntryPoint address is hard-coded into the BatchedWallet for gas efficiency, thus for EntryPoint upgrades (if the EntryPoint address changes) redeployment would be required. 32 | 33 | # Improvements 34 | 35 | There are many areas where the BatchedWallet contracts can be improved. The list below touches on some of these areas: 36 | 37 | - Expand the ownership options to include multiple owner and/or tiers of ownership (a privileges system). 38 | - Implement a testing setup that uses an alternative mempool (to more closely replicate a production environment). 39 | - Improve the signature encoding scheme to include additional details such as `chainId` as well as a `sigType` as a part of the encoding. 40 | - Expand testing of the signature verification scheme. 41 | - Add checks in the existing testing for event emissions using the Foundry cheatcodes. 42 | - Add testing related to ERC-1155 and ERC-721 tokens. 43 | - Further gas optimisation of wallet functions. 44 | - Abstract out authorization functionalities from the BatchedWallet contract into a seperate Auth-specific contract. 45 | - Make the scripts adaptable for EVM-based chains other than the Sepolia testnet. 46 | - Add the option to have custom "validators" for validating different types of operations on the wallet. 47 | - Enable customization of default fallback functionality by allowing `STATICCALL`s to a custom contract (without allowing state modifications). 48 | 49 | # Getting Started 50 | 51 | ## Requirements 52 | 53 | Please install the following: 54 | 55 | - [Foundry / Foundryup](https://github.com/gakonst/foundry) 56 | - [Make](https://askubuntu.com/questions/161104/how-do-i-install-make) 57 | - [Solhint](https://github.com/protofire/solhint) (ensure that you have it installed globally) 58 | - [Python](https://www.python.org/downloads/) (if you want to use Slither) 59 | - [Slither](https://github.com/crytic/slither#how-to-install) (optional) 60 | 61 | ## Quickstart 62 | 63 | ```sh 64 | git clone https://github.com/leopoldjoy/simple-smart-wallet 65 | cd simple-smart-wallet 66 | forge install 67 | forge test 68 | ``` 69 | 70 | ## Testing 71 | 72 | 73 | ``` 74 | forge test 75 | ``` 76 | 77 | # Deploying to an EVM Testnet 78 | 79 | The repo is currently setup to deploy to the Sepolia Ethereum testnet, we will walk through the setup process below. 80 | 81 | ## Setup 82 | 83 | You'll need to add the following variables to a `.env` file in the root of the repo: 84 | - `PRIVATE_KEY`: A private key from your wallet. (If you don't have one you can get a private key from a new [Metamask](https://metamask.io/) account.) 85 | - `SEPOLIA_RPC_URL`: A URL to connect to the blockchain. You can get one for free from [Infura](https://www.infura.io/) account 86 | - `SEPOLIA_VERIFIER_URL`: The URL of the Etherescan Sepolia endpoint, currently this should be: `https://api-sepolia.etherscan.io/api` 87 | - `ETHERSCAN_API_KEY`: Your Etherscan API key, you can sign up for a free [account here](https://etherscan.io/apis). 88 | 89 | If you prefer, you can also run the following command from the project root to create the `.env` file and then input your values manually: 90 | 91 | ``` 92 | cp .env.example .env 93 | ``` 94 | Please note that there is an existing default private key included in the `.env.example` file. This key was used to deploy the [Sepolia Deployment Addresses](sepolia-deployment-addresses) in order to streamline testing if you prefer not to run your own deployment (this address is the owner of the deployed Sepolia contracts). 95 | 96 | Since we're using the Sepolia testnet, go get some [testnet sepolia ETH](https://sepoliafaucet.com/) if you don't have any already. 97 | 98 | ## Deploying 99 | 100 | We will walk through deploying all of the contracts now, including the mock contracts (for testing purposes). **Please do note however that in a real use-case (e.g. in production) the bundler would gather UserOperations from an alternative mempool**, however for our testing purposes we will be relaying our UserOperations to our mock bundler in the same general mempool of the testnet. 101 | 102 | First run the following in order to use the environment variables we set: 103 | ``` 104 | source .env 105 | ``` 106 | 107 | Deploy the ERC20Mock contract (so we can use it to test ERC-20 transfers): 108 | ``` 109 | make deploy-sepolia contract=ERC20Mock 110 | ``` 111 | 112 | Deploy the [BundlerMock](./contracts/mock/BundlerMock.sol) contract (which will function as our ERC-4337 bundler for testing purposes): 113 | ``` 114 | make deploy-sepolia contract=BundlerMock 115 | ``` 116 | 117 | Deploy the [BatchedWalletFactory](./contracts/BatchedWalletFactory.sol) contract: 118 | ``` 119 | make deploy-sepolia contract=BatchedWalletFactory 120 | ``` 121 | 122 | Now we create a [BatchedWallet](./contracts/BatchedWallet.sol) contract: 123 | ``` 124 | OWNER_ADDRESS= \ 125 | FACTORY_ADDRESS= \ 126 | SALT= \ 127 | make deploy-sepolia contract=BatchedWallet 128 | ``` 129 | Please replace the poritions above surrounded by arrows with the relevent values. (Note that these environment variables may also be set via the `.env` file if you prefer, however this is not recommended since modifications would need to be made before each deployment command.) For the `SALT` value, please ensure that is has the correct amount of trailing zeros, totaling 66 characters (for example: `0x7465737400000000000000000000000000000000000000000000000000000000`). 130 | 131 | Now we create a [SponsorshipPaymaster](./contracts/SponsorshipPaymaster.sol) contract (which will function for testing out paymaster sponsorship functionality): 132 | ``` 133 | OWNER_ADDRESS= \ 134 | FACTORY_ADDRESS= \ 135 | make deploy-sepolia contract=SponsorshipPaymaster 136 | ``` 137 | 138 | If you visit the deployment addresses on [Sepolia's Etherscan](https://sepolia.etherscan.io/) you may notice that they are also verified. However if any don't verifiy automatically for any reason, simply run the `forge verify-contract` command (see the [documentation here](https://book.getfoundry.sh/forge/deploying#verifying-a-pre-existing-contract)). Also please note that to verify the BatchedWallet contract in particular, since it's deployed via a proxy, you must click the "Is this a proxy?" button in Etherscan and follow the instructions. 139 | 140 | Congratulations! We now have all of the contracts deployed that we need to start testing everything out on the testnet! 141 | 142 | # Sepolia Deployment Addresses 143 | 144 | An existing deployment of the contracts (11/12/23) has been made at the following addresses: 145 | - **ERC20Mock**: [0xFbFe85108EdE87fdF9933B619311eeac313E31a3](https://sepolia.etherscan.io/address/0xfbfe85108ede87fdf9933b619311eeac313e31a3) 146 | - **BundlerMock**: [0x7192ff565893d812b0d76de7101eae6fd12e587a](https://sepolia.etherscan.io/address/0x7192ff565893d812b0d76de7101eae6fd12e587a) 147 | - **BatchedWalletFactory**: [0xdd4195dae1326a2391714b7fdb67f6d592c21ad6](https://sepolia.etherscan.io/address/0xdd4195dae1326a2391714b7fdb67f6d592c21ad6) 148 | - **BatchedWallet**: [0x4788037629494dd2ebb0b665e2027091f1109d56](https://sepolia.etherscan.io/address/0x4788037629494dd2ebb0b665e2027091f1109d56) 149 | - **SponsorshipPaymaster**: [0xc87ebf920b44c8ebf69260b54f2accbe75a9ea81](https://sepolia.etherscan.io/address/0xc87ebf920b44c8ebf69260b54f2accbe75a9ea81) 150 | 151 | Also, please note that this is the address of the current EntryPoint deployment on Sepolia: [0x0576a174D229E3cFA37253523E645A78A0C91B57](https://sepolia.etherscan.io/address/0x0576a174D229E3cFA37253523E645A78A0C91B57) 152 | 153 | # Interacting with the Testnet Deployment 154 | 155 | For the purpose of this walkthrough of the available functionality, we will use the [deployment addresses](#sepolia-deployment-addresses) listed in the section above, however feel free to subsitute the contract addresses with your own (if you've completed the deployment steps listed earlier). 156 | 157 | Also, if you are using the [addresses listed above](#sepolia-deployment-addresses), please ensure that you also ran `cp .env.example .env` earlier in the [Setup](#setup) section, since the existing default private key in the `.env.example` file is the owner of the Sepolia contracts above in order to streamline testing (if you prefer not to run your own deployment). Also, in this case please additionally import this private key into your MetaMask wallet (so we can test with it in the following steps). 158 | 159 | ## 1. Fund the Paymaster's Deposit 160 | 161 | In order for our SponsorshipPaymaster contract to cover the cost of UserOperations, we must make a deposit into the EntryPoint on behalf of the paymaster contract's address. Go to the [EntryPoint address](https://sepolia.etherscan.io/address/0x0576a174D229E3cFA37253523E645A78A0C91B57#writeContract), connect your Web3 wallet, and then click the `depositTo()` function. Enter the following values: 162 | - `payableAmount (ether)`: `0.2` (or whatever amount you want to deposit on behalf of the paymaster) 163 | - `account: 0xc87ebf920b44c8ebf69260b54f2accbe75a9ea81` (the SponsorshipPaymaster address [from the deployment](#sepolia-deployment-addresses)) 164 | 165 | Submit the transaction and wait for it to confirm. You can also switch to the read-tab of the contract and call the `deposits()` function with the paymaster's address to confirm the new size of the paymaster's deposit on the EntryPoint. 166 | 167 | ## 2. Mint ERC20Mock Tokens 168 | 169 | Go to the [ERC20Mock contract](https://sepolia.etherscan.io/address/0xfbfe85108ede87fdf9933b619311eeac313e31a3#writeContract) and run the `mint()` function with the following values: 170 | - `account`: `0x4788037629494dd2ebB0b665E2027091F1109d56` ([the deployed BatchedWallet's address](#sepolia-deployment-addresses)) 171 | - `amount`: `1000000000000000000` (1 token) 172 | 173 | Submit and wait for confirmation. The BatchedWallet now has 1 token worth of the ERC20Mock token. 174 | 175 | ## 3. Make a Feeless Transfer of ERC20 Token Out of the BatchedWallet 176 | 177 | First we need to sign the UserOperation that we want to submit to the Bundler. To do this we must run the following script: 178 | ``` 179 | USEROP_SENDER=0x4788037629494dd2ebB0b665E2027091F1109d56 \ 180 | USEROP_NONCE=2 \ 181 | USEROP_CALL_DATA_ADDRESS=0xFbFe85108EdE87fdF9933B619311eeac313E31a3 \ 182 | USEROP_CALL_DATA_ERC20_TO_ADDRESS=0x227C8be27B6699747b5a33F623E65eA072a6153A \ 183 | USEROP_CALL_DATA_ERC20_AMOUNT=1000000000000000000 \ 184 | USEROP_PAYMASTER_ADDRESS=0xC87eBf920b44C8eBf69260b54F2AccBE75a9EA81 \ 185 | make sign-user-op 186 | ``` 187 | Note that you will need to replace the nonce (and possibly the other values depending on if you deployed yourself), you can find the full documentation for these environment variables [here](/script/SignUserOpUtil.s.sol). When the command finishes, take note of the resulting signature and all of the returned values. Then [go to the bundler](https://sepolia.etherscan.io/address/0x7192ff565893d812b0d76de7101eae6fd12e587a#writeContract), and run the `post()` function with the following values: 188 | - `entryPoint`: `0x0576a174D229E3cFA37253523E645A78A0C91B57` 189 | - `sender`: `0x4788037629494dd2ebB0b665E2027091F1109d56` (the BatchedWallet address) 190 | - `nonce`: `2` (be sure that this matches the correct value and the same value you used to create the signature above) 191 | - `initCode`: `0x` 192 | - `callData`: `` 193 | - `callGasLimit`: `100000` 194 | - `verificationGasLimit`: `100000` 195 | - `preVerificationGas`: `10000` 196 | - `maxFeePerGas`: `2000000000` 197 | - `maxPriorityFeePerGas`: `2000000000` 198 | - `paymasterAndData`: `0xc87ebf920b44c8ebf69260b54f2accbe75a9ea81` 199 | - `signature`: `` 200 | 201 | Please note that it's essential that the values that you used to generate the signature are the same as the values passed above, otherwise the transaction will fail. 202 | 203 | Submit the transaction and await confirmation. Once confirmed, you can view the state changes in Etherscan and confirm that everything happened correctly (e.g. the paymaster covered the UserOperation cost and the tokens were transferred). You can see an example of the state changes of [a confirmed transaction here](https://sepolia.etherscan.io/tx/0xb68cac186154b98ba4d2710046f075e3b8572e21c36abf5fef526f526894aedf#statechange). 204 | 205 | # Security 206 | 207 | To run slither, use: 208 | 209 | ``` 210 | make slither 211 | ``` 212 | 213 | And get your slither output. 214 | 215 | # Contributing 216 | 217 | Please be sure to run the linter before pushing: 218 | 219 | ``` 220 | make lint 221 | ``` 222 | 223 | # Thank You! 224 | 225 | ## Resources 226 | 227 | - [EIP-4337](https://eips.ethereum.org/EIPS/eip-4337) 228 | - [EIP-1271](https://eips.ethereum.org/EIPS/eip-1271) 229 | --------------------------------------------------------------------------------