Skip to content

Commit

Permalink
✨ develop the one-step create account flow
Browse files Browse the repository at this point in the history
This commit implements the function to create and initializing an
account. This function can be used in a one-step flow
where the account is created and directly initialized with a signer.
In addition to the unit tests, a new integration test has been added
to garantee the deterministic deployment of the account matches the
formulas defined in the getAddress function and both flow works the same.
It's important to note this function also deploy a ERC1967Proxy for the
user using the provided login hash. The additionnal arguments are used to
initialize the proxy with the provided signer.
A script has been written to call the function from the CLI.
Last but not least, it is important to mention this function can revert
either by the recovery library with use or by the factory itself when the
signature provided is valid but the signer is not the one expected.

Some notes have been added at the end of the file to give some details
about the factory contract and the natspec documentation of the contract
has been written.
  • Loading branch information
qd-qd committed Nov 24, 2023
1 parent 2050baf commit 3559b3e
Show file tree
Hide file tree
Showing 6 changed files with 282 additions and 0 deletions.
30 changes: 30 additions & 0 deletions script/AccountFactory/CreateAndInitAccount.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: APACHE-2.0
pragma solidity >=0.8.19 <0.9.0;

import { AccountFactory } from "src/AccountFactory.sol";
import { BaseScript } from "../Base.s.sol";

/// @title Create an Account using an already deployed AccountFactory and init it
/**
* @notice
* forge script CreateAndInitAccount --sig run(address,uint256,uint256,bytes32,bytes,bytes)"
* " <...args> <...flags>
*/
/// @dev If you need to deploy an AccountFactory, use the Deploy script in this directory
contract CreateAndInitAccount is BaseScript {
function run(
address factoryAddress,
uint256 pubKeyX,
uint256 pubKeyY,
bytes32 loginHash,
bytes calldata credId,
bytes calldata nameServiceSignature // ℹ️ must be made by the nameServiceOwner of the AccountFactory
)
public
broadcaster
returns (address)
{
AccountFactory factory = AccountFactory(factoryAddress);
return factory.createAndInitAccount(pubKeyX, pubKeyY, loginHash, credId, nameServiceSignature);
}
}
82 changes: 82 additions & 0 deletions src/AccountFactory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,25 @@ import { ECDSA } from "@openzeppelin/utils/cryptography/ECDSA.sol";
import { MessageHashUtils } from "@openzeppelin/utils/cryptography/MessageHashUtils.sol";
import { Account } from "./Account.sol";

// TODO: Implement an universal registry and use it to store the value of `nameServiceOwner`

/// @title 4337-compliant Account Factory
/// @notice This contract is a 4337-compliant factory for smart-accounts. It is in charge of deploying an account
/// implementation during its construction, then deploying proxies for the users. The proxies are deployed
/// using the CREATE2 opcode and they use the implementation contract deployed on construction as a
/// reference. For the 1-step onboarding purpose, the factory can also be in charge of setting the first
/// signer of the account, leading to a fully-setup account for the user.
/// @dev The name service signature is only used to set the first-signer to the account. It is a EIP-191 message
/// signed by the nameServiceOwner. The message is the keccak256 hash of the login of the account.
contract AccountFactory {
address public immutable accountImplementation;
address public immutable nameServiceOwner;

event AccountCreatedAndInit(bytes32 loginHash, address account, bytes credId, uint256 pubKeyX, uint256 pubKeyY);
event AccountCreated(bytes32 loginHash, address account);

error InvalidNameServiceSignature(bytes32 loginHash, bytes nameServiceSignature);

/// @notice Deploy the implementation of the account and store its address in the storage of the factory. This
/// implementation will be used as the implementation reference
/// for all the proxies deployed by this factory.
Expand Down Expand Up @@ -49,6 +62,56 @@ contract AccountFactory {
return recoveredAddress == nameServiceOwner;
}

/// @notice This is the one-step scenario. This function either deploys an account and sets its first signer
/// or returns the address of an existing account based on the parameter given
/// @param pubKeyX The X coordinate of the public key of the first signer. We use the r1 curve here
/// @param pubKeyY The Y coordinate of the public key of the first signer. We use the r1 curve here
/// @param loginHash The keccak256 hash of the login of the account
/// @param credId The WebAuthn credential ID of the first signer. Take a look to the WebAuthn specification
/// @param nameServiceSignature The signature of the name service. Its recovery must match the nameServiceOwner.
/// The loginHash is expected to be the hash used by the recover function.
/// @return The address of the account (either deployed or not)
function createAndInitAccount(
uint256 pubKeyX,
uint256 pubKeyY,
bytes32 loginHash,
bytes calldata credId,
bytes calldata nameServiceSignature
)
external
returns (address)
{
// check if the account is already deployed and return prematurely if it is
address alreadyDeployedAddress = _checkAccountExistence(loginHash);
if (alreadyDeployedAddress != address(0)) {
return alreadyDeployedAddress;
}

// check if the signature of the name service is valid
if (_isNameServiceSignatureLeggit(loginHash, nameServiceSignature) == false) {
revert InvalidNameServiceSignature(loginHash, nameServiceSignature);
}

// deploy the proxy for the user. During the deployment, the
// initialize function in the implementation contract is called
// using the `delegatecall` opcode
Account account = Account(
payable(
new ERC1967Proxy{salt : loginHash}(
address(accountImplementation),
abi.encodeCall(Account.initialize, (loginHash))
)
)
);

// set the first signer of the account using the parameters given
account.addFirstSigner(pubKeyX, pubKeyY, credId);

emit AccountCreatedAndInit(loginHash, address(account), credId, pubKeyX, pubKeyY);

return address(account);
}

/// @notice This is the multi-steps scenario. This function either deploys an account or returns the address of
/// an existing account based on the parameter given. In any case this function set the first signer.
/// @param loginHash The keccak256 hash of the login of the account
Expand Down Expand Up @@ -107,3 +170,22 @@ contract AccountFactory {
);
}
}

// NOTE:
// - Both creation methods defined in this contract follow the EIP-4337 recommandations.
// That's why the methods return the address of the already deployed account if it exists.
// https://eips.ethereum.org/EIPS/eip-4337#first-time-account-creation
//
// - CREATE2 is used to deploy the proxy for our users. The formula of this deterministic computation
// depends on these parameters:
// - the address of the factory
// - the loginHash (used as the salt)
// - the implementation of the ERC1967Proxy (included in the init code hash)
// - the arguments passed to the constructor of the ERC1967Proxy (included in the init code hash):
// - the address of the implementation of the account
// - the signature selector of the initialize function present in the account implementation (first 4-bytes)
// - the value of loginHash
//
// - Once set, it's not possible to change the account implementation later.
// - Once deployed by the constructor, it's not possible to change the instance of the account implementation.
// - The implementation of the proxy is hardcoded, it is not possible to change it later.
29 changes: 29 additions & 0 deletions test/integration/AccountFactory/deterministicDeployments.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { BaseTest } from "test/BaseTest.sol";
contract AccountFactoryDeterministicDeployment is BaseTest {
bytes32 private constant LOGIN_HASH = keccak256("qdqd");
address private constant SIGNER = 0x7a8c35e1CcE64FD85baeD9a3e4f399cAADb52f20;
// this signature has been forged using the private key of the signer and the login hash above (message)
bytes private constant PRE_FORGED_SIGNATURE = hex"247bbb60d4e8fd56e177234fb566331249f367465120c95ce65f"
hex"a784b0b917cd6e19a4b6ebfb5d93a217ea76c37ff6d98d5f3aa18015e7220543a95d215a50381c";

AccountFactory private factory;

Expand All @@ -25,4 +28,30 @@ contract AccountFactoryDeterministicDeployment is BaseTest {
assertEq(factory.getAddress(LOGIN_HASH), factory.createAccount(LOGIN_HASH));
}

function test_WhenUsingTheCreateAccountAndIntFlow() external {
// it should deploy the account to the same address calculated by getAddress

assertEq(
factory.getAddress(LOGIN_HASH),
factory.createAndInitAccount(___, ___, LOGIN_HASH, ____, PRE_FORGED_SIGNATURE)
);
}

function test_WhenUsingBothFlowsWithTheSameParameters() external {
// snapshot the state of the EVM before deploying the account
uint256 snapshot = vm.snapshot();

// deploy the account using `createAndInitAccount`
address createAccountAndInitAddress =
factory.createAndInitAccount(___, ___, LOGIN_HASH, ____, PRE_FORGED_SIGNATURE);

// revert to the state of the EVM before deploying the first account
vm.revertTo(snapshot);

// deploy the account using `createAccount`
address createAccountAddress = factory.createAccount(LOGIN_HASH);

// ensure both flows deployed the account to the same address
assertEq(createAccountAddress, createAccountAndInitAddress);
}
}
4 changes: 4 additions & 0 deletions test/integration/AccountFactory/deterministicDeployments.tree
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
AccountFactoryDeterministicDeployment
├── when using the createAccount flow
│ └── it should deploy the account to the same address calculated by getAddress
├── when using the createAccountAndInt flow
│ └── it should deploy the account to the same address calculated by getAddress
└── when using both flows with the same parameters
└── it should deploy the account to the same address
126 changes: 126 additions & 0 deletions test/unit/AccountFactory/createAndInitAccount.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.20 <0.9.0;

import { AccountFactory } from "src/AccountFactory.sol";
import { BaseTest } from "test/BaseTest.sol";

contract AccountFactory__CreateAndInitAccount is BaseTest {

Check warning on line 7 in test/unit/AccountFactory/createAndInitAccount.t.sol

View workflow job for this annotation

GitHub Actions / lint

Contract name must be in CamelCase
bytes32 private constant LOGIN_HASH = keccak256("qdqd");
address private constant SIGNER = 0x7a8c35e1CcE64FD85baeD9a3e4f399cAADb52f20;
bytes private constant SIGNATURE = hex"247bbb60d4e8fd56e177234fb566331249f367465120c95ce65f"
hex"a784b0b917cd6e19a4b6ebfb5d93a217ea76c37ff6d98d5f3aa18015e7220543a95d215a50381c";
AccountFactory private factory;

// copy here the event definition from the contract
// @dev: once we bump to 0.8.21, import the event from the contract
event AccountCreatedAndInit(bytes32 loginHash, address account, bytes credId, uint256 pubKeyX, uint256 pubKeyY);

function setUp() external {
factory = new AccountFactory(address(0), address(0), SIGNER);
}

function test_ShouldUseADeterministicDeploymentProcess() external {
// predict where the account linked to a specific hash will be deployed
address predictedAddress = factory.getAddress(LOGIN_HASH);

// check the address of the account doesn't have any code before the deployment
assertEq(keccak256(predictedAddress.code), keccak256(""));

// deploy the account contract using the same hash
factory.createAndInitAccount(___, ___, LOGIN_HASH, ____, SIGNATURE);

// make sure the account contract has been deployed
assertNotEq(keccak256(predictedAddress.code), keccak256(""));
}

function test_GivenAHashAlreadyUsed() external {
// it should return the existing account address

// make sure the second attempt of creation return the already deployed address
// without reverting or something else
assertEq(
factory.createAndInitAccount(___, ___, LOGIN_HASH, ____, SIGNATURE),
factory.createAndInitAccount(___, ___, LOGIN_HASH, ____, SIGNATURE)
);
}

function test_GivenANewHash() external {
// it should deploy a new account if none exists

// deploy a valid proxy account using the constants predefined
address proxy1 = factory.createAndInitAccount(___, ___, LOGIN_HASH, ____, SIGNATURE);

// generated using the same private key as the one used to generate the SIGNATURE constant
bytes memory newValidCorrectSignature =
hex"4b4b6f4ecc5fb0427bbbe61b539a6c45062b45d794641a5dc86e12bd8c6f68a747"
hex"df2abe57a1df59f2d2d5ba5f9c89d723ea8a7c1ca79e95fc1321f3eeb775f51c";
bytes32 newLoginHash = keccak256("xoxo");

// deploy a valid proxy account using a different loginHash and a correct valid signature
address proxy2 = factory.createAndInitAccount(___, ___, newLoginHash, ____, newValidCorrectSignature);

assertNotEq(proxy1, proxy2);
assertNotEq(keccak256(proxy1.code), keccak256(""));
assertNotEq(keccak256(proxy2.code), keccak256(""));
}

function test_RevertGiven_AnIncorrectValidSignature() external {
// it should revert

// this signature is a valid ECDSA signature but it as been createrd using a non authorized private key
bytes memory invalidSignature = hex"1020211079cccfe88a67ed9d00d719c922b4d79e11ddb5f1f59c2e41"
hex"fb27d5fa3f7825d448a05d75273f75f42def0010fdfb4f6ac1e0abe65dc426f7536d325c1b";

// we tell the VM to expect a revert with a precise error
vm.expectRevert(
abi.encodeWithSelector(AccountFactory.InvalidNameServiceSignature.selector, LOGIN_HASH, invalidSignature)
);

// we call the function with the invalid signature to trigger the error
factory.createAndInitAccount(___, ___, LOGIN_HASH, ____, invalidSignature);
}

function test_ShouldCallInitialize() external {
// we tell the VM to expect *one* call to the initialize function with the loginHash as parameter
vm.expectCall(factory.accountImplementation(), abi.encodeCall(this.initialize, (LOGIN_HASH)), 1);

// we call the function that is supposed to trigger the call
factory.createAndInitAccount(___, ___, LOGIN_HASH, ____, SIGNATURE);
}

function test_ShouldCallTheProxyAddFirstSignerFunction() external {
uint256 pubKeyX = uint256(43);
uint256 pubKeyY = uint256(22);
bytes memory credId = abi.encodePacked(keccak256("a"), keccak256("b"));

// we tell the VM to expect *one* call to the addFirstSigner function with the loginHash as parameter
vm.expectCall(
factory.getAddress(LOGIN_HASH), abi.encodeCall(this.addFirstSigner, (pubKeyX, pubKeyY, credId)), 1
);

// we call the function that is supposed to trigger the call
factory.createAndInitAccount(pubKeyX, pubKeyY, LOGIN_HASH, credId, SIGNATURE);
}

function test_ShouldTriggerAnEventOnDeployment() external {
uint256 pubKeyX = uint256(43);
uint256 pubKeyY = uint256(22);
bytes memory credId = abi.encodePacked(keccak256("a"), keccak256("b"));

// we tell the VM to expect an event
vm.expectEmit(true, true, true, true, address(factory));
// we trigger the exact event we expect to be emitted in the next call
emit AccountCreatedAndInit(LOGIN_HASH, factory.getAddress(LOGIN_HASH), credId, pubKeyX, pubKeyY);

// we call the function that is supposed to trigger the event
// if the exact event is not triggered, the test will fail
factory.createAndInitAccount(pubKeyX, pubKeyY, LOGIN_HASH, credId, SIGNATURE);
}

// @dev: I don't know why but encodeCall crashes when using Account.XXX
// when using the utils Test contract from Forge, so I had to copy the function here
// it works as expected if I switch to the utils Test contract from PRB 🤷‍♂️
// Anyway, remove this useless function once the bug is fixed
function initialize(bytes32) public { }
function addFirstSigner(uint256, uint256, bytes calldata) public { }
}
11 changes: 11 additions & 0 deletions test/unit/AccountFactory/createAndInitAccount.tree
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
AccountFactory__CreateAndInitAccount
├── it should use a deterministic deployment process
├── given a hash already used
│ └── it should return existing account address
├── given a new hash
│ └── it should deploy a new account if none exists
├── given an incorrect valid signature
│ └── it should revert
├── it should call initialize
├── it should call the proxy addFirstSigner function
└── it should trigger an event on deployment

0 comments on commit 3559b3e

Please sign in to comment.