Skip to content

Add AccountFactory contract #134

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

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
67 changes: 67 additions & 0 deletions contracts/account/AccountFactory.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// contracts/MyFactoryAccount.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";

/**
* @dev A factory contract to create smart accounts on demand.
*
* This factory takes an opinionated approach of using initializable
* https://docs.openzeppelin.com/contracts/5.x/api/proxy#Clones[clones]. However,
* it is possible to create account factories that don't require initialization
* or use other deployment patterns.
*
* See {predictAddress} and {cloneAndInitialize} for details on how to create accounts.
*/
contract AccountFactory {
using Clones for address;
using Address for address;

/// @dev Emitted when a new account is created.
event AccountCreated(address indexed account, bytes32 salt);

address private immutable _impl;

/// @dev Sets the implementation contract address to be used for cloning accounts.
constructor(address impl_) {
_impl = impl_;
}

/// @dev Predict the address of the account
function predictAddress(bytes32 salt, bytes calldata callData) public view virtual returns (address, bytes32) {
bytes32 calldataSalt = _saltedCallData(salt, callData);
return (_impl.predictDeterministicAddress(calldataSalt, address(this)), calldataSalt);
}

/**
* @dev Create clone accounts on demand and return the address. Uses `callData` to initialize the clone.
*
* NOTE: The function will not revert if the predicted address already exists. Instead, it will return the existing address.
*
* WARNING: Make sure the ownership of the account is tied to `callData` to avoid front-running the salt with a
* malicious `callData` to steal the account.
*/
function cloneAndInitialize(bytes32 salt, bytes calldata callData) public virtual returns (address) {
return _cloneAndInitialize(salt, callData);
}

/// @dev Same as {cloneAndInitialize}, but internal.
function _cloneAndInitialize(bytes32 salt, bytes calldata callData) internal virtual returns (address) {
(address predicted, bytes32 _calldataSalt) = predictAddress(salt, callData);
if (predicted.code.length == 0) {
_impl.cloneDeterministic(_calldataSalt);
predicted.functionCall(callData);
emit AccountCreated(predicted, salt);
}
return predicted;
}

/// @dev Creates a unique that includes the initialization arguments (i.e. `callData`) as part of the salt.
function _saltedCallData(bytes32 salt, bytes calldata callData) internal pure virtual returns (bytes32) {
// Scope salt to the callData to avoid front-running the salt with a different callData
return keccak256(abi.encodePacked(salt, callData));
}
}
3 changes: 3 additions & 0 deletions contracts/account/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ NOTE: This document is better viewed at https://docs.openzeppelin.com/community-
This directory includes contracts to build accounts for ERC-4337. These include:

* {Account}: An ERC-4337 smart account implementation that includes the core logic to process user operations.
* {AccountFactory}: A factory contract that uses https://docs.openzeppelin.com/contracts/5.x/api/proxy#Clones[clones][Clones] to deploy deterministic smart accounts with predictable addresses.
* {AccountERC7579}: An extension of `Account` that implements support for ERC-7579 modules.
* {AccountERC7579Hooked}: An extension of `AccountERC7579` with support for a single hook module (type 4).
* {ERC7821}: Minimal batch executor implementation contracts. Useful to enable easy batch execution for smart contracts.
Expand All @@ -25,6 +26,8 @@ This directory includes contracts to build accounts for ERC-4337. These include:

{{Account}}

{{AccountFactory}}

== Extensions

{{AccountERC7579}}
Expand Down
14 changes: 14 additions & 0 deletions contracts/mocks/account/AccountMock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

pragma solidity ^0.8.20;

import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
import {Account} from "../../account/Account.sol";
import {ERC721Holder} from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol";
import {ERC1155Holder} from "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol";
Expand All @@ -25,3 +26,16 @@ abstract contract AccountMock is Account, ERC7739, ERC7821, ERC721Holder, ERC115
return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData);
}
}

abstract contract AccountInitializableMock is Initializable, AccountMock {
function initialize() public initializer {}

/// @inheritdoc ERC7821
function _erc7821AuthorizedExecutor(
address caller,
bytes32 mode,
bytes calldata executionData
) internal view virtual override returns (bool) {
return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData);
}
}
43 changes: 3 additions & 40 deletions contracts/mocks/docs/account/MyFactoryAccount.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,8 @@

pragma solidity ^0.8.20;

import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {AccountFactory} from "../../../account/AccountFactory.sol";

/**
* @dev A factory contract to create accounts on demand.
*/
contract MyFactoryAccount {
using Clones for address;
using Address for address;

address private immutable _impl;

constructor(address impl_) {
_impl = impl_;
}

/// @dev Predict the address of the account
function predictAddress(bytes32 salt, bytes calldata callData) public view returns (address, bytes32) {
bytes32 calldataSalt = _saltedCallData(salt, callData);
return (_impl.predictDeterministicAddress(calldataSalt, address(this)), calldataSalt);
}

/// @dev Create clone accounts on demand
function cloneAndInitialize(bytes32 salt, bytes calldata callData) public returns (address) {
return _cloneAndInitialize(salt, callData);
}

/// @dev Create clone accounts on demand and return the address. Uses `callData` to initialize the clone.
function _cloneAndInitialize(bytes32 salt, bytes calldata callData) internal returns (address) {
(address predicted, bytes32 _calldataSalt) = predictAddress(salt, callData);
if (predicted.code.length == 0) {
_impl.cloneDeterministic(_calldataSalt);
predicted.functionCall(callData);
}
return predicted;
}

function _saltedCallData(bytes32 salt, bytes calldata callData) internal pure returns (bytes32) {
// Scope salt to the callData to avoid front-running the salt with a different callData
return keccak256(abi.encodePacked(salt, callData));
}
contract MyFactoryAccount is AccountFactory {
constructor(address impl_) AccountFactory(impl_) {}
}
4 changes: 3 additions & 1 deletion docs/modules/ROOT/pages/accounts.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ TIP: Given xref:api:utils/cryptography.adoc#SignerERC7913[`SignerERC7913`] provi

The first time you send an user operation, your account will be created deterministically (i.e. its code and address can be predicted) using the the `initCode` field in the UserOperation. This field contains both the address of a smart contract (the factory) and the data required to call it and create your smart account.

Suggestively, you can create your own account factory using the https://docs.openzeppelin.com/contracts/5.x/api/proxy#Clones[Clones library from OpenZeppelin Contracts], taking advantage of decreased deployment costs and account address predictability.
The library offers an xref:api:account.adoc#AccountFactory[AccountFactory] contract you can start with. It uses the https://docs.openzeppelin.com/contracts/5.x/api/proxy#Clones[Clones library from OpenZeppelin Contracts] to take advantage of decreased deployment costs and account address predictability.

TIP: Custom factory implementations may use a different approach to initializable clones. For example, some implementations could leverage https://docs.openzeppelin.com/contracts/5.x/api/proxy#Clones-cloneWithImmutableArgs-address-bytes-[clones with immutable arguments].

[source,solidity]
----
Expand Down
130 changes: 130 additions & 0 deletions test/account/AccountFactory.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');

const cloneInitCode = instance => ethers.concat(['0x3d602d80600a3d3981f3', cloneRuntimeCode(instance)]);
const cloneRuntimeCode = instance =>
ethers.concat([
'0x363d3d373d3d3d363d73',
instance.target ?? instance.address ?? instance,
'0x5af43d82803e903d91602b57fd5bf3',
]);

async function fixture() {
const [other] = await ethers.getSigners();

// ERC-4337 account implementation. Implementation does not require initial values
const accountImpl = await ethers.deployContract('$AccountInitializableMock', ['', '']);

// ERC-4337 factory
const factory = await ethers.deployContract('$AccountFactory', [accountImpl.target]);

// Initialize function calldata
const initializeData = accountImpl.interface.encodeFunctionData('initialize', []);

return { other, factory, accountImpl, initializeData };
}

describe('AccountFactory', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});

describe('predictAddress', function () {
it('should predict the correct account address', async function () {
const salt = ethers.randomBytes(32);
const [predictedAddress, saltedCallData] = await this.factory.predictAddress(salt, this.initializeData);
const expectedAddress = ethers.getCreate2Address(
this.factory.target,
saltedCallData,
ethers.keccak256(cloneInitCode(this.accountImpl)),
);
expect(predictedAddress).to.equal(expectedAddress);
});

it('should return the same predicted address for the same salt and calldata', async function () {
const salt = ethers.randomBytes(32);
const [predictedAddress1, saltedCallData1] = await this.factory.predictAddress(salt, this.initializeData);
const [predictedAddress2, saltedCallData2] = await this.factory.predictAddress(salt, this.initializeData);
expect(predictedAddress1).to.equal(predictedAddress2);
expect(saltedCallData1).to.equal(saltedCallData2);
});

it('should return different addresses for different salts', async function () {
const salt1 = ethers.randomBytes(32);
const salt2 = ethers.randomBytes(32);
const [predictedAddress1] = await this.factory.predictAddress(salt1, this.initializeData);
const [predictedAddress2] = await this.factory.predictAddress(salt2, this.initializeData);
expect(predictedAddress1).to.not.equal(predictedAddress2);
});

it('should return different addresses for different calldata', async function () {
const salt = ethers.randomBytes(32);
const initializeData1 = ethers.concat([this.initializeData, '0x00']);
const initializeData2 = ethers.concat([this.initializeData, '0x01']);
const [predictedAddress1] = await this.factory.predictAddress(salt, initializeData1);
const [predictedAddress2] = await this.factory.predictAddress(salt, initializeData2);
expect(predictedAddress1).to.not.equal(predictedAddress2);
});
});

describe('cloneAndInitialize', function () {
it('should create and initialize a new account', async function () {
const salt = ethers.randomBytes(32);
const [predictedAddress] = await this.factory.predictAddress(salt, this.initializeData);
await expect(ethers.provider.getCode(predictedAddress)).to.eventually.equal('0x');
await this.factory.cloneAndInitialize(salt, this.initializeData);
await expect(ethers.provider.getCode(predictedAddress)).to.eventually.eq(cloneRuntimeCode(this.accountImpl));

const deployedClone = this.accountImpl.attach(predictedAddress);
await expect(deployedClone.initialize()).to.be.reverted;
});

it('should emit AccountCreated event once when a new account is created', async function () {
const salt = ethers.randomBytes(32);
const [predictedAddress] = await this.factory.predictAddress(salt, this.initializeData);

// First deployment (should emit event)
await expect(this.factory.cloneAndInitialize(salt, this.initializeData))
.to.emit(this.factory, 'AccountCreated')
.withArgs(predictedAddress, salt);

// Second deployment attempt (should not emit event)
await expect(this.factory.cloneAndInitialize(salt, this.initializeData)).to.not.emit(
this.factory,
'AccountCreated',
);
});

it('should return the existing account if already deployed', async function () {
const salt = ethers.randomBytes(32);

// First deployment
await this.factory.cloneAndInitialize(salt, this.initializeData);
const [cloneAddress1] = await this.factory.predictAddress(salt, this.initializeData);

// Second deployment attempt (should return the existing clone)
await this.factory.cloneAndInitialize(salt, this.initializeData);
const [cloneAddress2] = await this.factory.predictAddress(salt, this.initializeData);

expect(cloneAddress1).to.equal(cloneAddress2);
});

it('should deploy accounts with different salts at different addresses', async function () {
const salt1 = ethers.randomBytes(32);
const salt2 = ethers.randomBytes(32);
await this.factory.cloneAndInitialize(salt1, this.initializeData);
await this.factory.cloneAndInitialize(salt2, this.initializeData);

// Addresses differ
const [predicted1] = await this.factory.predictAddress(salt1, this.initializeData);
const [predicted2] = await this.factory.predictAddress(salt2, this.initializeData);
expect(predicted1).to.not.equal(predicted2);

// Could should be the same regardless
const code1 = await ethers.provider.getCode(predicted1);
const code2 = await ethers.provider.getCode(predicted2);
expect(code1).to.be.eq(code2);
});
});
});