Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ERC7821 to Account.sol #49

Merged
merged 12 commits into from
Dec 24, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading