From d60557c8decb64ba7b0147d7e5a21ccf32ea3957 Mon Sep 17 00:00:00 2001 From: Jay Paik Date: Sun, 10 Mar 2024 15:45:19 -0400 Subject: [PATCH] feat: use LinkedListSetLib and tests --- .gitmodules | 1 + remappings.txt | 1 + src/MultiOwnerLightAccount.sol | 224 +++++------- src/MultiOwnerLightAccountFactory.sol | 92 +++-- test/LightAccount.t.sol | 2 +- test/MultiOwnerLightAccount.t.sol | 418 +++++++++++++++++++++++ test/MultiOwnerLightAccountFactory.t.sol | 112 ++++++ 7 files changed, 672 insertions(+), 178 deletions(-) create mode 100644 test/MultiOwnerLightAccount.t.sol create mode 100644 test/MultiOwnerLightAccountFactory.t.sol diff --git a/.gitmodules b/.gitmodules index 6ccf288..d6b1854 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,4 @@ [submodule "lib/modular-account"] path = lib/modular-account url = https://github.com/alchemyplatform/modular-account + branch = v1.0.x diff --git a/remappings.txt b/remappings.txt index 83d18e4..30c0981 100644 --- a/remappings.txt +++ b/remappings.txt @@ -2,3 +2,4 @@ ds-test/=lib/forge-std/lib/ds-test/src/ forge-std/=lib/forge-std/src/ @openzeppelin/=lib/openzeppelin-contracts/ account-abstraction/=lib/account-abstraction/contracts/ +modular-account/=lib/modular-account/src/ diff --git a/src/MultiOwnerLightAccount.sol b/src/MultiOwnerLightAccount.sol index 5789ed7..da1345e 100644 --- a/src/MultiOwnerLightAccount.sol +++ b/src/MultiOwnerLightAccount.sol @@ -1,55 +1,25 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.23; -/* solhint-disable avoid-low-level-calls */ -/* solhint-disable no-inline-assembly */ -/* solhint-disable reason-string */ - import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; -import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; - import {BaseAccount} from "account-abstraction/core/BaseAccount.sol"; import {SIG_VALIDATION_FAILED} from "account-abstraction/core/Helpers.sol"; import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol"; import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol"; import {TokenCallbackHandler} from "account-abstraction/samples/callback/TokenCallbackHandler.sol"; +import {CastLib} from "modular-account/helpers/CastLib.sol"; +import {SetValue} from "modular-account/libraries/Constants.sol"; +import {LinkedListSet, LinkedListSetLib} from "modular-account/libraries/LinkedListSetLib.sol"; import {CustomSlotInitializable} from "./CustomSlotInitializable.sol"; -/** - * @title A simple ERC-4337 compatible smart contract account with a designated owner account - * @dev Like eth-infinitism's `SimpleAccount`, but with the following changes: - * - * 1. Instead of the default storage slots, uses namespaced storage to avoid - * clashes when switching implementations. - * - * 2. Ownership can be transferred via `transferOwnership`, similar to the - * behavior of an `Ownable` contract. This is a simple single-step operation, - * so care must be taken to ensure that the ownership is being transferred to - * the correct address. - * - * 3. Supports [ERC-1271](https://eips.ethereum.org/EIPS/eip-1271) signature - * validation for both validating the signature on user operations and in - * exposing its own `isValidSignature` method. This only works when the owner of - * `LightAccount` also support ERC-1271. - * - * ERC-4337's bundler validation rules limit the types of contracts that can be - * used as owners to validate user operation signatures. For example, the - * contract's `isValidSignature` function may not use any forbidden opcodes - * such as `TIMESTAMP` or `NUMBER`, and the contract may not be an ERC-1967 - * proxy as it accesses a constant implementation slot not associated with - * the account, violating storage access rules. This also means that the - * owner of a `LightAccount` may not be another `LightAccount` if you want to - * send user operations through a bundler. - * - * 4. Event `SimpleAccountInitialized` renamed to `LightAccountInitialized`. - * - * 5. Uses custom errors. - */ +/// @title A simple ERC-4337 compatible smart contract account with one or more designated owner accounts. +/// @dev Like LightAccount, but multiple owners are supported. The account is initialized with a list of owners, +/// and the `updateOwners` method can be used to add or remove owners. contract MultiOwnerLightAccount is BaseAccount, TokenCallbackHandler, @@ -59,36 +29,38 @@ contract MultiOwnerLightAccount is { using ECDSA for bytes32; using MessageHashUtils for bytes32; - using EnumerableSet for EnumerableSet.AddressSet; + using LinkedListSetLib for LinkedListSet; + using CastLib for address; + using CastLib for SetValue[]; - // keccak256(abi.encode(uint256(keccak256("light_account_v2.storage")) - 1)) & ~bytes32(uint256(0xff)); - bytes32 internal constant _STORAGE_POSITION = 0xa9cd5bd8d4b90fed52f8eaaace1a3a8ac855f095ba98d4d96a5ad818d4a74600; - // keccak256(abi.encode(uint256(keccak256("light_account_v2.initializable")) - 1)) & ~bytes32(uint256(0xff)); + // keccak256(abi.encode(uint256(keccak256("multi_owner_light_account_v1.storage")) - 1)) & ~bytes32(uint256(0xff)); + bytes32 internal constant _STORAGE_POSITION = 0x0eb5184329babcda7203727c83eff940fb292fc735f61720a6182b755bf7f900; + // keccak256(abi.encode(uint256(keccak256("multi_owner_light_account_v1.initializable")) - 1)) & ~bytes32(uint256(0xff)); bytes32 internal constant _INITIALIZABLE_STORAGE_POSITION = - 0x09ba3c1cfc09dc16f8cdfb51034f08a03bdfe11fb54fd48d270939e59b283200; + 0xaa296a366a62f6551d3ddfceae892d1791068a359a0d3461aab99dfc6c5fd700; // bytes4(keccak256("isValidSignature(bytes32,bytes)")) bytes4 internal constant _1271_MAGIC_VALUE = 0x1626ba7e; IEntryPoint private immutable _ENTRY_POINT; bytes32 private constant _DOMAIN_SEPARATOR_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); - bytes32 private constant _LA_MSG_TYPEHASH = keccak256("LightAccountMessage(bytes message)"); - bytes32 private constant _NAME_HASH = keccak256("LightAccount"); + bytes32 private constant _LA_MSG_TYPEHASH = keccak256("MultiOwnerLightAccountMessage(bytes message)"); + bytes32 private constant _NAME_HASH = keccak256("MultiOwnerLightAccount"); bytes32 private constant _VERSION_HASH = keccak256("1"); struct LightAccountStorage { - EnumerableSet.AddressSet owners; + LinkedListSet owners; } - /// @notice Emitted when this account is first initialized - /// @param entryPoint The entry point - /// @param owners The initial owners + /// @notice Emitted when this account is first initialized. + /// @param entryPoint The entry point. + /// @param owners The initial owners. event LightAccountInitialized(IEntryPoint indexed entryPoint, address[] owners); /// @notice This event is emitted when owners of the account are updated. Also emitted once at initialization, with /// an empty `removedOwners`. /// @param addedOwners The address array of added owners. /// @param removedOwners The address array of removed owners. - event OwnerUpdated(address[] addedOwners, address[] removedOwners); + event OwnersUpdated(address[] addedOwners, address[] removedOwners); /// @dev The length of the array does not match the expected length. error ArrayLengthMismatch(); @@ -110,73 +82,59 @@ contract MultiOwnerLightAccount is _; } - constructor(IEntryPoint anEntryPoint) CustomSlotInitializable(_INITIALIZABLE_STORAGE_POSITION) { - _ENTRY_POINT = anEntryPoint; + constructor(IEntryPoint entryPoint_) CustomSlotInitializable(_INITIALIZABLE_STORAGE_POSITION) { + _ENTRY_POINT = entryPoint_; _disableInitializers(); } // solhint-disable-next-line no-empty-blocks receive() external payable {} - /** - * @notice Execute a transaction. This may only be called directly by the - * owner or by the entry point via a user operation signed by the owner. - * @param dest The target of the transaction - * @param value The amount of wei sent in the transaction - * @param func The transaction's calldata - */ + /// @notice Execute a transaction. This may only be called directly by an owner or by the entry point via a user + /// operation signed by an owner. + /// @param dest The target of the transaction. + /// @param value The amount of wei sent in the transaction. + /// @param func The transaction's calldata. function execute(address dest, uint256 value, bytes calldata func) external { _requireFromEntryPointOrOwner(); _call(dest, value, func); } - /** - * @notice Execute a sequence of transactions - * @param dest An array of the targets for each transaction in the sequence - * @param func An array of calldata for each transaction in the sequence. - * Must be the same length as dest, with corresponding elements representing - * the parameters for each transaction. - */ + /// @notice Execute a sequence of transactions. + /// @param dest An array of the targets for each transaction in the sequence. + /// @param func An array of calldata for each transaction in the sequence. Must be the same length as `dest`, with + /// corresponding elements representing the parameters for each transaction. function executeBatch(address[] calldata dest, bytes[] calldata func) external { _requireFromEntryPointOrOwner(); if (dest.length != func.length) { revert ArrayLengthMismatch(); } uint256 length = dest.length; - for (uint256 i = 0; i < length;) { + for (uint256 i = 0; i < length; ++i) { _call(dest[i], 0, func[i]); - unchecked { - ++i; - } } } - /** - * @notice Execute a sequence of transactions - * @param dest An array of the targets for each transaction in the sequence - * @param value An array of value for each transaction in the sequence - * @param func An array of calldata for each transaction in the sequence. - * Must be the same length as dest, with corresponding elements representing - * the parameters for each transaction. - */ + /// @notice Execute a sequence of transactions. + /// @param dest An array of the targets for each transaction in the sequence. + /// @param value An array of value for each transaction in the sequence. + /// @param func An array of calldata for each transaction in the sequence. Must be the same length as `dest`, with + /// corresponding elements representing the parameters for each transaction. function executeBatch(address[] calldata dest, uint256[] calldata value, bytes[] calldata func) external { _requireFromEntryPointOrOwner(); if (dest.length != func.length || dest.length != value.length) { revert ArrayLengthMismatch(); } uint256 length = dest.length; - for (uint256 i = 0; i < length;) { + for (uint256 i = 0; i < length; ++i) { _call(dest[i], value[i], func[i]); - unchecked { - ++i; - } } } /// @notice Update owners of the account. Can only be called by a current owner or from the entry point via /// a user operation signed by a current owner. - /// @dev If an owner is present in both ownersToAdd and ownersToRemove, it will be added as owner. The owner array - /// cannot have 0 or duplicate addresses. + /// @dev If an owner is present in both `ownersToAdd` and `ownersToRemove`, it will be added as owner. The owner + /// array cannot have 0 or duplicate addresses. /// @param ownersToAdd The address array of owners to be added. /// @param ownersToRemove The address array of owners to be removed. function updateOwners(address[] memory ownersToAdd, address[] memory ownersToRemove) external virtual onlyOwners { @@ -185,26 +143,22 @@ contract MultiOwnerLightAccount is /// @notice Called once as part of initialization, either during initial deployment or when first upgrading to /// this contract. - /// @dev The _ENTRY_POINT member is immutable, to reduce gas consumption. To upgrade EntryPoint, - /// a new implementation of LightAccount must be deployed with the new EntryPoint address, then upgrading - /// the implementation by calling `upgradeTo()` + /// @dev The `_ENTRY_POINT` member is immutable, to reduce gas consumption. To update the entry point address, a new + /// implementation of LightAccount must be deployed with the new entry point address, and then `upgradeToAndCall` + /// must be called to upgrade the implementation. /// @param owners_ The initial owners of the account. function initialize(address[] calldata owners_) public virtual initializer { _initialize(owners_); } - /** - * @notice Deposit more funds for this account in the entryPoint - */ + /// @notice Deposit more funds for this account in the entry point. function addDeposit() public payable { entryPoint().depositTo{value: msg.value}(address(this)); } - /** - * @notice Withdraw value from the account's deposit - * @param withdrawAddress Target to send to - * @param amount Amount to withdraw - */ + /// @notice Withdraw value from the account's deposit. + /// @param withdrawAddress Target to send to. + /// @param amount Amount to withdraw. function withdrawDepositTo(address payable withdrawAddress, uint256 amount) public onlyOwners { entryPoint().withdrawTo(withdrawAddress, amount); } @@ -214,26 +168,20 @@ contract MultiOwnerLightAccount is return _ENTRY_POINT; } - /** - * @notice Return the owners of this account. - * @return The array of owner addresses. - */ + ///@notice Return the owners of this account. + ///@return The array of owner addresses. function owners() public view returns (address[] memory) { - return _getStorage().owners.values(); + return _getStorage().owners.getAll().toAddressArray(); } - /** - * @notice Check current account deposit in the entryPoint - * @return The current account deposit - */ + /// @notice Check current account deposit in the entry point. + /// @return The current account deposit. function getDeposit() public view returns (uint256) { return entryPoint().balanceOf(address(this)); } - /** - * @notice Returns the domain separator for this contract, as defined in the EIP-712 standard. - * @return bytes32 The domain separator hash. - */ + /// @notice Returns the domain separator for this contract, as defined in the EIP-712 standard. + /// @return bytes32 The domain separator hash. function domainSeparator() public view returns (bytes32) { return keccak256( abi.encode( @@ -246,38 +194,30 @@ contract MultiOwnerLightAccount is ); } - /** - * @notice Returns the pre-image of the message hash - * @param message Message that should be encoded. - * @return Encoded message. - */ + /// @notice Returns the pre-image of the message hash. + /// @param message Message that should be encoded. + /// @return Encoded message. function encodeMessageData(bytes memory message) public view returns (bytes memory) { bytes32 messageHash = keccak256(abi.encode(_LA_MSG_TYPEHASH, keccak256(message))); return abi.encodePacked("\x19\x01", domainSeparator(), messageHash); } - /** - * @notice Returns hash of a message that can be signed by owners. - * @param message Message that should be hashed. - * @return Message hash. - */ + /// @notice Returns hash of a message that can be signed by owners. + /// @param message Message that should be hashed. + /// @return Message hash. function getMessageHash(bytes memory message) public view returns (bytes32) { return keccak256(encodeMessageData(message)); } - /** - * @dev The signature is valid if it is signed by the owner's private key - * (if the owner is an EOA) or if it is a valid ERC-1271 signature from the - * owner (if the owner is a contract). Note that unlike the signature - * validation used in `validateUserOp`, this does **not** wrap the digest in - * an "Ethereum Signed Message" envelope before checking the signature in - * the EOA-owner case. - * @inheritdoc IERC1271 - */ + /// @dev The signature is valid if it is signed by the owner's private key (if the owner is an EOA) or if it is a + /// valid ERC-1271 signature from the owner (if the owner is a contract). Note that unlike the signature validation + /// used in `validateUserOp`, this does **not** wrap the digest in an "Ethereum Signed Message" envelope before + /// checking the signature in the EOA-owner case. + /// @inheritdoc IERC1271 function isValidSignature(bytes32 digest, bytes memory signature) public view override returns (bytes4) { bytes32 messageHash = getMessageHash(abi.encode(digest)); (address recovered, ECDSA.RecoverError error,) = messageHash.tryRecover(signature); - if (error == ECDSA.RecoverError.NoError && _getStorage().owners.contains(recovered)) { + if (error == ECDSA.RecoverError.NoError && _getStorage().owners.contains(CastLib.toSetValue(recovered))) { return _1271_MAGIC_VALUE; } if (_isValidERC1271SignatureNow(messageHash, signature)) { @@ -295,11 +235,11 @@ contract MultiOwnerLightAccount is _removeOwnersOrRevert(ownersToRemove); _addOwnersOrRevert(ownersToAdd); - if (_getStorage().owners.length() == 0) { + if (_getStorage().owners.isEmpty()) { revert EmptyOwnersNotAllowed(); } - emit OwnerUpdated(ownersToAdd, ownersToRemove); + emit OwnersUpdated(ownersToAdd, ownersToRemove); } function _addOwnersOrRevert(address[] memory ownersToAdd) internal { @@ -307,7 +247,10 @@ contract MultiOwnerLightAccount is uint256 length = ownersToAdd.length; for (uint256 i = 0; i < length; ++i) { address ownerToAdd = ownersToAdd[i]; - if (ownerToAdd == address(0) || ownerToAdd == address(this) || !_storage.owners.add(ownerToAdd)) { + if ( + ownerToAdd == address(0) || ownerToAdd == address(this) + || !_storage.owners.tryAdd(ownerToAdd.toSetValue()) + ) { revert InvalidOwner(ownerToAdd); } } @@ -317,19 +260,15 @@ contract MultiOwnerLightAccount is LightAccountStorage storage _storage = _getStorage(); uint256 length = ownersToRemove.length; for (uint256 i = 0; i < length; ++i) { - if (!_storage.owners.remove(ownersToRemove[i])) { + if (!_storage.owners.tryRemove(ownersToRemove[i].toSetValue())) { revert OwnerDoesNotExist(ownersToRemove[i]); } } } - /* - * Implement template method of BaseAccount. - * - * Uses a modified version of `SignatureChecker.isValidSignatureNow` in - * which the digest is wrapped with an "Ethereum Signed Message" envelope - * for the EOA-owner case but not in the ERC-1271 contract-owner case. - */ + /// @dev Implement template method of BaseAccount. + /// Uses a modified version of `SignatureChecker.isValidSignatureNow` in which the digest is wrapped with an + /// "Ethereum Signed Message" envelope for the EOA-owner case but not in the ERC-1271 contract-owner case. function _validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash) internal virtual @@ -339,7 +278,7 @@ contract MultiOwnerLightAccount is bytes32 signedHash = userOpHash.toEthSignedMessageHash(); bytes memory signature = userOp.signature; (address recovered, ECDSA.RecoverError error,) = signedHash.tryRecover(signature); - if (error == ECDSA.RecoverError.NoError && _getStorage().owners.contains(recovered)) { + if (error == ECDSA.RecoverError.NoError && _getStorage().owners.contains(recovered.toSetValue())) { return 0; } if (_isValidERC1271SignatureNow(userOpHash, signature)) { @@ -350,9 +289,10 @@ contract MultiOwnerLightAccount is function _isValidERC1271SignatureNow(bytes32 digest, bytes memory signature) internal view returns (bool) { LightAccountStorage storage _storage = _getStorage(); - uint256 length = _storage.owners.length(); + address[] memory owners_ = _storage.owners.getAll().toAddressArray(); + uint256 length = owners_.length; for (uint256 i = 0; i < length; ++i) { - if (SignatureChecker.isValidERC1271SignatureNow(_storage.owners.at(i), digest, signature)) { + if (SignatureChecker.isValidERC1271SignatureNow(owners_[i], digest, signature)) { return true; } } @@ -361,14 +301,14 @@ contract MultiOwnerLightAccount is /// @dev Revert if the caller is not one of the owners or the account itself (when redirected through `execute`). function _onlyOwners() internal view { - if (msg.sender != address(this) && !_getStorage().owners.contains(msg.sender)) { + if (msg.sender != address(this) && !_getStorage().owners.contains(msg.sender.toSetValue())) { revert NotAuthorized(msg.sender); } } /// @dev Require that the call is from the entry point or an owner. function _requireFromEntryPointOrOwner() internal view { - if (msg.sender != address(entryPoint()) && !_getStorage().owners.contains(msg.sender)) { + if (msg.sender != address(entryPoint()) && !_getStorage().owners.contains(msg.sender.toSetValue())) { revert NotAuthorized(msg.sender); } } diff --git a/src/MultiOwnerLightAccountFactory.sol b/src/MultiOwnerLightAccountFactory.sol index 76b8588..4d76552 100644 --- a/src/MultiOwnerLightAccountFactory.sol +++ b/src/MultiOwnerLightAccountFactory.sol @@ -1,35 +1,34 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.23; -import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; - +import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol"; import {MultiOwnerLightAccount} from "./MultiOwnerLightAccount.sol"; -/** - * @title A factory contract for MultiOwnerLightAccount - * @dev A UserOperations "initCode" holds the address of the factory, and a method call (to createAccount, in this sample factory). - * The factory's createAccount returns the target account address even if it is already installed. - * This way, the entryPoint.getSenderAddress() can be called either before or after the account is created. - */ +/// @title A factory contract for MultiOwnerLightAccount. +/// @dev A UserOperations "initCode" holds the address of the factory, and a method call (`createAccount` or +/// `createAccountSingle`). The factory returns the target account address even if it is already deployed. This way, +/// `entryPoint.getSenderAddress()` can be called either before or after the account is created. contract MultiOwnerLightAccountFactory { + uint256 internal constant _MAX_OWNERS_ON_CREATION = 100; MultiOwnerLightAccount public immutable accountImplementation; - constructor(IEntryPoint _entryPoint) { - accountImplementation = new MultiOwnerLightAccount(_entryPoint); + error InvalidOwners(); + error OwnersLimitExceeded(); + + constructor(IEntryPoint entryPoint) { + accountImplementation = new MultiOwnerLightAccount(entryPoint); } - /** - * @notice Create an account, and return its address. - * Returns the address even if the account is already deployed. - * @dev During UserOperation execution, this method is called only if the account is not deployed. - * This method returns an existing account address so that entryPoint.getSenderAddress() would work even after account creation. - * @param owners The owners of the account to be created - * @param salt A salt, which can be changed to create multiple accounts with the same owner - * @return ret The address of either the newly deployed account or an existing account with this owner and salt - */ + /// @notice Create an account, and return its address. Returns the address even if the account is already deployed. + /// @dev During UserOperation execution, this method is called only if the account is not deployed. This method + /// returns an existing account address so that `entryPoint.getSenderAddress()` would work even after account + /// creation. + /// @param owners The owners of the account to be created. + /// @param salt A salt, which can be changed to create multiple accounts with the same owners. + /// @return ret The address of either the newly deployed account or an existing account with these owners and salt. function createAccount(address[] calldata owners, uint256 salt) public returns (MultiOwnerLightAccount ret) { address addr = getAddress(owners, salt); uint256 codeSize = addr.code.length; @@ -45,16 +44,12 @@ contract MultiOwnerLightAccountFactory { ); } - /** - * @notice Create an account, and return its address. - * Returns the address even if the account is already deployed. - * @dev During UserOperation execution, this method is called only if the account is not deployed. - * This method returns an existing account address so that entryPoint.getSenderAddress() would work even after account creation. - * @param owner The owner of the account to be created - * @param salt A salt, which can be changed to create multiple accounts with the same owner - * @return ret The address of either the newly deployed account or an existing account with this owner and salt - */ - function createAccount(address owner, uint256 salt) public returns (MultiOwnerLightAccount ret) { + /// @notice Create an account, and return its address. Returns the address even if the account is already deployed. + /// @dev This method uses less calldata than `createAccount` when creating accounts with a single initial owner. + /// @param owner The owner of the account to be created. + /// @param salt A salt, which can be changed to create multiple accounts with the same owner. + /// @return ret The address of either the newly deployed account or an existing account with this owner and salt. + function createAccountSingle(address owner, uint256 salt) public returns (MultiOwnerLightAccount ret) { address[] memory owners = new address[](1); owners[0] = owner; address addr = getAddress(owners, salt); @@ -71,13 +66,20 @@ contract MultiOwnerLightAccountFactory { ); } - /** - * @notice Calculate the counterfactual address of this account as it would be returned by createAccount() - * @param owners The owners of the account to be created - * @param salt A salt, which can be changed to create multiple accounts with the same owner - * @return The address of the account that would be created with createAccount() - */ + /// @notice Calculate the counterfactual address of this account as it would be returned by `createAccount`. + /// @param owners The owners of the account to be created. + /// @param salt A salt, which can be changed to create multiple accounts with the same owners. + /// @return The address of the account that would be created with `createAccount`. function getAddress(address[] memory owners, uint256 salt) public view returns (address) { + // This protects against counterfactuals being generated against an exceptionally large number of owners + // that may exceed the block gas limit when actually creating the account. + if (owners.length > _MAX_OWNERS_ON_CREATION) { + revert OwnersLimitExceeded(); + } + if (!_isValidOwnersArray(owners)) { + revert InvalidOwners(); + } + return Create2.computeAddress( bytes32(salt), keccak256( @@ -90,4 +92,24 @@ contract MultiOwnerLightAccountFactory { ) ); } + + /// @dev `owners` must be in strictly ascending order and not include the 0 address. + /// @param owners Array of owner addresses. + /// @return Whether the owners array is valid. + function _isValidOwnersArray(address[] memory owners) internal pure returns (bool) { + if (owners.length == 0) { + return false; + } + + address prevOwner; + uint256 length = owners.length; + for (uint256 i = 0; i < length; ++i) { + if (owners[i] <= prevOwner) { + return false; + } + prevOwner = owners[i]; + } + + return true; + } } diff --git a/test/LightAccount.t.sol b/test/LightAccount.t.sol index 1d0ceac..dd9e7f7 100644 --- a/test/LightAccount.t.sol +++ b/test/LightAccount.t.sol @@ -287,7 +287,7 @@ contract LightAccountTest is Test { bytes32(uint256(uint160(0x0000000071727De22E5E9d8BAf0edAc6f37da032))) ) ), - 0xbb16617edd0a177192b9a37ddb37f7783ae99a0cd21d53607df759ded54e024c + 0x3bc154d32c096215e957ca99af52e83275464261e8cbe90d8da1df052c89947a ); } diff --git a/test/MultiOwnerLightAccount.t.sol b/test/MultiOwnerLightAccount.t.sol new file mode 100644 index 0000000..de7780c --- /dev/null +++ b/test/MultiOwnerLightAccount.t.sol @@ -0,0 +1,418 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +import "forge-std/Test.sol"; + +import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {EntryPoint} from "account-abstraction/core/EntryPoint.sol"; +import {IEntryPoint} from "account-abstraction/interfaces/IEntryPoint.sol"; +import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol"; +import {SimpleAccount} from "account-abstraction/samples/SimpleAccount.sol"; +import {SENTINEL_VALUE} from "modular-account/libraries/Constants.sol"; +import {LinkedListSet, LinkedListSetLib} from "modular-account/libraries/LinkedListSetLib.sol"; + +import {MultiOwnerLightAccount} from "../src/MultiOwnerLightAccount.sol"; +import {MultiOwnerLightAccountFactory} from "../src/MultiOwnerLightAccountFactory.sol"; + +contract MultiOwnerLightAccountTest is Test { + using stdStorage for StdStorage; + using ECDSA for bytes32; + using MessageHashUtils for bytes32; + using LinkedListSetLib for LinkedListSet; + + uint256 public constant EOA_PRIVATE_KEY = 1; + address payable public constant BENEFICIARY = payable(address(0xbe9ef1c1a2ee)); + address public eoaAddress; + MultiOwnerLightAccount public account; + MultiOwnerLightAccount public contractOwnedAccount; + EntryPoint public entryPoint; + LightSwitch public lightSwitch; + Owner public contractOwner; + + event SimpleAccountInitialized(IEntryPoint indexed entryPoint, address indexed owner); + event OwnersUpdated(address[] ownersToAdd, address[] ownersToRemove); + event Initialized(uint64 version); + + function setUp() public { + eoaAddress = vm.addr(EOA_PRIVATE_KEY); + entryPoint = new EntryPoint(); + MultiOwnerLightAccountFactory factory = new MultiOwnerLightAccountFactory(entryPoint); + account = factory.createAccountSingle(eoaAddress, 1); + vm.deal(address(account), 1 << 128); + lightSwitch = new LightSwitch(); + contractOwner = new Owner(); + } + + function testExecuteCanBeCalledByOwner() public { + vm.prank(eoaAddress); + account.execute(address(lightSwitch), 0, abi.encodeCall(LightSwitch.turnOn, ())); + assertTrue(lightSwitch.on()); + } + + function testExecuteWithValueCanBeCalledByOwner() public { + vm.prank(eoaAddress); + account.execute(address(lightSwitch), 1 ether, abi.encodeCall(LightSwitch.turnOn, ())); + assertTrue(lightSwitch.on()); + assertEq(address(lightSwitch).balance, 1 ether); + } + + function testExecuteCanBeCalledByEntryPointWithExternalOwner() public { + PackedUserOperation memory op = + _getSignedOp(address(lightSwitch), abi.encodeCall(LightSwitch.turnOn, ()), EOA_PRIVATE_KEY); + PackedUserOperation[] memory ops = new PackedUserOperation[](1); + ops[0] = op; + entryPoint.handleOps(ops, BENEFICIARY); + assertTrue(lightSwitch.on()); + } + + function testExecutedCanBeCalledByEntryPointWithContractOwner() public { + _useContractOwner(); + PackedUserOperation memory op = _getUnsignedOp(address(lightSwitch), abi.encodeCall(LightSwitch.turnOn, ())); + op.signature = contractOwner.sign(entryPoint.getUserOpHash(op)); + PackedUserOperation[] memory ops = new PackedUserOperation[](1); + ops[0] = op; + entryPoint.handleOps(ops, BENEFICIARY); + assertTrue(lightSwitch.on()); + } + + function testRejectsUserOpsWithInvalidSignature() public { + PackedUserOperation memory op = _getSignedOp(address(lightSwitch), abi.encodeCall(LightSwitch.turnOn, ()), 1234); + PackedUserOperation[] memory ops = new PackedUserOperation[](1); + ops[0] = op; + vm.expectRevert(abi.encodeWithSelector(IEntryPoint.FailedOp.selector, 0, "AA24 signature error")); + entryPoint.handleOps(ops, BENEFICIARY); + } + + function testExecuteCannotBeCalledByRandos() public { + vm.expectRevert(abi.encodeWithSelector(MultiOwnerLightAccount.NotAuthorized.selector, (address(this)))); + account.execute(address(lightSwitch), 0, abi.encodeCall(LightSwitch.turnOn, ())); + } + + function testExecuteRevertingCallShouldRevertWithSameData() public { + Reverter reverter = new Reverter(); + vm.prank(eoaAddress); + vm.expectRevert("did revert"); + account.execute(address(reverter), 0, abi.encodeCall(Reverter.doRevert, ())); + } + + function testExecuteBatchCalledByOwner() public { + vm.prank(eoaAddress); + address[] memory dest = new address[](1); + dest[0] = address(lightSwitch); + bytes[] memory func = new bytes[](1); + func[0] = abi.encodeCall(LightSwitch.turnOn, ()); + account.executeBatch(dest, func); + assertTrue(lightSwitch.on()); + } + + function testExecuteBatchFailsForUnevenInputArrays() public { + vm.prank(eoaAddress); + address[] memory dest = new address[](2); + dest[0] = address(lightSwitch); + dest[1] = address(lightSwitch); + bytes[] memory func = new bytes[](1); + func[0] = abi.encodeCall(LightSwitch.turnOn, ()); + vm.expectRevert(MultiOwnerLightAccount.ArrayLengthMismatch.selector); + account.executeBatch(dest, func); + } + + function testExecuteBatchWithValueCalledByOwner() public { + vm.prank(eoaAddress); + address[] memory dest = new address[](1); + dest[0] = address(lightSwitch); + uint256[] memory value = new uint256[](1); + value[0] = uint256(1); + bytes[] memory func = new bytes[](1); + func[0] = abi.encodeCall(LightSwitch.turnOn, ()); + account.executeBatch(dest, value, func); + assertTrue(lightSwitch.on()); + assertEq(address(lightSwitch).balance, 1); + } + + function testExecuteBatchWithValueFailsForUnevenInputArrays() public { + vm.prank(eoaAddress); + address[] memory dest = new address[](1); + dest[0] = address(lightSwitch); + uint256[] memory value = new uint256[](2); + value[0] = uint256(1); + value[1] = uint256(1 ether); + bytes[] memory func = new bytes[](1); + func[0] = abi.encodeCall(LightSwitch.turnOn, ()); + vm.expectRevert(MultiOwnerLightAccount.ArrayLengthMismatch.selector); + account.executeBatch(dest, value, func); + } + + function testInitialize() public { + MultiOwnerLightAccountFactory factory = new MultiOwnerLightAccountFactory(entryPoint); + vm.expectEmit(true, false, false, false); + emit Initialized(0); + account = factory.createAccountSingle(eoaAddress, 1); + } + + function testCannotInitializeWithZeroOwner() public { + MultiOwnerLightAccountFactory factory = new MultiOwnerLightAccountFactory(entryPoint); + vm.expectRevert(MultiOwnerLightAccountFactory.InvalidOwners.selector); + account = factory.createAccountSingle(address(0), 1); + } + + function testAddDeposit() public { + assertEq(account.getDeposit(), 0); + account.addDeposit{value: 10}(); + assertEq(account.getDeposit(), 10); + assertEq(account.getDeposit(), entryPoint.balanceOf(address(account))); + } + + function testWithdrawDepositToCalledByOwner() public { + account.addDeposit{value: 10}(); + vm.prank(eoaAddress); + account.withdrawDepositTo(BENEFICIARY, 5); + assertEq(entryPoint.balanceOf(address(account)), 5); + } + + function testWithdrawDepositToCannotBeCalledByRandos() public { + account.addDeposit{value: 10}(); + vm.expectRevert(abi.encodeWithSelector(MultiOwnerLightAccount.NotAuthorized.selector, (address(this)))); + account.withdrawDepositTo(BENEFICIARY, 5); + } + + function testOwnerCanUpdateOwners() public { + address[] memory ownersToAdd = new address[](1); + ownersToAdd[0] = address(0x100); + address[] memory ownersToRemove = new address[](1); + ownersToRemove[0] = eoaAddress; + vm.prank(eoaAddress); + + vm.expectEmit(true, true, false, false); + emit OwnersUpdated(ownersToAdd, ownersToRemove); + account.updateOwners(ownersToAdd, ownersToRemove); + assertEq(account.owners(), ownersToAdd); + } + + function testEntryPointCanUpdateOwners() public { + address[] memory ownersToAdd = new address[](1); + ownersToAdd[0] = address(0x100); + address[] memory ownersToRemove = new address[](1); + ownersToRemove[0] = eoaAddress; + PackedUserOperation memory op = _getSignedOp( + address(account), + abi.encodeCall(MultiOwnerLightAccount.updateOwners, (ownersToAdd, ownersToRemove)), + EOA_PRIVATE_KEY + ); + PackedUserOperation[] memory ops = new PackedUserOperation[](1); + ops[0] = op; + vm.expectEmit(true, true, false, false); + emit OwnersUpdated(ownersToAdd, ownersToRemove); + entryPoint.handleOps(ops, BENEFICIARY); + assertEq(account.owners(), ownersToAdd); + } + + function testRandosCannotUpdateOwners() public { + address[] memory ownersToAdd = new address[](1); + ownersToAdd[0] = address(0x100); + vm.expectRevert(abi.encodeWithSelector(MultiOwnerLightAccount.NotAuthorized.selector, (address(this)))); + account.updateOwners(ownersToAdd, new address[](0)); + } + + function testCannotAddExistingOwner() public { + address[] memory ownersToAdd = new address[](1); + ownersToAdd[0] = address(eoaAddress); + vm.prank(eoaAddress); + vm.expectRevert(abi.encodeWithSelector(MultiOwnerLightAccount.InvalidOwner.selector, (eoaAddress))); + account.updateOwners(ownersToAdd, new address[](0)); + } + + function testCannotAddZeroAddressAsOwner() public { + address[] memory ownersToAdd = new address[](1); + ownersToAdd[0] = address(0); + vm.prank(eoaAddress); + vm.expectRevert(abi.encodeWithSelector(MultiOwnerLightAccount.InvalidOwner.selector, (address(0)))); + account.updateOwners(ownersToAdd, new address[](0)); + } + + function testCannotRemoveAllOwners() public { + address[] memory ownersToRemove = new address[](1); + ownersToRemove[0] = address(eoaAddress); + vm.prank(eoaAddress); + vm.expectRevert(MultiOwnerLightAccount.EmptyOwnersNotAllowed.selector); + account.updateOwners(new address[](0), ownersToRemove); + } + + function testCannotAddLightContractItselfAsOwner() public { + address[] memory ownersToAdd = new address[](1); + ownersToAdd[0] = address(account); + vm.prank(eoaAddress); + vm.expectRevert(abi.encodeWithSelector(MultiOwnerLightAccount.InvalidOwner.selector, (address(account)))); + account.updateOwners(ownersToAdd, new address[](0)); + } + + function testAddAndRemoveSameOwner() public { + address[] memory ownersToAdd = new address[](1); + ownersToAdd[0] = eoaAddress; + address[] memory ownersToRemove = new address[](1); + ownersToRemove[0] = eoaAddress; + vm.prank(eoaAddress); + account.updateOwners(ownersToAdd, ownersToRemove); + + address[] memory owners = account.owners(); + assertEq(owners.length, 1); + assertEq(owners[0], eoaAddress); + } + + function testEntryPointGetter() public { + assertEq(address(account.entryPoint()), address(entryPoint)); + } + + function testIsValidSignatureForEoaOwner() public { + bytes32 digest = keccak256("digest"); + bytes memory signature = _sign(EOA_PRIVATE_KEY, account.getMessageHash(abi.encode(digest))); + assertEq(account.isValidSignature(digest, signature), bytes4(keccak256("isValidSignature(bytes32,bytes)"))); + } + + function testIsValidSignatureForContractOwner() public { + _useContractOwner(); + bytes32 digest = keccak256("digest"); + bytes memory signature = contractOwner.sign(account.getMessageHash(abi.encode(digest))); + assertEq(account.isValidSignature(digest, signature), bytes4(keccak256("isValidSignature(bytes32,bytes)"))); + } + + function testIsValidSignatureRejectsInvalid() public { + bytes32 digest = keccak256("digest"); + bytes memory signature = _sign(123, account.getMessageHash(abi.encode(digest))); + assertEq(account.isValidSignature(digest, signature), bytes4(0xffffffff)); + } + + function testOwnerCanUpgrade() public { + // Upgrade to a normal SimpleAccount with a different entry point. + IEntryPoint newEntryPoint = IEntryPoint(address(0x2000)); + SimpleAccount newImplementation = new SimpleAccount(newEntryPoint); + vm.expectEmit(true, true, false, false); + emit SimpleAccountInitialized(newEntryPoint, address(this)); + vm.prank(eoaAddress); + account.upgradeToAndCall(address(newImplementation), abi.encodeCall(SimpleAccount.initialize, (address(this)))); + SimpleAccount upgradedAccount = SimpleAccount(payable(account)); + assertEq(address(upgradedAccount.entryPoint()), address(newEntryPoint)); + } + + function testNonOwnerCannotUpgrade() public { + // Try to upgrade to a normal SimpleAccount with a different entry point. + IEntryPoint newEntryPoint = IEntryPoint(address(0x2000)); + SimpleAccount newImplementation = new SimpleAccount(newEntryPoint); + vm.expectRevert(abi.encodeWithSelector(MultiOwnerLightAccount.NotAuthorized.selector, (address(this)))); + account.upgradeToAndCall(address(newImplementation), abi.encodeCall(SimpleAccount.initialize, (address(this)))); + } + + function testStorageSlots() public { + // No storage at start (slot 0). + bytes32 storageStart = vm.load(address(account), bytes32(uint256(0))); + assertEq(storageStart, 0); + + // Instead, storage at the chosen locations. + bytes32 accountSlot = keccak256(abi.encode(uint256(keccak256("multi_owner_light_account_v1.storage")) - 1)) + & ~bytes32(uint256(0xff)); + address owner = address(bytes20(vm.load(address(account), keccak256(abi.encode(SENTINEL_VALUE, accountSlot))))); + assertEq(owner, eoaAddress); + + bytes32 initializableSlot = keccak256( + abi.encode(uint256(keccak256("multi_owner_light_account_v1.initializable")) - 1) + ) & ~bytes32(uint256(0xff)); + uint8 initialized = abi.decode(abi.encode(vm.load(address(account), initializableSlot)), (uint8)); + assertEq(initialized, 1); + } + + function testValidateInitCodeHash() external { + assertEq( + keccak256( + abi.encodePacked( + type(MultiOwnerLightAccountFactory).creationCode, + bytes32(uint256(uint160(0x0000000071727De22E5E9d8BAf0edAc6f37da032))) + ) + ), + 0xc08a03589978f2f668b59c037f95687ffb9826f79ffe8c8287f524f1423b9be8 + ); + } + + function _useContractOwner() internal { + vm.prank(eoaAddress); + address[] memory ownersToAdd = new address[](1); + ownersToAdd[0] = address(contractOwner); + address[] memory ownersToRemove = new address[](1); + ownersToRemove[0] = eoaAddress; + account.updateOwners(ownersToAdd, ownersToRemove); + } + + function _getUnsignedOp(address target, bytes memory innerCallData) + internal + view + returns (PackedUserOperation memory) + { + uint128 verificationGasLimit = 1 << 24; + uint128 callGasLimit = 1 << 24; + uint128 maxPriorityFeePerGas = 1 << 8; + uint128 maxFeePerGas = 1 << 8; + return PackedUserOperation({ + sender: address(account), + nonce: 0, + initCode: "", + callData: abi.encodeCall(MultiOwnerLightAccount.execute, (target, 0, innerCallData)), + accountGasLimits: bytes32(uint256(verificationGasLimit) << 128 | callGasLimit), + preVerificationGas: 1 << 24, + gasFees: bytes32(uint256(maxPriorityFeePerGas) << 128 | maxFeePerGas), + paymasterAndData: "", + signature: "" + }); + } + + function _getSignedOp(address target, bytes memory innerCallData, uint256 privateKey) + internal + view + returns (PackedUserOperation memory) + { + PackedUserOperation memory op = _getUnsignedOp(target, innerCallData); + op.signature = _sign(privateKey, entryPoint.getUserOpHash(op).toEthSignedMessageHash()); + return op; + } + + function _sign(uint256 privateKey, bytes32 digest) internal pure returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); + return abi.encodePacked(r, s, v); + } + + function _getStorage(bytes32 position) + internal + pure + returns (MultiOwnerLightAccount.LightAccountStorage storage storageStruct) + { + assembly { + storageStruct.slot := position + } + } +} + +contract LightSwitch { + bool public on; + + function turnOn() external payable { + on = true; + } +} + +contract Reverter { + function doRevert() external pure { + revert("did revert"); + } +} + +contract Owner is IERC1271 { + function sign(bytes32 digest) public pure returns (bytes memory) { + return abi.encodePacked("Signed: ", digest); + } + + function isValidSignature(bytes32 digest, bytes memory signature) public pure override returns (bytes4) { + if (keccak256(signature) == keccak256(sign(digest))) { + return bytes4(keccak256("isValidSignature(bytes32,bytes)")); + } + return 0xffffffff; + } +} diff --git a/test/MultiOwnerLightAccountFactory.t.sol b/test/MultiOwnerLightAccountFactory.t.sol new file mode 100644 index 0000000..0c4c35d --- /dev/null +++ b/test/MultiOwnerLightAccountFactory.t.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +import "forge-std/Test.sol"; + +import {EntryPoint} from "account-abstraction/core/EntryPoint.sol"; + +import {MultiOwnerLightAccount} from "../src/MultiOwnerLightAccount.sol"; +import {MultiOwnerLightAccountFactory} from "../src/MultiOwnerLightAccountFactory.sol"; + +contract MultiOwnerLightAccountFactoryTest is Test { + uint256 internal constant _MAX_OWNERS_ON_CREATION = 100; + + address[] public owners; + MultiOwnerLightAccountFactory public factory; + EntryPoint public entryPoint; + + function setUp() public { + entryPoint = new EntryPoint(); + factory = new MultiOwnerLightAccountFactory(entryPoint); + owners = new address[](1); + owners[0] = address(1); + } + + function testReturnSameAddressWhenAccountAlreadyExists() public { + MultiOwnerLightAccount account1 = factory.createAccountSingle(owners[0], 1); + MultiOwnerLightAccount account2 = factory.createAccountSingle(owners[0], 1); + MultiOwnerLightAccount account3 = factory.createAccount(owners, 1); + assertEq(address(account1), address(account2)); + assertEq(address(account1), address(account3)); + } + + function testGetAddress() public { + address counterfactual = factory.getAddress(owners, 1); + assertEq(counterfactual.codehash, bytes32(0)); + MultiOwnerLightAccount factual = factory.createAccount(owners, 1); + assertTrue(address(factual).codehash != bytes32(0)); + assertEq(counterfactual, address(factual)); + } + + function testGetAddressSingle() public { + address counterfactual = factory.getAddress(owners, 1); + assertEq(counterfactual.codehash, bytes32(0)); + MultiOwnerLightAccount factual = factory.createAccountSingle(owners[0], 1); + assertTrue(address(factual).codehash != bytes32(0)); + assertEq(counterfactual, address(factual)); + } + + function testGetAddressAndCreateAccountWithMaxOwners() public { + owners = new address[](_MAX_OWNERS_ON_CREATION); + for (uint160 i = 0; i < _MAX_OWNERS_ON_CREATION; i++) { + owners[i] = address(i + 1); + } + address counterfactual = factory.getAddress(owners, 1); + MultiOwnerLightAccount factual = factory.createAccount(owners, 1); + assertEq(counterfactual, address(factual)); + } + + function testGetAddressAndCreateAccountWithTooManyOwners() public { + owners = new address[](_MAX_OWNERS_ON_CREATION + 1); + for (uint160 i = 0; i < _MAX_OWNERS_ON_CREATION + 1; i++) { + owners[i] = address(i + 1); + } + vm.expectRevert(MultiOwnerLightAccountFactory.OwnersLimitExceeded.selector); + factory.getAddress(owners, 1); + vm.expectRevert(MultiOwnerLightAccountFactory.OwnersLimitExceeded.selector); + factory.createAccount(owners, 1); + } + + function testGetAddressAndCreateAccountWithDescendingOwners() public { + owners = new address[](3); + owners[0] = address(100); + owners[1] = address(99); + owners[1] = address(98); + vm.expectRevert(MultiOwnerLightAccountFactory.InvalidOwners.selector); + factory.getAddress(owners, 1); + vm.expectRevert(MultiOwnerLightAccountFactory.InvalidOwners.selector); + factory.createAccount(owners, 1); + } + + function testGetAddressAndCreateAccountWithDuplicateOwners() public { + owners = new address[](3); + owners[0] = address(100); + owners[1] = address(101); + owners[1] = address(101); + vm.expectRevert(MultiOwnerLightAccountFactory.InvalidOwners.selector); + factory.getAddress(owners, 1); + vm.expectRevert(MultiOwnerLightAccountFactory.InvalidOwners.selector); + factory.createAccount(owners, 1); + } + + function testGetAddressAndCreateAccountWithZeroAddress() public { + owners = new address[](3); + owners[0] = address(0); + owners[1] = address(100); + owners[1] = address(101); + vm.expectRevert(MultiOwnerLightAccountFactory.InvalidOwners.selector); + factory.getAddress(owners, 1); + vm.expectRevert(MultiOwnerLightAccountFactory.InvalidOwners.selector); + factory.createAccount(owners, 1); + vm.expectRevert(MultiOwnerLightAccountFactory.InvalidOwners.selector); + factory.createAccountSingle(address(0), 1); + } + + function testGetAddressAndCreateAccountWithEmptyOwners() public { + owners = new address[](0); + vm.expectRevert(MultiOwnerLightAccountFactory.InvalidOwners.selector); + factory.getAddress(owners, 1); + vm.expectRevert(MultiOwnerLightAccountFactory.InvalidOwners.selector); + factory.createAccount(owners, 1); + } +}