diff --git a/.gitmodules b/.gitmodules index ca6218e..d6b1854 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,7 @@ path = lib/account-abstraction url = https://github.com/eth-infinitism/account-abstraction branch = releases/v0.7 +[submodule "lib/modular-account"] + path = lib/modular-account + url = https://github.com/alchemyplatform/modular-account + branch = v1.0.x diff --git a/lib/modular-account b/lib/modular-account new file mode 160000 index 0000000..1ceb793 --- /dev/null +++ b/lib/modular-account @@ -0,0 +1 @@ +Subproject commit 1ceb7935b3d8642283f0dc8fd0a7a7f00132be9d 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 new file mode 100644 index 0000000..da1345e --- /dev/null +++ b/src/MultiOwnerLightAccount.sol @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.23; + +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 {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 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, + UUPSUpgradeable, + CustomSlotInitializable, + IERC1271 +{ + using ECDSA for bytes32; + using MessageHashUtils for bytes32; + using LinkedListSetLib for LinkedListSet; + using CastLib for address; + using CastLib for SetValue[]; + + // 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 = + 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("MultiOwnerLightAccountMessage(bytes message)"); + bytes32 private constant _NAME_HASH = keccak256("MultiOwnerLightAccount"); + bytes32 private constant _VERSION_HASH = keccak256("1"); + + struct LightAccountStorage { + LinkedListSet 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 OwnersUpdated(address[] addedOwners, address[] removedOwners); + + /// @dev The length of the array does not match the expected length. + error ArrayLengthMismatch(); + + /// @dev The account is not allowed to have 0 owners. + error EmptyOwnersNotAllowed(); + + /// @dev The owner to be added is not valid (e.g., `address(0)`, the account itself, or a current owner). + error InvalidOwner(address owner); + + /// @dev The caller is not authorized. + error NotAuthorized(address caller); + + /// @dev The owner to be removed does not exist. + error OwnerDoesNotExist(address owner); + + modifier onlyOwners() { + _onlyOwners(); + _; + } + + 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 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. + 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; ++i) { + _call(dest[i], 0, func[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. + 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; ++i) { + _call(dest[i], value[i], func[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. + /// @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 { + _updateOwners(ownersToAdd, ownersToRemove); + } + + /// @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 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 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. + function withdrawDepositTo(address payable withdrawAddress, uint256 amount) public onlyOwners { + entryPoint().withdrawTo(withdrawAddress, amount); + } + + /// @inheritdoc BaseAccount + function entryPoint() public view virtual override returns (IEntryPoint) { + return _ENTRY_POINT; + } + + ///@notice Return the owners of this account. + ///@return The array of owner addresses. + function owners() public view returns (address[] memory) { + return _getStorage().owners.getAll().toAddressArray(); + } + + /// @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. + function domainSeparator() public view returns (bytes32) { + return keccak256( + abi.encode( + _DOMAIN_SEPARATOR_TYPEHASH, + _NAME_HASH, // name + _VERSION_HASH, // version + block.chainid, // chainId + address(this) // verifying contract + ) + ); + } + + /// @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. + 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 + 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(CastLib.toSetValue(recovered))) { + return _1271_MAGIC_VALUE; + } + if (_isValidERC1271SignatureNow(messageHash, signature)) { + return _1271_MAGIC_VALUE; + } + return 0xffffffff; + } + + function _initialize(address[] calldata owners_) internal virtual { + emit LightAccountInitialized(_ENTRY_POINT, owners_); + _updateOwners(owners_, new address[](0)); + } + + function _updateOwners(address[] memory ownersToAdd, address[] memory ownersToRemove) internal { + _removeOwnersOrRevert(ownersToRemove); + _addOwnersOrRevert(ownersToAdd); + + if (_getStorage().owners.isEmpty()) { + revert EmptyOwnersNotAllowed(); + } + + emit OwnersUpdated(ownersToAdd, ownersToRemove); + } + + function _addOwnersOrRevert(address[] memory ownersToAdd) internal { + LightAccountStorage storage _storage = _getStorage(); + uint256 length = ownersToAdd.length; + for (uint256 i = 0; i < length; ++i) { + address ownerToAdd = ownersToAdd[i]; + if ( + ownerToAdd == address(0) || ownerToAdd == address(this) + || !_storage.owners.tryAdd(ownerToAdd.toSetValue()) + ) { + revert InvalidOwner(ownerToAdd); + } + } + } + + function _removeOwnersOrRevert(address[] memory ownersToRemove) internal { + LightAccountStorage storage _storage = _getStorage(); + uint256 length = ownersToRemove.length; + for (uint256 i = 0; i < length; ++i) { + if (!_storage.owners.tryRemove(ownersToRemove[i].toSetValue())) { + revert OwnerDoesNotExist(ownersToRemove[i]); + } + } + } + + /// @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 + override + returns (uint256 validationData) + { + 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.toSetValue())) { + return 0; + } + if (_isValidERC1271SignatureNow(userOpHash, signature)) { + return 0; + } + return SIG_VALIDATION_FAILED; + } + + function _isValidERC1271SignatureNow(bytes32 digest, bytes memory signature) internal view returns (bool) { + LightAccountStorage storage _storage = _getStorage(); + address[] memory owners_ = _storage.owners.getAll().toAddressArray(); + uint256 length = owners_.length; + for (uint256 i = 0; i < length; ++i) { + if (SignatureChecker.isValidERC1271SignatureNow(owners_[i], digest, signature)) { + return true; + } + } + return false; + } + + /// @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.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.toSetValue())) { + revert NotAuthorized(msg.sender); + } + } + + function _call(address target, uint256 value, bytes memory data) internal { + (bool success, bytes memory result) = target.call{value: value}(data); + if (!success) { + assembly { + revert(add(result, 32), mload(result)) + } + } + } + + function _authorizeUpgrade(address newImplementation) internal view override { + (newImplementation); + _onlyOwners(); + } + + function _getStorage() internal pure returns (LightAccountStorage storage storageStruct) { + bytes32 position = _STORAGE_POSITION; + assembly { + storageStruct.slot := position + } + } +} diff --git a/src/MultiOwnerLightAccountFactory.sol b/src/MultiOwnerLightAccountFactory.sol new file mode 100644 index 0000000..05c2745 --- /dev/null +++ b/src/MultiOwnerLightAccountFactory.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.23; + +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 (`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; + + error InvalidOwners(); + error OwnersArrayEmpty(); + 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 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; + if (codeSize > 0) { + return MultiOwnerLightAccount(payable(addr)); + } + ret = MultiOwnerLightAccount( + payable( + new ERC1967Proxy{salt: bytes32(salt)}( + address(accountImplementation), abi.encodeCall(MultiOwnerLightAccount.initialize, (owners)) + ) + ) + ); + } + + /// @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); + uint256 codeSize = addr.code.length; + if (codeSize > 0) { + return MultiOwnerLightAccount(payable(addr)); + } + ret = MultiOwnerLightAccount( + payable( + new ERC1967Proxy{salt: bytes32(salt)}( + address(accountImplementation), abi.encodeCall(MultiOwnerLightAccount.initialize, (owners)) + ) + ) + ); + } + + /// @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) { + _validateOwnersArray(owners); + + return Create2.computeAddress( + bytes32(salt), + keccak256( + abi.encodePacked( + type(ERC1967Proxy).creationCode, + abi.encode( + address(accountImplementation), abi.encodeCall(MultiOwnerLightAccount.initialize, (owners)) + ) + ) + ) + ); + } + + /// @dev `owners` must be in strictly ascending order and not include the 0 address. The ordering requirement + /// ensures a canonical counterfactual for a given set of initial owners. Also, its length must not be empty + /// and not exceed `_MAX_OWNERS_ON_CREATION`. + /// @param owners Array of owner addresses. + function _validateOwnersArray(address[] memory owners) internal pure { + if (owners.length == 0) { + revert OwnersArrayEmpty(); + } + + // 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(); + } + + address prevOwner; + uint256 length = owners.length; + for (uint256 i = 0; i < length; ++i) { + if (owners[i] <= prevOwner) { + revert InvalidOwners(); + } + prevOwner = owners[i]; + } + } +} 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..1524063 --- /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))) + ) + ), + 0x13b72a4d0723d9429d9c149d74e8355c44fc38752d581d4f27c99f4cf749e62c + ); + } + + 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..127c960 --- /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.OwnersArrayEmpty.selector); + factory.getAddress(owners, 1); + vm.expectRevert(MultiOwnerLightAccountFactory.OwnersArrayEmpty.selector); + factory.createAccount(owners, 1); + } +}