Skip to content

SimpleAccountFactory

Deterministic account deployment

src/SimpleAccountFactory.sol deploys accounts as EIP-1167 minimal proxies pointing at a single shared implementation. It deploys that implementation once in its own constructor, so a factory deployment uniquely determines the account bytecode it produces.

contract SimpleAccountFactory {
    IEntryPoint public immutable ENTRY_POINT;
    ISignatureVerifier public immutable VERIFIER;           // ForsVerifier
    ISphincsVerifier public immutable SPHINCS_VERIFIER;     // SphincsVerifier
    address public immutable ACCOUNT_IMPL;                  // = new SimpleAccount(ENTRY_POINT, VERIFIER, SPHINCS_VERIFIER)
 
    function createAccount(bytes32 initialSignerRoot, bytes32 backupPkSeed, bytes32 backupPkRoot, uint256 salt)
        external returns (address);
    function getAddress(bytes32 initialSignerRoot, bytes32 backupPkSeed, bytes32 backupPkRoot, uint256 salt)
        external view returns (address);
}

Creating an account

function createAccount(bytes32 initialSignerRoot, bytes32 backupPkSeed, bytes32 backupPkRoot, uint256 salt)
    external returns (address accountAddr)
{
    bytes32 fullSalt = _salt(initialSignerRoot, backupPkSeed, backupPkRoot, salt);
    address predicted = LibClone.predictDeterministicAddress(ACCOUNT_IMPL, fullSalt, address(this));
    if (predicted.code.length > 0) return predicted;                 // idempotent
    accountAddr = LibClone.cloneDeterministic(ACCOUNT_IMPL, fullSalt);
    SimpleAccount(payable(accountAddr)).initialize(initialSignerRoot, backupPkSeed, backupPkRoot);
    emit AccountCreated(accountAddr, initialSignerRoot, salt);
}

createAccount is idempotent: if the predicted address already has code it returns it instead of redeploying, which is what makes the standard ERC-4337 initCode flow safe. getAddress returns the same prediction without deploying, so a wallet can know the account address before it exists onchain.

The three inputs besides the user salt:

InputMeaningWhere it comes from
initialSignerRootMerkle root over (chainId, firstSigner) leaves for every committed chainBuilding the initial-signer tree
backupPkSeed, backupPkRootthe SPHINCS backup public key, each 16 bytes left-aligned in a bytes32SPHINCS backup
saltuser-chosen uint256, lets one user own several accountswallet

A zero root or a zero backup key is rejected in _salt, and a non-canonical backup key is rejected by initialize.

The salt

The CREATE2 salt is domain-separated via InitialSignerCommitment and commits to both the root and the backup key:

backupLeaf = keccak256(abi.encode(
    keccak256("NiceTryBackupSignerLeaf:v1(bytes32 pkSeed,bytes32 pkRoot)"),
    backupPkSeed, backupPkRoot
));
 
fullSalt = keccak256(abi.encode(
    keccak256("NiceTryAccountSalt:v2(bytes32 initialSignerRoot,bytes32 backupSignerLeaf,uint256 salt)"),
    initialSignerRoot, backupLeaf, salt
));

Binding the backup key into the address is what makes the recovery key front-run-safe: a deploy-race attacker who calls createAccount with a different backup key lands at a different address, so the user's counterfactual address can only ever be initialised with the user's own backup key.

Address stability

Because the factory, the implementation, the verifiers, the EntryPoint and the compiler settings are all inputs to the address, they must be identical on every chain. The deploy script guarantees that through the standard CREATE2 deployer and fixed salts. The full invariant, the initial-signer tree, and the deployment-race handling are on the Multichain addresses page.

Events

EventEmitted when
AccountCreated(account, initialSignerRoot, salt)a new clone is deployed and initialised (not on the idempotent early return)