diff --git a/CHANGELOG.md b/CHANGELOG.md index 521bb64..8291696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 23-12-2024 + +- `AccountERC7821`: Account implementation that implements ERC-7821 for minimal batch execution interface. No support for additional `opData` is included. + ## 16-12-2024 - `AccountCore`: Added a simple ERC-4337 account implementation with minimal logic to process user operations. diff --git a/contracts/account/Account.sol b/contracts/account/Account.sol index bc6aeda..b43d388 100644 --- a/contracts/account/Account.sol +++ b/contracts/account/Account.sol @@ -6,14 +6,16 @@ import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Hol import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; import {ERC7739Signer} from "../utils/cryptography/ERC7739Signer.sol"; import {AccountCore} from "./AccountCore.sol"; +import {AccountERC7821} from "./extensions/AccountERC7821.sol"; /** * @dev Extension of {AccountCore} with recommended feature that most account abstraction implementation will want: * + * * {AccountERC7821} for performing external calls in batches. * * {ERC721Holder} and {ERC1155Holder} to accept ERC-712 and ERC-1155 token transfers transfers. * * {ERC7739Signer} for ERC-1271 signature support with ERC-7739 replay protection * * NOTE: To use this contract, the {ERC7739Signer-_rawSignatureValidation} function must be * implemented using a specific signature verification algorithm. See {SignerECDSA}, {SignerP256} or {SignerRSA}. */ -abstract contract Account is AccountCore, ERC721Holder, ERC1155Holder, ERC7739Signer {} +abstract contract Account is AccountCore, AccountERC7821, ERC721Holder, ERC1155Holder, ERC7739Signer {} diff --git a/contracts/account/AccountCore.sol b/contracts/account/AccountCore.sol index 8bab198..90a3b44 100644 --- a/contracts/account/AccountCore.sol +++ b/contracts/account/AccountCore.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.20; -import {PackedUserOperation, IAccount, IEntryPoint, IAccountExecute} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol"; +import {PackedUserOperation, IAccount, IEntryPoint} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol"; import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; @@ -15,11 +15,15 @@ import {AbstractSigner} from "../utils/cryptography/AbstractSigner.sol"; * * Developers must implement the {AbstractSigner-_rawSignatureValidation} function to define the account's validation logic. * + * NOTE: This core account doesn't include any mechanism for performing arbitrary external calls. This is an essential + * feature that all Account should have. We leave it up to the developers to implement the mechanism of their choice. + * Common choices include ERC-6900, ERC-7579 and ERC-7821 (among others). + * * IMPORTANT: Implementing a mechanism to validate signatures is a security-sensitive operation as it may allow an * attacker to bypass the account's security measures. Check out {SignerECDSA}, {SignerP256}, or {SignerRSA} for * digital signature validation implementations. */ -abstract contract AccountCore is AbstractSigner, EIP712, IAccount, IAccountExecute { +abstract contract AccountCore is AbstractSigner, EIP712, IAccount { using MessageHashUtils for bytes32; bytes32 internal constant _PACKED_USER_OPERATION = @@ -84,23 +88,6 @@ abstract contract AccountCore is AbstractSigner, EIP712, IAccount, IAccountExecu return validationData; } - /** - * @inheritdoc IAccountExecute - */ - function executeUserOp( - PackedUserOperation calldata userOp, - bytes32 /*userOpHash*/ - ) public virtual onlyEntryPointOrSelf { - // decode packed calldata - address target = address(bytes20(userOp.callData[4:24])); - uint256 value = uint256(bytes32(userOp.callData[24:56])); - bytes calldata data = userOp.callData[56:]; - - // we cannot use `Address.functionCallWithValue` here as it would revert on EOA targets - (bool success, bytes memory returndata) = target.call{value: value}(data); - Address.verifyCallResult(success, returndata); - } - /** * @dev Returns the digest used by an offchain signer instead of the opaque `userOpHash`. * diff --git a/contracts/account/extensions/AccountERC7821.sol b/contracts/account/extensions/AccountERC7821.sol new file mode 100644 index 0000000..5f558de --- /dev/null +++ b/contracts/account/extensions/AccountERC7821.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {ERC7579Utils, Mode, CallType, ExecType, ModeSelector} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; +import {IERC7821} from "../../interfaces/IERC7821.sol"; +import {AccountCore} from "../AccountCore.sol"; + +/** + * @dev Minimal batch executor following ERC7821. Only supports basic mode (no optional "opData"). + */ +abstract contract AccountERC7821 is AccountCore, IERC7821 { + using ERC7579Utils for *; + + error UnsupportedExecutionMode(); + + /// @inheritdoc IERC7821 + function execute(bytes32 mode, bytes calldata executionData) public payable virtual onlyEntryPointOrSelf { + if (!supportsExecutionMode(mode)) revert UnsupportedExecutionMode(); + executionData.execBatch(ERC7579Utils.EXECTYPE_DEFAULT); + } + + /// @inheritdoc IERC7821 + function supportsExecutionMode(bytes32 mode) public view virtual returns (bool result) { + (CallType callType, ExecType execType, ModeSelector modeSelector, ) = Mode.wrap(mode).decodeMode(); + return + callType == ERC7579Utils.CALLTYPE_BATCH && + execType == ERC7579Utils.EXECTYPE_DEFAULT && + modeSelector == ModeSelector.wrap(0x00000000); + } +} diff --git a/contracts/interfaces/IERC7821.sol b/contracts/interfaces/IERC7821.sol new file mode 100644 index 0000000..1607caa --- /dev/null +++ b/contracts/interfaces/IERC7821.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @dev Interface for minimal batch executor. + */ +interface IERC7821 { + /** + * @dev Executes the calls in `executionData`. + * Reverts and bubbles up error if any call fails. + * + * `executionData` encoding: + * - If `opData` is empty, `executionData` is simply `abi.encode(calls)`. + * - Else, `executionData` is `abi.encode(calls, opData)`. + * See: https://eips.ethereum.org/EIPS/eip-7579 + * + * Supported modes: + * - `bytes32(0x01000000000000000000...)`: does not support optional `opData`. + * - `bytes32(0x01000000000078210001...)`: supports optional `opData`. + * + * Authorization checks: + * - If `opData` is empty, the implementation SHOULD require that + * `msg.sender == address(this)`. + * - If `opData` is not empty, the implementation SHOULD use the signature + * encoded in `opData` to determine if the caller can perform the execution. + * + * `opData` may be used to store additional data for authentication, + * paymaster data, gas limits, etc. + */ + function execute(bytes32 mode, bytes calldata executionData) external payable; + + /** + * @dev This function is provided for frontends to detect support. + * Only returns true for: + * - `bytes32(0x01000000000000000000...)`: does not support optional `opData`. + * - `bytes32(0x01000000000078210001...)`: supports optional `opData`. + */ + function supportsExecutionMode(bytes32 mode) external view returns (bool); +} diff --git a/contracts/mocks/account/AccountBaseMock.sol b/contracts/mocks/account/AccountMock.sol similarity index 92% rename from contracts/mocks/account/AccountBaseMock.sol rename to contracts/mocks/account/AccountMock.sol index 104678c..ad01d9d 100644 --- a/contracts/mocks/account/AccountBaseMock.sol +++ b/contracts/mocks/account/AccountMock.sol @@ -6,7 +6,7 @@ import {PackedUserOperation} from "@openzeppelin/contracts/interfaces/draft-IERC import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol"; import {Account} from "../../account/Account.sol"; -abstract contract AccountBaseMock is Account { +abstract contract AccountMock is Account { /// Validates a user operation with a boolean signature. function _rawSignatureValidation( bytes32 /* userOpHash */, diff --git a/test/account/Account.behavior.js b/test/account/Account.behavior.js index f58ce2e..9e00015 100644 --- a/test/account/Account.behavior.js +++ b/test/account/Account.behavior.js @@ -4,6 +4,7 @@ const { setBalance } = require('@nomicfoundation/hardhat-network-helpers'); const { impersonate } = require('@openzeppelin/contracts/test/helpers/account'); const { SIG_VALIDATION_SUCCESS, SIG_VALIDATION_FAILURE } = require('@openzeppelin/contracts/test/helpers/erc4337'); +const { CALL_TYPE_BATCH, encodeMode, encodeBatch } = require('@openzeppelin/contracts/test/helpers/erc7579'); const { shouldSupportInterfaces, } = require('@openzeppelin/contracts/test/utils/introspection/SupportsInterface.behavior'); @@ -148,8 +149,8 @@ function shouldBehaveLikeAccountHolder() { }); } -function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) { - describe('executeUserOp', function () { +function shouldBehaveLikeAccountERC7821({ deployable = true } = {}) { + describe('execute', function () { beforeEach(async function () { // give eth to the account (before deployment) await setBalance(this.mock.target, ethers.parseEther('1')); @@ -157,30 +158,25 @@ function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) { // account is not initially deployed expect(ethers.provider.getCode(this.mock)).to.eventually.equal('0x'); - this.encodeUserOpCalldata = (to, value, calldata) => - ethers.concat([ - this.mock.interface.getFunction('executeUserOp').selector, - ethers.solidityPacked( - ['address', 'uint256', 'bytes'], - [to.target ?? to.address ?? to, value ?? 0, calldata ?? '0x'], - ), + this.encodeUserOpCalldata = (...calls) => + this.mock.interface.encodeFunctionData('execute', [ + encodeMode({ callType: CALL_TYPE_BATCH }), + encodeBatch(...calls), ]); }); it('should revert if the caller is not the canonical entrypoint or the account itself', async function () { await this.mock.deploy(); - const operation = await this.mock - .createUserOp({ - callData: this.encodeUserOpCalldata( - this.target, - 0, - this.target.interface.encodeFunctionData('mockFunctionExtra'), - ), - }) - .then(op => this.signUserOp(op)); - - await expect(this.mock.connect(this.other).executeUserOp(operation.packed, operation.hash())) + await expect( + this.mock.connect(this.other).execute( + encodeMode({ callType: CALL_TYPE_BATCH }), + encodeBatch({ + target: this.target, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), + ), + ) .to.be.revertedWithCustomError(this.mock, 'AccountUnauthorized') .withArgs(this.other); }); @@ -190,11 +186,11 @@ function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) { it('should be created with handleOps and increase nonce', async function () { const operation = await this.mock .createUserOp({ - callData: this.encodeUserOpCalldata( - this.target, - 17, - this.target.interface.encodeFunctionData('mockFunctionExtra'), - ), + callData: this.encodeUserOpCalldata({ + target: this.target, + value: 17, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), }) .then(op => op.addInitCode()) .then(op => this.signUserOp(op)); @@ -211,11 +207,11 @@ function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) { it('should revert if the signature is invalid', async function () { const operation = await this.mock .createUserOp({ - callData: this.encodeUserOpCalldata( - this.target, - 17, - this.target.interface.encodeFunctionData('mockFunctionExtra'), - ), + callData: this.encodeUserOpCalldata({ + target: this.target, + value: 17, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), }) .then(op => op.addInitCode()); @@ -234,11 +230,11 @@ function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) { it('should increase nonce and call target', async function () { const operation = await this.mock .createUserOp({ - callData: this.encodeUserOpCalldata( - this.target, - 42, - this.target.interface.encodeFunctionData('mockFunctionExtra'), - ), + callData: this.encodeUserOpCalldata({ + target: this.target, + value: 42, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }), }) .then(op => this.signUserOp(op)); @@ -251,7 +247,7 @@ function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) { it('should support sending eth to an EOA', async function () { const operation = await this.mock - .createUserOp({ callData: this.encodeUserOpCalldata(this.other, value) }) + .createUserOp({ callData: this.encodeUserOpCalldata({ target: this.other, value }) }) .then(op => this.signUserOp(op)); expect(this.mock.getNonce()).to.eventually.equal(0); @@ -261,6 +257,30 @@ function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) { ); expect(this.mock.getNonce()).to.eventually.equal(1); }); + + it('should support batch execution', async function () { + const value1 = 43374337n; + const value2 = 69420n; + + const operation = await this.mock + .createUserOp({ + callData: this.encodeUserOpCalldata( + { target: this.other, value: value1 }, + { + target: this.target, + value: value2, + data: this.target.interface.encodeFunctionData('mockFunctionExtra'), + }, + ), + }) + .then(op => this.signUserOp(op)); + + expect(this.mock.getNonce()).to.eventually.equal(0); + const tx = entrypoint.handleOps([operation.packed], this.beneficiary); + await expect(tx).to.changeEtherBalances([this.other, this.target], [value1, value2]); + await expect(tx).to.emit(this.target, 'MockFunctionCalledExtra').withArgs(this.mock, value2); + expect(this.mock.getNonce()).to.eventually.equal(1); + }); }); }); } @@ -268,5 +288,5 @@ function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) { module.exports = { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder, - shouldBehaveLikeAccountExecutor, + shouldBehaveLikeAccountERC7821, }; diff --git a/test/account/AccountBase.test.js b/test/account/Account.test.js similarity index 74% rename from test/account/AccountBase.test.js rename to test/account/Account.test.js index db613e8..b22e2e2 100644 --- a/test/account/AccountBase.test.js +++ b/test/account/Account.test.js @@ -3,7 +3,11 @@ const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { ERC4337Helper } = require('../helpers/erc4337'); const { NonNativeSigner } = require('../helpers/signers'); -const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountExecutor } = require('./Account.behavior'); +const { + shouldBehaveLikeAccountCore, + shouldBehaveLikeAccountERC7821, + shouldBehaveLikeAccountHolder, +} = require('./Account.behavior'); async function fixture() { // EOAs and environment @@ -16,7 +20,7 @@ async function fixture() { // ERC-4337 account const helper = new ERC4337Helper(); const env = await helper.wait(); - const mock = await helper.newAccount('$AccountBaseMock', ['AccountBase', '1']); + const mock = await helper.newAccount('$AccountMock', ['Account', '1']); const signUserOp = async userOp => { userOp.signature = await signer.signMessage(userOp.hash()); @@ -26,11 +30,12 @@ async function fixture() { return { ...env, mock, signer, target, beneficiary, other, signUserOp }; } -describe('AccountBase', function () { +describe('Account', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)); }); shouldBehaveLikeAccountCore(); - shouldBehaveLikeAccountExecutor(); + shouldBehaveLikeAccountERC7821(); + shouldBehaveLikeAccountHolder(); }); diff --git a/test/account/AccountECDSA.test.js b/test/account/AccountECDSA.test.js index 346e90b..9ea61c3 100644 --- a/test/account/AccountECDSA.test.js +++ b/test/account/AccountECDSA.test.js @@ -5,7 +5,7 @@ const { PackedUserOperation } = require('../helpers/eip712-types'); const { shouldBehaveLikeAccountCore, - shouldBehaveLikeAccountExecutor, + shouldBehaveLikeAccountERC7821, shouldBehaveLikeAccountHolder, } = require('./Account.behavior'); const { shouldBehaveLikeERC7739Signer } = require('../utils/cryptography/ERC7739Signer.behavior'); @@ -45,7 +45,7 @@ describe('AccountECDSA', function () { }); shouldBehaveLikeAccountCore(); - shouldBehaveLikeAccountExecutor(); + shouldBehaveLikeAccountERC7821(); shouldBehaveLikeAccountHolder(); describe('ERC7739Signer', function () { diff --git a/test/account/AccountERC7702.test.js b/test/account/AccountERC7702.test.js index ee0b29a..526ce4c 100644 --- a/test/account/AccountERC7702.test.js +++ b/test/account/AccountERC7702.test.js @@ -5,7 +5,7 @@ const { PackedUserOperation } = require('../helpers/eip712-types'); const { shouldBehaveLikeAccountCore, - shouldBehaveLikeAccountExecutor, + shouldBehaveLikeAccountERC7821, shouldBehaveLikeAccountHolder, } = require('./Account.behavior'); const { shouldBehaveLikeERC7739Signer } = require('../utils/cryptography/ERC7739Signer.behavior'); @@ -45,7 +45,7 @@ describe('AccountERC7702', function () { }); shouldBehaveLikeAccountCore(); - shouldBehaveLikeAccountExecutor({ deployable: false }); + shouldBehaveLikeAccountERC7821({ deployable: false }); shouldBehaveLikeAccountHolder(); describe('ERC7739Signer', function () { diff --git a/test/account/AccountP256.test.js b/test/account/AccountP256.test.js index 3b33bd8..6ffc06f 100644 --- a/test/account/AccountP256.test.js +++ b/test/account/AccountP256.test.js @@ -6,7 +6,7 @@ const { PackedUserOperation } = require('../helpers/eip712-types'); const { shouldBehaveLikeAccountCore, - shouldBehaveLikeAccountExecutor, + shouldBehaveLikeAccountERC7821, shouldBehaveLikeAccountHolder, } = require('./Account.behavior'); const { shouldBehaveLikeERC7739Signer } = require('../utils/cryptography/ERC7739Signer.behavior'); @@ -51,7 +51,7 @@ describe('AccountP256', function () { }); shouldBehaveLikeAccountCore(); - shouldBehaveLikeAccountExecutor(); + shouldBehaveLikeAccountERC7821(); shouldBehaveLikeAccountHolder(); describe('ERC7739Signer', function () { diff --git a/test/account/AccountRSA.test.js b/test/account/AccountRSA.test.js index 35b0bfc..ad89eae 100644 --- a/test/account/AccountRSA.test.js +++ b/test/account/AccountRSA.test.js @@ -6,7 +6,7 @@ const { PackedUserOperation } = require('../helpers/eip712-types'); const { shouldBehaveLikeAccountCore, - shouldBehaveLikeAccountExecutor, + shouldBehaveLikeAccountERC7821, shouldBehaveLikeAccountHolder, } = require('./Account.behavior'); const { shouldBehaveLikeERC7739Signer } = require('../utils/cryptography/ERC7739Signer.behavior'); @@ -51,7 +51,7 @@ describe('AccountRSA', function () { }); shouldBehaveLikeAccountCore(); - shouldBehaveLikeAccountExecutor(); + shouldBehaveLikeAccountERC7821(); shouldBehaveLikeAccountHolder(); describe('ERC7739Signer', function () {