Skip to content

Commit e19d51c

Browse files
Amxxernestognw
andauthored
Add ERC7821 to Account.sol (#49)
Co-authored-by: ernestognw <[email protected]>
1 parent dc1556f commit e19d51c

File tree

12 files changed

+159
-70
lines changed

12 files changed

+159
-70
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 23-12-2024
2+
3+
- `AccountERC7821`: Account implementation that implements ERC-7821 for minimal batch execution interface. No support for additional `opData` is included.
4+
15
## 16-12-2024
26

37
- `AccountCore`: Added a simple ERC-4337 account implementation with minimal logic to process user operations.

contracts/account/Account.sol

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Hol
66
import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol";
77
import {ERC7739Signer} from "../utils/cryptography/ERC7739Signer.sol";
88
import {AccountCore} from "./AccountCore.sol";
9+
import {AccountERC7821} from "./extensions/AccountERC7821.sol";
910

1011
/**
1112
* @dev Extension of {AccountCore} with recommended feature that most account abstraction implementation will want:
1213
*
14+
* * {AccountERC7821} for performing external calls in batches.
1315
* * {ERC721Holder} and {ERC1155Holder} to accept ERC-712 and ERC-1155 token transfers transfers.
1416
* * {ERC7739Signer} for ERC-1271 signature support with ERC-7739 replay protection
1517
*
1618
* NOTE: To use this contract, the {ERC7739Signer-_rawSignatureValidation} function must be
1719
* implemented using a specific signature verification algorithm. See {SignerECDSA}, {SignerP256} or {SignerRSA}.
1820
*/
19-
abstract contract Account is AccountCore, ERC721Holder, ERC1155Holder, ERC7739Signer {}
21+
abstract contract Account is AccountCore, AccountERC7821, ERC721Holder, ERC1155Holder, ERC7739Signer {}

contracts/account/AccountCore.sol

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
pragma solidity ^0.8.20;
44

5-
import {PackedUserOperation, IAccount, IEntryPoint, IAccountExecute} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol";
5+
import {PackedUserOperation, IAccount, IEntryPoint} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol";
66
import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
77
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
88
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
@@ -15,11 +15,15 @@ import {AbstractSigner} from "../utils/cryptography/AbstractSigner.sol";
1515
*
1616
* Developers must implement the {AbstractSigner-_rawSignatureValidation} function to define the account's validation logic.
1717
*
18+
* NOTE: This core account doesn't include any mechanism for performing arbitrary external calls. This is an essential
19+
* feature that all Account should have. We leave it up to the developers to implement the mechanism of their choice.
20+
* Common choices include ERC-6900, ERC-7579 and ERC-7821 (among others).
21+
*
1822
* IMPORTANT: Implementing a mechanism to validate signatures is a security-sensitive operation as it may allow an
1923
* attacker to bypass the account's security measures. Check out {SignerECDSA}, {SignerP256}, or {SignerRSA} for
2024
* digital signature validation implementations.
2125
*/
22-
abstract contract AccountCore is AbstractSigner, EIP712, IAccount, IAccountExecute {
26+
abstract contract AccountCore is AbstractSigner, EIP712, IAccount {
2327
using MessageHashUtils for bytes32;
2428

2529
bytes32 internal constant _PACKED_USER_OPERATION =
@@ -84,23 +88,6 @@ abstract contract AccountCore is AbstractSigner, EIP712, IAccount, IAccountExecu
8488
return validationData;
8589
}
8690

87-
/**
88-
* @inheritdoc IAccountExecute
89-
*/
90-
function executeUserOp(
91-
PackedUserOperation calldata userOp,
92-
bytes32 /*userOpHash*/
93-
) public virtual onlyEntryPointOrSelf {
94-
// decode packed calldata
95-
address target = address(bytes20(userOp.callData[4:24]));
96-
uint256 value = uint256(bytes32(userOp.callData[24:56]));
97-
bytes calldata data = userOp.callData[56:];
98-
99-
// we cannot use `Address.functionCallWithValue` here as it would revert on EOA targets
100-
(bool success, bytes memory returndata) = target.call{value: value}(data);
101-
Address.verifyCallResult(success, returndata);
102-
}
103-
10491
/**
10592
* @dev Returns the digest used by an offchain signer instead of the opaque `userOpHash`.
10693
*
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
import {ERC7579Utils, Mode, CallType, ExecType, ModeSelector} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol";
6+
import {IERC7821} from "../../interfaces/IERC7821.sol";
7+
import {AccountCore} from "../AccountCore.sol";
8+
9+
/**
10+
* @dev Minimal batch executor following ERC7821. Only supports basic mode (no optional "opData").
11+
*/
12+
abstract contract AccountERC7821 is AccountCore, IERC7821 {
13+
using ERC7579Utils for *;
14+
15+
error UnsupportedExecutionMode();
16+
17+
/// @inheritdoc IERC7821
18+
function execute(bytes32 mode, bytes calldata executionData) public payable virtual onlyEntryPointOrSelf {
19+
if (!supportsExecutionMode(mode)) revert UnsupportedExecutionMode();
20+
executionData.execBatch(ERC7579Utils.EXECTYPE_DEFAULT);
21+
}
22+
23+
/// @inheritdoc IERC7821
24+
function supportsExecutionMode(bytes32 mode) public view virtual returns (bool result) {
25+
(CallType callType, ExecType execType, ModeSelector modeSelector, ) = Mode.wrap(mode).decodeMode();
26+
return
27+
callType == ERC7579Utils.CALLTYPE_BATCH &&
28+
execType == ERC7579Utils.EXECTYPE_DEFAULT &&
29+
modeSelector == ModeSelector.wrap(0x00000000);
30+
}
31+
}

contracts/interfaces/IERC7821.sol

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.0;
4+
5+
/**
6+
* @dev Interface for minimal batch executor.
7+
*/
8+
interface IERC7821 {
9+
/**
10+
* @dev Executes the calls in `executionData`.
11+
* Reverts and bubbles up error if any call fails.
12+
*
13+
* `executionData` encoding:
14+
* - If `opData` is empty, `executionData` is simply `abi.encode(calls)`.
15+
* - Else, `executionData` is `abi.encode(calls, opData)`.
16+
* See: https://eips.ethereum.org/EIPS/eip-7579
17+
*
18+
* Supported modes:
19+
* - `bytes32(0x01000000000000000000...)`: does not support optional `opData`.
20+
* - `bytes32(0x01000000000078210001...)`: supports optional `opData`.
21+
*
22+
* Authorization checks:
23+
* - If `opData` is empty, the implementation SHOULD require that
24+
* `msg.sender == address(this)`.
25+
* - If `opData` is not empty, the implementation SHOULD use the signature
26+
* encoded in `opData` to determine if the caller can perform the execution.
27+
*
28+
* `opData` may be used to store additional data for authentication,
29+
* paymaster data, gas limits, etc.
30+
*/
31+
function execute(bytes32 mode, bytes calldata executionData) external payable;
32+
33+
/**
34+
* @dev This function is provided for frontends to detect support.
35+
* Only returns true for:
36+
* - `bytes32(0x01000000000000000000...)`: does not support optional `opData`.
37+
* - `bytes32(0x01000000000078210001...)`: supports optional `opData`.
38+
*/
39+
function supportsExecutionMode(bytes32 mode) external view returns (bool);
40+
}

contracts/mocks/account/AccountBaseMock.sol renamed to contracts/mocks/account/AccountMock.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {PackedUserOperation} from "@openzeppelin/contracts/interfaces/draft-IERC
66
import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
77
import {Account} from "../../account/Account.sol";
88

9-
abstract contract AccountBaseMock is Account {
9+
abstract contract AccountMock is Account {
1010
/// Validates a user operation with a boolean signature.
1111
function _rawSignatureValidation(
1212
bytes32 /* userOpHash */,

test/account/Account.behavior.js

Lines changed: 57 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const { setBalance } = require('@nomicfoundation/hardhat-network-helpers');
44

55
const { impersonate } = require('@openzeppelin/contracts/test/helpers/account');
66
const { SIG_VALIDATION_SUCCESS, SIG_VALIDATION_FAILURE } = require('@openzeppelin/contracts/test/helpers/erc4337');
7+
const { CALL_TYPE_BATCH, encodeMode, encodeBatch } = require('@openzeppelin/contracts/test/helpers/erc7579');
78
const {
89
shouldSupportInterfaces,
910
} = require('@openzeppelin/contracts/test/utils/introspection/SupportsInterface.behavior');
@@ -148,39 +149,34 @@ function shouldBehaveLikeAccountHolder() {
148149
});
149150
}
150151

151-
function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) {
152-
describe('executeUserOp', function () {
152+
function shouldBehaveLikeAccountERC7821({ deployable = true } = {}) {
153+
describe('execute', function () {
153154
beforeEach(async function () {
154155
// give eth to the account (before deployment)
155156
await setBalance(this.mock.target, ethers.parseEther('1'));
156157

157158
// account is not initially deployed
158159
expect(ethers.provider.getCode(this.mock)).to.eventually.equal('0x');
159160

160-
this.encodeUserOpCalldata = (to, value, calldata) =>
161-
ethers.concat([
162-
this.mock.interface.getFunction('executeUserOp').selector,
163-
ethers.solidityPacked(
164-
['address', 'uint256', 'bytes'],
165-
[to.target ?? to.address ?? to, value ?? 0, calldata ?? '0x'],
166-
),
161+
this.encodeUserOpCalldata = (...calls) =>
162+
this.mock.interface.encodeFunctionData('execute', [
163+
encodeMode({ callType: CALL_TYPE_BATCH }),
164+
encodeBatch(...calls),
167165
]);
168166
});
169167

170168
it('should revert if the caller is not the canonical entrypoint or the account itself', async function () {
171169
await this.mock.deploy();
172170

173-
const operation = await this.mock
174-
.createUserOp({
175-
callData: this.encodeUserOpCalldata(
176-
this.target,
177-
0,
178-
this.target.interface.encodeFunctionData('mockFunctionExtra'),
179-
),
180-
})
181-
.then(op => this.signUserOp(op));
182-
183-
await expect(this.mock.connect(this.other).executeUserOp(operation.packed, operation.hash()))
171+
await expect(
172+
this.mock.connect(this.other).execute(
173+
encodeMode({ callType: CALL_TYPE_BATCH }),
174+
encodeBatch({
175+
target: this.target,
176+
data: this.target.interface.encodeFunctionData('mockFunctionExtra'),
177+
}),
178+
),
179+
)
184180
.to.be.revertedWithCustomError(this.mock, 'AccountUnauthorized')
185181
.withArgs(this.other);
186182
});
@@ -190,11 +186,11 @@ function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) {
190186
it('should be created with handleOps and increase nonce', async function () {
191187
const operation = await this.mock
192188
.createUserOp({
193-
callData: this.encodeUserOpCalldata(
194-
this.target,
195-
17,
196-
this.target.interface.encodeFunctionData('mockFunctionExtra'),
197-
),
189+
callData: this.encodeUserOpCalldata({
190+
target: this.target,
191+
value: 17,
192+
data: this.target.interface.encodeFunctionData('mockFunctionExtra'),
193+
}),
198194
})
199195
.then(op => op.addInitCode())
200196
.then(op => this.signUserOp(op));
@@ -211,11 +207,11 @@ function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) {
211207
it('should revert if the signature is invalid', async function () {
212208
const operation = await this.mock
213209
.createUserOp({
214-
callData: this.encodeUserOpCalldata(
215-
this.target,
216-
17,
217-
this.target.interface.encodeFunctionData('mockFunctionExtra'),
218-
),
210+
callData: this.encodeUserOpCalldata({
211+
target: this.target,
212+
value: 17,
213+
data: this.target.interface.encodeFunctionData('mockFunctionExtra'),
214+
}),
219215
})
220216
.then(op => op.addInitCode());
221217

@@ -234,11 +230,11 @@ function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) {
234230
it('should increase nonce and call target', async function () {
235231
const operation = await this.mock
236232
.createUserOp({
237-
callData: this.encodeUserOpCalldata(
238-
this.target,
239-
42,
240-
this.target.interface.encodeFunctionData('mockFunctionExtra'),
241-
),
233+
callData: this.encodeUserOpCalldata({
234+
target: this.target,
235+
value: 42,
236+
data: this.target.interface.encodeFunctionData('mockFunctionExtra'),
237+
}),
242238
})
243239
.then(op => this.signUserOp(op));
244240

@@ -251,7 +247,7 @@ function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) {
251247

252248
it('should support sending eth to an EOA', async function () {
253249
const operation = await this.mock
254-
.createUserOp({ callData: this.encodeUserOpCalldata(this.other, value) })
250+
.createUserOp({ callData: this.encodeUserOpCalldata({ target: this.other, value }) })
255251
.then(op => this.signUserOp(op));
256252

257253
expect(this.mock.getNonce()).to.eventually.equal(0);
@@ -261,12 +257,36 @@ function shouldBehaveLikeAccountExecutor({ deployable = true } = {}) {
261257
);
262258
expect(this.mock.getNonce()).to.eventually.equal(1);
263259
});
260+
261+
it('should support batch execution', async function () {
262+
const value1 = 43374337n;
263+
const value2 = 69420n;
264+
265+
const operation = await this.mock
266+
.createUserOp({
267+
callData: this.encodeUserOpCalldata(
268+
{ target: this.other, value: value1 },
269+
{
270+
target: this.target,
271+
value: value2,
272+
data: this.target.interface.encodeFunctionData('mockFunctionExtra'),
273+
},
274+
),
275+
})
276+
.then(op => this.signUserOp(op));
277+
278+
expect(this.mock.getNonce()).to.eventually.equal(0);
279+
const tx = entrypoint.handleOps([operation.packed], this.beneficiary);
280+
await expect(tx).to.changeEtherBalances([this.other, this.target], [value1, value2]);
281+
await expect(tx).to.emit(this.target, 'MockFunctionCalledExtra').withArgs(this.mock, value2);
282+
expect(this.mock.getNonce()).to.eventually.equal(1);
283+
});
264284
});
265285
});
266286
}
267287

268288
module.exports = {
269289
shouldBehaveLikeAccountCore,
270290
shouldBehaveLikeAccountHolder,
271-
shouldBehaveLikeAccountExecutor,
291+
shouldBehaveLikeAccountERC7821,
272292
};

test/account/AccountBase.test.js renamed to test/account/Account.test.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
33
const { ERC4337Helper } = require('../helpers/erc4337');
44
const { NonNativeSigner } = require('../helpers/signers');
55

6-
const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountExecutor } = require('./Account.behavior');
6+
const {
7+
shouldBehaveLikeAccountCore,
8+
shouldBehaveLikeAccountERC7821,
9+
shouldBehaveLikeAccountHolder,
10+
} = require('./Account.behavior');
711

812
async function fixture() {
913
// EOAs and environment
@@ -16,7 +20,7 @@ async function fixture() {
1620
// ERC-4337 account
1721
const helper = new ERC4337Helper();
1822
const env = await helper.wait();
19-
const mock = await helper.newAccount('$AccountBaseMock', ['AccountBase', '1']);
23+
const mock = await helper.newAccount('$AccountMock', ['Account', '1']);
2024

2125
const signUserOp = async userOp => {
2226
userOp.signature = await signer.signMessage(userOp.hash());
@@ -26,11 +30,12 @@ async function fixture() {
2630
return { ...env, mock, signer, target, beneficiary, other, signUserOp };
2731
}
2832

29-
describe('AccountBase', function () {
33+
describe('Account', function () {
3034
beforeEach(async function () {
3135
Object.assign(this, await loadFixture(fixture));
3236
});
3337

3438
shouldBehaveLikeAccountCore();
35-
shouldBehaveLikeAccountExecutor();
39+
shouldBehaveLikeAccountERC7821();
40+
shouldBehaveLikeAccountHolder();
3641
});

test/account/AccountECDSA.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const { PackedUserOperation } = require('../helpers/eip712-types');
55

66
const {
77
shouldBehaveLikeAccountCore,
8-
shouldBehaveLikeAccountExecutor,
8+
shouldBehaveLikeAccountERC7821,
99
shouldBehaveLikeAccountHolder,
1010
} = require('./Account.behavior');
1111
const { shouldBehaveLikeERC7739Signer } = require('../utils/cryptography/ERC7739Signer.behavior');
@@ -45,7 +45,7 @@ describe('AccountECDSA', function () {
4545
});
4646

4747
shouldBehaveLikeAccountCore();
48-
shouldBehaveLikeAccountExecutor();
48+
shouldBehaveLikeAccountERC7821();
4949
shouldBehaveLikeAccountHolder();
5050

5151
describe('ERC7739Signer', function () {

test/account/AccountERC7702.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const { PackedUserOperation } = require('../helpers/eip712-types');
55

66
const {
77
shouldBehaveLikeAccountCore,
8-
shouldBehaveLikeAccountExecutor,
8+
shouldBehaveLikeAccountERC7821,
99
shouldBehaveLikeAccountHolder,
1010
} = require('./Account.behavior');
1111
const { shouldBehaveLikeERC7739Signer } = require('../utils/cryptography/ERC7739Signer.behavior');
@@ -45,7 +45,7 @@ describe('AccountERC7702', function () {
4545
});
4646

4747
shouldBehaveLikeAccountCore();
48-
shouldBehaveLikeAccountExecutor({ deployable: false });
48+
shouldBehaveLikeAccountERC7821({ deployable: false });
4949
shouldBehaveLikeAccountHolder();
5050

5151
describe('ERC7739Signer', function () {

0 commit comments

Comments
 (0)