diff --git a/package.json b/package.json index c55f94e..0c21b51 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "url": "https://github.com/jpgonzalezra" }, "dependencies": { - "@openzeppelin/contracts": "^5.0.1" + "@openzeppelin/contracts": "5.0.2", + "@account-abstraction/contracts": "0.7.0" }, "devDependencies": { "forge-std": "github:foundry-rs/forge-std#v1.8.1", diff --git a/remappings.txt b/remappings.txt index 550f908..b80d0e0 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,2 +1,5 @@ @openzeppelin/contracts/=node_modules/@openzeppelin/contracts/ +@openzeppelin/contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/ forge-std/=node_modules/forge-std/ +@account-abstraction/contracts/=node_modules/@account-abstraction/contracts/ +solady/=solady/src \ No newline at end of file diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 498db52..0db180f 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -1,13 +1,13 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25 <0.9.0; +// // SPDX-License-Identifier: UNLICENSED +// pragma solidity >=0.8.25 <0.9.0; -import { Foo } from "../src/Foo.sol"; +// import { SimplePlusAccount } from "../src/SimplePlusAccount.sol"; -import { BaseScript } from "./Base.s.sol"; +// import { BaseScript } from "./Base.s.sol"; -/// @dev See the Solidity Scripting tutorial: https://book.getfoundry.sh/tutorials/solidity-scripting -contract Deploy is BaseScript { - function run() public broadcast returns (Foo foo) { - foo = new Foo(); - } -} +// /// @dev See the Solidity Scripting tutorial: https://book.getfoundry.sh/tutorials/solidity-scripting +// contract Deploy is BaseScript { +// function run() public broadcast returns (Foo foo) { +// foo = new Foo(); +// } +// } diff --git a/src/Foo.sol b/src/Foo.sol deleted file mode 100644 index 7483070..0000000 --- a/src/Foo.sol +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25; - -contract Foo { - function id(uint256 value) external pure returns (uint256) { - return value; - } -} diff --git a/src/SimplePlusAccount.sol b/src/SimplePlusAccount.sol new file mode 100644 index 0000000..10e4c05 --- /dev/null +++ b/src/SimplePlusAccount.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.25; + +import { SimpleAccount, IEntryPoint } from "@account-abstraction/contracts/samples/SimpleAccount.sol"; +import { SIG_VALIDATION_SUCCESS, SIG_VALIDATION_FAILED } from "@account-abstraction/contracts/core/Helpers.sol"; +import { PackedUserOperation } from "@account-abstraction/contracts/interfaces/PackedUserOperation.sol"; +import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { IERC1271 } from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; + +contract SimplePlusAccount is SimpleAccount, IERC1271, EIP712 { + using ECDSA for bytes32; + using MessageHashUtils for bytes32; + + bytes32 internal constant _MESSAGE_TYPEHASH = keccak256("SimplePlusAccount(bytes message)"); + + modifier onlyAuthorized() { + _onlyAuthorized(); + _; + } + + // @notice Signature types used for user operation validation and ERC-1271 signature validation. + enum SignatureType { + EOA, + CONTRACT, + CONTRACT_WITH_ADDR + } + + error InvalidOwner(address newOwner); + error NotAuthorized(); + error InvalidSignatureType(); + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + constructor(IEntryPoint anEntryPoint) SimpleAccount(anEntryPoint) EIP712("SimplePlusAccount", "1") { } + + /** + * @dev The _entryPoint member is immutable, to reduce gas consumption. To upgrade EntryPoint, + * a new implementation of SimpleAccount must be deployed with the new EntryPoint address, then upgrading + * the implementation by calling `upgradeTo()` + */ + function initialize(address anOwner) public virtual override initializer { + _initialize(anOwner); + } + + /// @dev Revert if the caller is not any of: + /// 1. The entry point + /// 2. The account itself (when redirected through `execute`, etc.) + /// 3. An owner + function _onlyAuthorized() internal view { + if (msg.sender != address(entryPoint()) && msg.sender != address(this) && msg.sender != owner) { + revert NotAuthorized(); + } + } + + /// @notice Transfers ownership of the contract to a new account (`newOwner`). Can only be called by the current + /// owner or from the entry point via a user operation signed by the current owner. + /// @param newOwner The new owner. + function transferOwnership(address newOwner) external onlyAuthorized { + if (newOwner == address(0) || newOwner == address(this) || owner == newOwner) { + revert InvalidOwner(newOwner); + } + address oldOwner = owner; + owner = newOwner; + emit OwnershipTransferred(oldOwner, newOwner); + } + + /* + * @dev Validates the signature of a user operation. + * + * The signature is considered valid if: + * - It is signed by the owner's private key (when the owner is an EOA), or + * - It is a valid ERC-1271 signature from the owner (when the owner is a contract). + * + * Reverts if the signature is malformed or if the signature type is unrecognized. + * + * Note: + * - This function differs from `validateUserOp` in that it does **not** wrap the hash in an + * "Ethereum Signed Message" envelope before checking the signature for the EOA-owner case. + */ + function isValidSignature(bytes32 hash, bytes calldata signature) public view virtual returns (bytes4) { + bytes32 structHash = keccak256(abi.encode(_MESSAGE_TYPEHASH, keccak256(abi.encode(hash)))); + bytes32 replaySafeHash = MessageHashUtils.toTypedDataHash(_domainSeparatorV4(), structHash); + return _validateSignatureType(uint8(signature[0]), replaySafeHash, signature[1:]) == SIG_VALIDATION_SUCCESS + ? this.isValidSignature.selector + : bytes4(0xffffffff); + } + + function _validateSignature( + PackedUserOperation calldata userOp, + bytes32 userOpHash + ) + internal + virtual + override + returns (uint256 validationData) + { + return _validateSignatureType(uint8(userOp.signature[0]), userOpHash, userOp.signature[1:]); + } + + function _validateSignatureType( + uint8 signatureType, + bytes32 hash, + bytes memory signature + ) + private + view + returns (uint256) + { + if (signature.length == 0) { + revert InvalidSignatureType(); + } + + if (signatureType == uint8(SignatureType.EOA)) { + return _validateEOASignature(hash, signature); + } else if (signatureType == uint8(SignatureType.CONTRACT)) { + return _validateContractSignature(hash, signature); + } + + revert InvalidSignatureType(); + } + + function _validateEOASignature(bytes32 userOpHash, bytes memory signature) private view returns (uint256) { + bytes32 signedHash = userOpHash.toEthSignedMessageHash(); + address recovered = signedHash.recover(signature); + return recovered == owner ? SIG_VALIDATION_SUCCESS : SIG_VALIDATION_FAILED; + } + + function _validateContractSignature(bytes32 userOpHash, bytes memory signature) private view returns (uint256) { + return SignatureChecker.isValidERC1271SignatureNow(owner, userOpHash, signature) + ? SIG_VALIDATION_SUCCESS + : SIG_VALIDATION_FAILED; + } +} diff --git a/src/SimplePlusAccountFactory.sol b/src/SimplePlusAccountFactory.sol new file mode 100644 index 0000000..7d3f2a7 --- /dev/null +++ b/src/SimplePlusAccountFactory.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.25; + +import "@openzeppelin/contracts/utils/Create2.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import "./SimplePlusAccount.sol"; + +/** + * A sample factory contract for SimplePlusAccount + * 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. + */ +contract SimplePlusAccountFactory { + SimplePlusAccount public immutable accountImplementation; + + constructor(IEntryPoint _entryPoint) { + accountImplementation = new SimplePlusAccount(_entryPoint); + } + + /** + * create an account, and return its address. + * returns the address even if the account is already deployed. + * Note that 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 + */ + function createAccount(address owner,uint256 salt) public returns (SimplePlusAccount ret) { + address addr = getAddress(owner, salt); + uint256 codeSize = addr.code.length; + if (codeSize > 0) { + return SimplePlusAccount(payable(addr)); + } + ret = SimplePlusAccount(payable(new ERC1967Proxy{salt : bytes32(salt)}( + address(accountImplementation), + abi.encodeCall(SimplePlusAccount.initialize, (owner)) + ))); + } + + /** + * calculate the counterfactual address of this account as it would be returned by createAccount() + */ + function getAddress(address owner,uint256 salt) public view returns (address) { + return Create2.computeAddress(bytes32(salt), keccak256(abi.encodePacked( + type(ERC1967Proxy).creationCode, + abi.encode( + address(accountImplementation), + abi.encodeCall(SimplePlusAccount.initialize, (owner)) + ) + ))); + } +} diff --git a/test/AccountTest.sol b/test/AccountTest.sol new file mode 100644 index 0000000..76e6c8c --- /dev/null +++ b/test/AccountTest.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.25; + +import "forge-std/src/Test.sol"; + +import { PackedUserOperation } from "@account-abstraction/contracts/interfaces/PackedUserOperation.sol"; +import { EntryPoint } from "@account-abstraction/contracts/core/EntryPoint.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +contract AccountTest is Test { + using ECDSA for bytes32; + using MessageHashUtils for bytes32; + + function getSignedOp( + EntryPoint entryPoint, + uint8 sigType, + bytes memory callData, + uint256 privateKey, + address account + ) + internal + view + returns (PackedUserOperation memory) + { + PackedUserOperation memory op = getUnsignedOp(callData, account); + bytes32 userOpHash = entryPoint.getUserOpHash(op); + bytes32 ethSignedMessageHash = userOpHash.toEthSignedMessageHash(); + bytes memory signature = abi.encodePacked(sigType, sign(privateKey, ethSignedMessageHash)); + op.signature = signature; + return op; + } + + function getUnsignedOp(bytes memory callData, address account) internal pure returns (PackedUserOperation memory) { + uint128 verificationGasLimit = 1 << 24; + uint128 callGasLimit = 1 << 24; + uint128 maxPriorityFeePerGas = 1 << 8; + uint128 maxFeePerGas = 1 << 8; + return PackedUserOperation({ + sender: account, + nonce: 0, + initCode: "", + callData: callData, + accountGasLimits: bytes32(uint256(verificationGasLimit) << 128 | callGasLimit), + preVerificationGas: 1 << 24, + gasFees: bytes32(uint256(maxPriorityFeePerGas) << 128 | maxFeePerGas), + paymasterAndData: "", + signature: "" + }); + } + + 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); + } +} diff --git a/test/Foo.t.sol b/test/Foo.t.sol deleted file mode 100644 index 727337a..0000000 --- a/test/Foo.t.sol +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25 <0.9.0; - -import { Test } from "forge-std/src/Test.sol"; -import { console2 } from "forge-std/src/console2.sol"; - -import { Foo } from "../src/Foo.sol"; - -interface IERC20 { - function balanceOf(address account) external view returns (uint256); -} - -/// @dev If this is your first time with Forge, read this tutorial in the Foundry Book: -/// https://book.getfoundry.sh/forge/writing-tests -contract FooTest is Test { - Foo internal foo; - - /// @dev A function invoked before each test case is run. - function setUp() public virtual { - // Instantiate the contract-under-test. - foo = new Foo(); - } - - /// @dev Basic test. Run it with `forge test -vvv` to see the console log. - function test_Example() external view { - console2.log("Hello World"); - uint256 x = 42; - assertEq(foo.id(x), x, "value mismatch"); - } - - /// @dev Fuzz test that provides random values for an unsigned integer, but which rejects zero as an input. - /// If you need more sophisticated input validation, you should use the `bound` utility instead. - /// See https://twitter.com/PaulRBerg/status/1622558791685242880 - function testFuzz_Example(uint256 x) external view { - vm.assume(x != 0); // or x = bound(x, 1, 100) - assertEq(foo.id(x), x, "value mismatch"); - } - - /// @dev Fork test that runs against an Ethereum Mainnet fork. For this to work, you need to set `API_KEY_ALCHEMY` - /// in your environment You can get an API key for free at https://alchemy.com. - function testFork_Example() external { - // Silently pass this test if there is no API key. - string memory alchemyApiKey = vm.envOr("API_KEY_ALCHEMY", string("")); - if (bytes(alchemyApiKey).length == 0) { - return; - } - - // Otherwise, run the test against the mainnet fork. - vm.createSelectFork({ urlOrAlias: "mainnet", blockNumber: 16_428_000 }); - address usdc = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - address holder = 0x7713974908Be4BEd47172370115e8b1219F4A5f0; - uint256 actualBalance = IERC20(usdc).balanceOf(holder); - uint256 expectedBalance = 196_307_713.810457e6; - assertEq(actualBalance, expectedBalance); - } -} diff --git a/test/SimplePlusAccount.t.sol b/test/SimplePlusAccount.t.sol new file mode 100644 index 0000000..f3c2de9 --- /dev/null +++ b/test/SimplePlusAccount.t.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.23; + +import "forge-std/src/Test.sol"; +import { PackedUserOperation } from "@account-abstraction/contracts/interfaces/PackedUserOperation.sol"; +import { SimplePlusAccount } from "../src/SimplePlusAccount.sol"; +import { SimplePlusAccountFactory } from "../src/SimplePlusAccountFactory.sol"; +import { EntryPoint } from "@account-abstraction/contracts/core/EntryPoint.sol"; +import { SimpleAccount } from "@account-abstraction/contracts/samples/SimpleAccount.sol"; +import { AccountTest } from "./AccountTest.sol"; + +contract SimplePlusAccountTest is AccountTest { + uint256 public constant EOA_PRIVATE_KEY = 1; + address payable public constant BENEFICIARY = payable(address(0xbe9ef1c1a2ee)); + address public eoaAddress; + SimplePlusAccount public account; + EntryPoint public entryPoint; + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + function setUp() public { + eoaAddress = vm.addr(EOA_PRIVATE_KEY); + entryPoint = new EntryPoint(); + SimplePlusAccountFactory factory = new SimplePlusAccountFactory(entryPoint); + account = factory.createAccount(eoaAddress, 1); + vm.deal(address(account), 1 << 128); + } + + function testOwnerCanTransferOwnership() public { + _transferOwnership(eoaAddress, address(0x100)); + } + + function testEntryPointCanExecuteTransferOwnership() public { + address newOwner = address(0x100); + PackedUserOperation memory op = getSignedOp( + entryPoint, + uint8(SimplePlusAccount.SignatureType.EOA), + abi.encodeCall(SimplePlusAccount.transferOwnership, (newOwner)), + EOA_PRIVATE_KEY, + address(account) + ); + _executeOperation(op); + assertEq(account.owner(), newOwner); + } + + function testSelfExecuteCanTransferOwnership() public { + address newOwner = address(0x100); + PackedUserOperation memory op = getSignedOp( + entryPoint, + uint8(SimplePlusAccount.SignatureType.EOA), + abi.encodeCall( + SimpleAccount.execute, + (address(account), 0, abi.encodeCall(SimplePlusAccount.transferOwnership, (newOwner))) + ), + EOA_PRIVATE_KEY, + address(account) + ); + _executeOperation(op); + assertEq(account.owner(), newOwner); + } + + function testCannotTransferOwnershipToSelf() public { + vm.prank(eoaAddress); + vm.expectRevert(abi.encodeWithSelector(SimplePlusAccount.InvalidOwner.selector, (eoaAddress))); + account.transferOwnership(eoaAddress); + } + + function testUnauthorizedCannotTransferOwnership() public { + vm.expectRevert(abi.encodeWithSelector(SimplePlusAccount.NotAuthorized.selector)); + account.transferOwnership(address(0x100)); + } + + function testCannotTransferOwnershipToZeroAddress() public { + vm.prank(eoaAddress); + vm.expectRevert(abi.encodeWithSelector(SimplePlusAccount.InvalidOwner.selector, (address(0)))); + account.transferOwnership(address(0)); + } + + function testCannotTransferOwnershipToContractItself() public { + vm.prank(eoaAddress); + vm.expectRevert(abi.encodeWithSelector(SimplePlusAccount.InvalidOwner.selector, (address(account)))); + account.transferOwnership(address(account)); + } + + function _transferOwnership(address currentOwner, address newOwner) internal { + vm.prank(currentOwner); + vm.expectEmit(true, true, false, false); + emit OwnershipTransferred(currentOwner, newOwner); + account.transferOwnership(newOwner); + assertEq(account.owner(), newOwner); + } + + function _executeOperation(PackedUserOperation memory op) internal { + PackedUserOperation[] memory ops = new PackedUserOperation[](1); + ops[0] = op; + vm.expectEmit(true, true, false, false); + emit OwnershipTransferred(eoaAddress, address(0x100)); + entryPoint.handleOps(ops, BENEFICIARY); + } +} \ No newline at end of file