Skip to content

Commit

Permalink
Add ERC7821 to Account.sol (#49)
Browse files Browse the repository at this point in the history
Co-authored-by: ernestognw <[email protected]>
  • Loading branch information
Amxx and ernestognw authored Dec 24, 2024
1 parent dc1556f commit e19d51c
Show file tree
Hide file tree
Showing 12 changed files with 159 additions and 70 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 3 additions & 1 deletion contracts/account/Account.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
25 changes: 6 additions & 19 deletions contracts/account/AccountCore.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 =
Expand Down Expand Up @@ -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`.
*
Expand Down
31 changes: 31 additions & 0 deletions contracts/account/extensions/AccountERC7821.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
40 changes: 40 additions & 0 deletions contracts/interfaces/IERC7821.sol
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 */,
Expand Down
94 changes: 57 additions & 37 deletions test/account/Account.behavior.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -148,39 +149,34 @@ 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'));

// 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);
});
Expand All @@ -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));
Expand All @@ -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());

Expand All @@ -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));

Expand All @@ -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);
Expand All @@ -261,12 +257,36 @@ 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);
});
});
});
}

module.exports = {
shouldBehaveLikeAccountCore,
shouldBehaveLikeAccountHolder,
shouldBehaveLikeAccountExecutor,
shouldBehaveLikeAccountERC7821,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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());
Expand All @@ -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();
});
4 changes: 2 additions & 2 deletions test/account/AccountECDSA.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -45,7 +45,7 @@ describe('AccountECDSA', function () {
});

shouldBehaveLikeAccountCore();
shouldBehaveLikeAccountExecutor();
shouldBehaveLikeAccountERC7821();
shouldBehaveLikeAccountHolder();

describe('ERC7739Signer', function () {
Expand Down
4 changes: 2 additions & 2 deletions test/account/AccountERC7702.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -45,7 +45,7 @@ describe('AccountERC7702', function () {
});

shouldBehaveLikeAccountCore();
shouldBehaveLikeAccountExecutor({ deployable: false });
shouldBehaveLikeAccountERC7821({ deployable: false });
shouldBehaveLikeAccountHolder();

describe('ERC7739Signer', function () {
Expand Down
Loading

0 comments on commit e19d51c

Please sign in to comment.