diff --git a/contracts/samples/Simple7702Account.sol b/contracts/samples/Simple7702Account.sol new file mode 100644 index 00000000..679e8bb2 --- /dev/null +++ b/contracts/samples/Simple7702Account.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +// based on: https://gist.github.com/frangio/e40305b9f99de290b73750dff5ebe50a +pragma solidity ^0.8; + +import "../interfaces/PackedUserOperation.sol"; +import "../core/Helpers.sol"; +import "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "../core/BaseAccount.sol"; + +/** + * Simple7702Account.sol + * A minimal account to be used with EIP-7702 (for batching) and ERC-4337 (for gas sponsoring) + */ +contract Simple7702Account is BaseAccount, IERC165, IERC1271, ERC1155Holder, ERC721Holder { + + // temporary address of entryPoint v0.8 + function entryPoint() public pure override returns (IEntryPoint) { + return IEntryPoint(0x85fF6c675397CF96412c217e405F2175c249a6B2); + } + + /** + * Make this account callable through ERC-4337 EntryPoint. + * The UserOperation should be signed by this account's private key. + */ + function _validateSignature( + PackedUserOperation calldata userOp, + bytes32 userOpHash + ) internal virtual override returns (uint256 validationData) { + + if (address(this) != ECDSA.recover(userOpHash, userOp.signature)) { + return SIG_VALIDATION_FAILED; + } + return 0; + } + + function _requireFromSelfOrEntryPoint() internal view virtual { + require( + msg.sender == address(this) || + msg.sender == address(entryPoint()), + "not from self or EntryPoint" + ); + } + + struct Call { + address target; + uint256 value; + bytes data; + } + + function execute(Call[] calldata calls) external { + _requireFromSelfOrEntryPoint(); + + for (uint256 i = 0; i < calls.length; i++) { + Call calldata call = calls[i]; + (bool ok, bytes memory ret) = call.target.call{value: call.value}(call.data); + if (!ok) { + // solhint-disable-next-line no-inline-assembly + assembly { revert(add(ret, 32), mload(ret)) } + } + } + } + + function supportsInterface(bytes4 id) public override(ERC1155Holder, IERC165) pure returns (bool) { + return + id == type(IERC165).interfaceId || + id == type(IAccount).interfaceId || + id == type(IERC1271).interfaceId || + id == type(IERC1155Receiver).interfaceId || + id == type(IERC721Receiver).interfaceId; + } + + function isValidSignature(bytes32 hash, bytes memory signature) public view returns (bytes4 magicValue) { + return ECDSA.recover(hash, signature) == address(this) ? this.isValidSignature.selector : bytes4(0); + } + + // accept incoming calls (with our without value), to mimic an EOA. + fallback() external payable { + } + + receive() external payable { + } +} diff --git a/test/eip7702-wallet.test.ts b/test/eip7702-wallet.test.ts new file mode 100644 index 00000000..529a1f56 --- /dev/null +++ b/test/eip7702-wallet.test.ts @@ -0,0 +1,151 @@ +import { expect } from 'chai' + +import { Simple7702Account, Simple7702Account__factory, EntryPoint, TestPaymasterAcceptAll__factory } from '../typechain' +import { createAccountOwner, createAddress, deployEntryPoint } from './testutils' +import { fillAndSign, packUserOp } from './UserOp' +import { hexConcat, parseEther } from 'ethers/lib/utils' +import { signEip7702Authorization } from './eip7702helpers' +import { GethExecutable } from './GethExecutable' +import { Wallet } from 'ethers' + +describe('Simple7702Account.sol', function () { + // can't deploy coverage "entrypoint" on geth (contract too large) + if (process.env.COVERAGE != null) { + return + } + + let entryPoint: EntryPoint + + let eip7702delegate: Simple7702Account + let geth: GethExecutable + + before(async function () { + geth = new GethExecutable() + await geth.init() + + entryPoint = await deployEntryPoint(geth.provider) + + eip7702delegate = await new Simple7702Account__factory(geth.provider.getSigner()).deploy() + expect(await eip7702delegate.entryPoint()).to.equal(entryPoint.address, 'fix entryPoint in Simple7702Account.sol') + console.log('set eip7702delegate=', eip7702delegate.address) + }) + + after(() => { + geth.done() + }) + + describe('sanity: normal 7702 batching', () => { + let eoa: Wallet + before(async () => { + eoa = createAccountOwner(geth.provider) + + const auth = await signEip7702Authorization(eoa, { + chainId: 0, + nonce: 0, + address: eip7702delegate.address + }) + const sendVal = parseEther('10') + const tx = { + to: eoa.address, + value: sendVal.toHexString(), + gas: 1e6, + authorizationList: [auth] + } + await geth.sendTx(tx) + expect(await geth.provider.getBalance(eoa.address)).to.equal(sendVal) + expect(await geth.provider.getCode(eoa.address)).to.equal(hexConcat(['0xef0100', eip7702delegate.address])) + }) + + it('should fail call from another account', async () => { + const wallet1 = Simple7702Account__factory.connect(eoa.address, geth.provider.getSigner()) + await expect(wallet1.execute([])).to.revertedWith('not from self or EntryPoint') + }) + + it('should succeed sending a batch', async () => { + // submit a batch + const wallet2 = Simple7702Account__factory.connect(eoa.address, eoa) + console.log('eoa balance=', await geth.provider.getBalance(eoa.address)) + + const addr1 = createAddress() + const addr2 = createAddress() + + await wallet2.execute([{ + target: addr1, value: 1, data: '0x' + }, { + target: addr2, value: 2, data: '0x' + }]).then(async tx => tx.wait()) + expect(await geth.provider.getBalance(addr1)).to.equal(1) + expect(await geth.provider.getBalance(addr2)).to.equal(2) + }) + }) + + it('should be able to use EntryPoint without paymaster', async () => { + const addr1 = createAddress() + const eoa = createAccountOwner(geth.provider) + + const callData = eip7702delegate.interface.encodeFunctionData('execute', [[{ + target: addr1, + value: 1, + data: '0x' + }]]) + const userop = await fillAndSign({ + sender: eoa.address, + initCode: '0xef01', + nonce: 0, + callData + }, eoa, entryPoint, { eip7702delegate: eip7702delegate.address }) + + await geth.sendTx({ to: eoa.address, value: parseEther('1') }) + const auth = await signEip7702Authorization(eoa, { chainId: 0, nonce: 0, address: eip7702delegate.address }) + const beneficiary = createAddress() + // submit separate tx with tuple: geth's estimateGas doesn't work, and its easier to detect thrown errors.. + await geth.sendTx({ + to: entryPoint.address, + data: '0x', + gas: 1000000, + authorizationList: [auth] + }) + const handleOps = entryPoint.interface.encodeFunctionData('handleOps', [[packUserOp(userop)], beneficiary]) + const tx = { + to: entryPoint.address, + data: handleOps + } + await geth.sendTx(tx) + }) + + it('should use EntryPoint with paymaster', async () => { + const addr1 = createAddress() + const eoa = createAccountOwner(geth.provider) + const paymaster = await new TestPaymasterAcceptAll__factory(geth.provider.getSigner()).deploy(entryPoint.address) + await paymaster.deposit({ value: parseEther('1') }) + const callData = eip7702delegate.interface.encodeFunctionData('execute', [[{ + target: addr1, + value: 1, + data: '0x' + }]]) + const userop = await fillAndSign({ + sender: eoa.address, + paymaster: paymaster.address, + initCode: '0xef01', + nonce: 0, + callData + }, eoa, entryPoint, { eip7702delegate: eip7702delegate.address }) + + const auth = await signEip7702Authorization(eoa, { chainId: 0, nonce: 0, address: eip7702delegate.address }) + const beneficiary = createAddress() + console.log('delegate=', eip7702delegate.address) + // submit separate tx with tuple: geth's estimateGas doesn't work, and its easier to detect thrown errors.. + await geth.sendTx({ + to: entryPoint.address, + data: '0x', + gas: 1000000, + authorizationList: [auth] + }) + const handleOps = entryPoint.interface.encodeFunctionData('handleOps', [[packUserOp(userop)], beneficiary]) + const tx = { + to: entryPoint.address, + data: handleOps + } + await geth.sendTx(tx) + }) +})