Keystore Basics
Inheriting a Keystore
In Keyspace, a wallet's cross-chain keystore is typically embedded within the wallet's smart contract. When you inherit from the Keystore
contract, your wallet gains the ability to sync its configuration across chains.
Since a Keystore
needs know how to read its storage across chains, the logic for verifying cross-chain proofs from your wallet's master chain needs to be provided. The OPStackKeystore
contract shipped with Keyspace provides this logic for OP Stack L2s.
Configuration Hooks
Of the Keystore
's virtual methods that you'll need to implement, the most important are the configuration hooks that are called when the wallet's configuration is changed.
Here's the core logic for configuration updates:
// Hook before (to authorize the new Keystore config).
require(
_hookIsNewConfigAuthorized({newConfig: newConfig, authorizationProof: authorizeAndValidateProof}),
UnauthorizedNewKeystoreConfig()
);
// Apply the new Keystore config to the internal storage.
bytes32 newConfigHash = applyConfigInternal(newConfig);
// Hook between (to apply the new Keystore config).
bool triggeredUpgrade = _hookApplyNewConfig({newConfig: newConfig});
// Hook after (to validate the new Keystore config).
bool isNewConfigValid = triggeredUpgrade
? this.hookIsNewConfigValid({newConfig: newConfig, validationProof: authorizeAndValidateProof})
: hookIsNewConfigValid({newConfig: newConfig, validationProof: authorizeAndValidateProof});
require(isNewConfigValid, InvalidNewKeystoreConfig());
_hookIsNewConfigAuthorized
_hookIsNewConfigAuthorized(ConfigLib.Config calldata newConfig, bytes calldata authorizationProof)
This hook is called before the configuration update is applied. It should verify that the caller is authorized to change the configuration. authorizationProof
is typically a pair of ECDSA signatures for most wallets. Only the first signature is relevant for _hookIsNewConfigAuthorized
. (The second signature is used for the hookIsNewConfigValid
.)
If the signature is valid, the hook should return successfully. Otherwise, it should revert.
_hookApplyNewConfig
_hookApplyNewConfig(ConfigLib.Config calldata newConfig)
Once a configuration update is authorized, the _hookApplyNewConfig
is called to apply the update to your wallet's internal storage. There are two typical tasks and one optional task that are performed in this hook:
- Check if the implementation of the wallet needs to be upgraded. If the new configuration stored in the keystore has an implementation address that is different from the address stored in the wallet's storage for its proxy to use, the wallet's implementation should be upgraded.
- Update the wallet's storage with the new configuration. Typically, you'll just decode
newConfig.data
into your locally defined configuration struct and store it. - (Optional) Store any synthesized data from the new configuration. For example, if your wallet uses a mapping of signers, that mapping cannot be serialized into
newConfig.data
as a bytes array. So, you'll need to iterate through the signers and initialize the mapping in the wallet's storage. To get a fresh mapping with each configuration update, you can store this data in its own mapping keyed by the current configuration hash: a new configuration hash will give you a fresh mapping.
hookIsNewConfigValid (optional)
hookIsNewConfigValid(ConfigLib.Config calldata newConfig, bytes calldata authorizationProof)
This hook is called after the configuration update is applied. It's optional. If implemented, it may verify that the update is valid. If the update is invalid, the hook should revert.
A typical implementation will validate a signature of the new configuration hash that is expected to be valid with the new configuration. If the signer of the configuration update is still a valid signer, a separate signature is not needed: just revalidate the same signature that was validated in the _hookIsNewConfigAuthorized
using the updated configuration.
Otherwise, a second signature is needed, and it can be packed into authorizationProof
as a pair of signatures. The simplest scenario where a second signature is needed is when a signer is removed from the wallet. This hook would prevent a signer from removing itself without another signature from another signer that proves the new configuration is still usable.
When an implementation upgrade has occurred, hookIsNewConfigValid
is called via an external call to ensure that the new implementation is executed by the proxy contract for validation.
Eventual Consistency
Inheriting from the Keystore
contract will require your wallet to implement _eventualConsistencyWindow()
, which should return the maximum duration that can elapse on a replica chain before the wallet's configuration expires. This value is used by _enforceEventualConsistency()
, which reverts on replica chains if the configuration has expired. Each of your wallet's methods that are designed to be called by the EntryPoint
should call _enforceEventualConsistency()
to ensure that the configuration has not expired.
The MultiOwnableWallet
example in the Keyspace GitHub repository shows how this can be done. It makes exceptions for confirmConfig()
and setConfig()
to be called even if the wallet hasn't been synced recently.
Exempting setConfig
from eventual consistency allows the wallet to replay configuration changes to get to the latest version of the configuration without relying on a sync. Cross-chain syncing can break with each L1 and L2 hard fork, so it's important that your wallet can handle this case.
Exempting confirmConfig
from eventual consistency allows the wallet to sync itself instead of relying on an external account to call confirmConfig()
.