diff --git a/contracts/account/AccountFactory.sol b/contracts/account/AccountFactory.sol new file mode 100644 index 00000000..eb47a6f0 --- /dev/null +++ b/contracts/account/AccountFactory.sol @@ -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)); + } +} diff --git a/contracts/account/README.adoc b/contracts/account/README.adoc index 122f794a..2010a906 100644 --- a/contracts/account/README.adoc +++ b/contracts/account/README.adoc @@ -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. @@ -25,6 +26,8 @@ This directory includes contracts to build accounts for ERC-4337. These include: {{Account}} +{{AccountFactory}} + == Extensions {{AccountERC7579}} diff --git a/contracts/mocks/account/AccountMock.sol b/contracts/mocks/account/AccountMock.sol index 54449002..19446b1c 100644 --- a/contracts/mocks/account/AccountMock.sol +++ b/contracts/mocks/account/AccountMock.sol @@ -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"; @@ -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); + } +} diff --git a/contracts/mocks/docs/account/MyFactoryAccount.sol b/contracts/mocks/docs/account/MyFactoryAccount.sol index 4f1fd736..894f8e07 100644 --- a/contracts/mocks/docs/account/MyFactoryAccount.sol +++ b/contracts/mocks/docs/account/MyFactoryAccount.sol @@ -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_) {} } diff --git a/docs/modules/ROOT/pages/accounts.adoc b/docs/modules/ROOT/pages/accounts.adoc index a78b6a0c..a5363a01 100644 --- a/docs/modules/ROOT/pages/accounts.adoc +++ b/docs/modules/ROOT/pages/accounts.adoc @@ -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] ---- diff --git a/test/account/AccountFactory.test.js b/test/account/AccountFactory.test.js new file mode 100644 index 00000000..d55380a0 --- /dev/null +++ b/test/account/AccountFactory.test.js @@ -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); + }); + }); +});