Though smart contract wallet offers many flexibity and possibility to manage wallets, the current AA wallet implementation faces a few UX issues that prevent it from mass adoption.
To solve these two issues, we propose to a keystore-centric AA wallet design. The goals of the keystore include:
In essence, the keystore is a database that stores users' signing keys to their smart contract wallets. Ethereum and L2 chains all need the read access to it in order to deploy AA wallets, while some chains have the write access to update the keys. The design of the keystore comes to the following questions.
The following figure shows an overview of the keystore architecture.
A few things to note in this design:
L1Keystore
is the main storage that holds the user signing keys. L2Keystore
is a proxy of the L1Keystore
that allows wallets on L2s to read the keys.L2Keystore
will use L1Blocks
contract and the l1Sload
precompile to load the keys from L1Keystore
.CREATE3
(see more details here) to derive the same account address across all EVM-compatible chains. We first deploy a universal AccountFactory
contract to the same address across on all chains. Then the AccountFactory
contract is used to deploy all user wallet contracts via CREATE3
. More details will be describe in the AccountFactory
contract section. Note that in the figure the User Wallet
and AccountFactory
in the "L2 Chain A (EVM compatible)" have the same addresses as the ones on Ethereum.L1Keystore
contractThe L1Keystore
contract is mainly a storage layer that serves as the single source of truth for all user keys. Note that this contract stores the key mappings for all users from different AA wallets. It doesn’t add any constraints to data format and layout. The interpretation of the keys stored in the contract is up to the AA wallet implementation.
More specifically, it stores the following data fields:
keyStorage
: a mapping from user account address to its own key store entry. The user wallet contract will read the keys from the keystore contract to verify user identity. There are 3 reserved keys in every user’s key store entry.
_SALT_KEY
: stores the salt value that determines the user account address. This value is read-only and cannot be modified after the initial setup in order to achieve the same address on new chains in the future.._WALLET_ID_KEY
: stores the wallet ID in the wallet registry that is used to load the wallet implementation address._INITDATA_HASH_KEY
: stores the Keccak digest of init data that is used to initialize the wallet contract.The reason to store the wallet ID and initdata hash in the keystore is to prevent others from front-running the wallet deployment on a chain with (malicious) implementation contract address or initialization data to possess user account address on a new chain.
keyUpdateAccess
: a mapping from user account address to a L2 rollup contract that is allowed to update the keys for the user. This entry can be empty, meaning that the key can be only updated by the user wallet contract on Ethereum.walletRegistry
: a mapping from wallet ID to the wallet registration entry. The entry contains a mapping from chain ID to the wallet implementation address. Each entry can be updated by owner
, a AA wallet project controlled address.The L1Keystore
wallet provides the following functions:
registerAccount
is used to register a new account in the keystore and return the new address for this account. The following parameters need to be provided to this function:
salt
: a value used to decide the deterministic contract address for the user account.walletID
: the wallet ID of the AA wallet implementation.initKeys
and initValues
: the initial signing keys and values to be stored in the user keystore. The initKeys
specifies the location to store the initValues
in the user keystore entry.initdata
: the initdata to initialize the AA wallet implementation. It can be the metadata other than user signing keys that doesn't need to be stored in the keystore.l2Rollup
: the L2 rollup contract address that can update the user keystore entry. If a user doesn't want to give the permission to any L2, she can just pass 0x0
to this parameter.loadKey
loads the value given the user account and a key. This function can be called by everyone.writeKey
and updateKey
writes a new value and updates an existing value in the user keystore respectively. This function can be only called by the user wallet contract or a L2 rollup contract if specified. The updateKey
function requires users to provide the old value to prevent the data racing.updateAccess
updates the key-update contract access to the user account, only allowing user contract or the previous L2 rollup contract.getWalletImplementation
returns the wallet implementation address based on the chain id.L2Keystore
ContractThe L2Keystore
contract serves as a proxy of the L1 keystore contract. This contract relies on the L2 chain to provide access to L1 state root so that it can load the keys from the L1Keystore
contract. The overall functions of L2Keystore
are the same as the L1Keystore
but with slight difference.
registerAccount
is not supported on L2s.loadKey
uses the l1Sload
precompile to load a storage slot using the current L1 state root stored on the L2. The l1Sload
precompile provides a convenient way to access the L1 storages without providing Merkle proofs. This precompile will be proved by the zkEVM circuit.writeKey
and updateKey
will encode the key update to a message and send to the L2 bridge. After the L2 is finalized on the Ethereum, the message can be then executed to materialize the update. These two functions can only be called by the user account on L2s.AccountFactoryEvm
ContractThe EvmAccountFactory
contract is deployed on all EVM-compatible chains. It should have the same address across all chains. It provides one single function createAccount
. The function takes the account
address and the contract initialize data initdata
as input and then loads the salt
and wallet contract implementation address from the L1 or L2 keystore contract to deploy the user contract. One thing to note is that the user account needs to pre-exist in the keystore before the contract being deployed.
L2Keystore
ContractThe majority of the L2Keystore
contract for non-EVM-compatible chains is the same as that for EVM-compatible chains. The only difference is that the write permission to user account now allows both the account address and the binded address specified on L1.
AccountFactoryNonEvm
ContractThis figure outlines the account creation workflow.
L1Keystore
contract.registerAccount
to the L1Keystore
contract to register a new user account with the init keys and other information.AccountFactory
on the Ethereum to deploy the user wallet contract.AccountFactory
contract on L2 to deploy the user wallet contract on L2.(will add description)
(will add description)
L1Blocks
system contractIn this design, the L2Keystore
contract on L2s needs to read the storage slots in the L1Keystore
contract. This requires L2s to provide trustless L1 access to the contract.
In order to achieve this, we introduce a system contract IL1Blocks
that provides interfaces to read L1 blockhashes and state roots. The L1Blocks
contract should hold a ring buffer of N
blockhashes and state roots where N
can be decided by each L2.
This contract is read-only from externals and can be only updated by system transactions generated by L2 seqeuencers. To lift the trust of the honesty of L2 sequencers, the L2 proofs need to verify that the blockhashes and state roots in the system transactions are correct.
l1Sload
precompileThe l1Sload
precompile allows smart contracts to read a L1 storage slots without providing Merkle proofs. This precompile is important such that AA wallets don't need to supply the Merkle proofs to read keys from L2Keystore
contract. It not only reduces the overhead of AA wallets to construct a transaction but reduces the tx cost and tx calldata size.
The l1Sload
returns the storage value against the latest state root in the L1Blocks
contract given the contract address and storage key.
Inputs
Stack | Name | Description |
---|---|---|
Top - 0 | address |
The contract address |
Top - 1 | key |
The storage key |
Output
Stack | Name | Description |
---|---|---|
Top - 0 | value |
The storage value based on the latest state root in the L1Blocks |
The sequencer is responsible for providing the storage value given the latest state root in the L1Blocks
. To prevent the sequencer from giving wrong values, the L2 proof system needs to construct a valid Merkle proof from the value to the state root stored in the L1Blocks
contract.
CREATE3
CREATE3
can deploy a contract to a deterministic address. It first uses CREATE2
with a fixed init code to deploy a proxy contract to a deterministic address, and then call the proxy contract to deploy the desired contract via CREATE
.
The contract address deployed by CREATE3
is computed as follows
CREATE2
. The proxy address is
Because the PROXY_BYTECODE
is fixed, the proxy_addr
depends on the salt
and deployer_address
. Given that we can have the same deployer_address
, the proxy_addr
only depends on the salt
.CREATE
. The contract address is
The proxy_nonce
is 1 since the proxy contract is newly deployed. Therefore, the deployed_addr
depends on the proxy_addr
, which consequently depends only on the salt
.Reference