Users transact on L2s with keystore-enabled smart accounts in a similar manner as with a standard ERC-4337 smart account, but with some modifications to verify their signing data against the keystore. This is facilitated by the Keystore Validator, which we outlined in the Overview.

Modifying the UserOperation Signature

The primary difference in userOp structure when transacting with a keystore-enabled smart account lies in the structure of the signature field within the UserOperation struct submitted during bundling. The signature is modified to add:

  • claimed signing data for the smart account
  • an indexed Merkle tree proof of signing data against a keystore state root

Wallets will obtain this information on behalf of users by calling the keystore JSON-RPC API and format it into the userOp signature in the following format:

Solidity
bytes memory signature = abi.encode(proof, authData);

where proof and authData are defined as

Solidity
struct KeyDataMerkleProof {
    bool isExclusion;
    // Only parsed if `isExclusion` is true
    // abi.encodePacked(bytes1 prevDummyByte, bytes32 prevImtKey, bytes32 salt, bytes32 valueHash)
    bytes exclusionExtraData;
    bytes1 nextDummyByte;
    bytes32 nextImtKey;
    bytes32 vkeyHash;
    bytes keyData;
    bytes32[] proof;
    uint256 isLeft;
}

KeyDataMerkleProof memory proof = ...;

// `authData` is validated to be valid against the `data` read from keystore
bytes memory authData = ...;

The KeyDataMerkleProof fields represent the following:

  • bool isExclusion: Indicates whether the proof is for an exclusion or inclusion IMT proof. Counterfactually initialized accounts will always be exclusion proofs.
  • bytes exclusionExtraData: Exclusion proofs require some additional fields, which are packed into the exclusionExtraData field.
  • bytes1 nextDummyByte: IMT-specific field — whether the next byte is a dummy byte or not.
  • bytes32 nextImtKey: IMT-specific field — the next key in the IMT.
  • bytes32 vkeyHash: The hash of the vkey at keystoreAddress.
  • bytes keyData: The data at keystoreAddress.
  • bytes32[] proof: The Merkle inclusion proof for the IMT node. If this is an exclusion proof, it is an inclusion proof for the previous IMT node.
  • uint256 isLeft: Bit packed flags indicating whether a node is a left child or right child.

All these fields can be easily queried by a certain keystoreAddress and block height with the keystore_getProof RPC endpoint.

For more information on the IMT and inclusion / exclusion proofs, see the IMT documentation.

Keystore Cost Overhead

Since rollups only have access to a commitment of the keystore state, there is some overhead when transacting on L2 to open this commitment. As such, the primary distinction in transacting via keystore-enabled smart accounts is that signing data must be proven as part of the transaction as opposed to being read from storage. In this section, we analyze this cost overhead of using a keystore-enabled smart account on L2s.

L2 transaction cost analyses must be done across two axes:

  • L1 data fee cost, estimated by 384 + 97 * isExclusion + keyData.length + 960
  • L2 transaction execution cost

To calculate the overhead of reading from the keystore, we will determine the extra data and execution required to facilitate the keystore read.

Analysis

In terms of calldata usage, the overhead will come from the KeyDataMerkleProof. Everything else (including the authData) would be required by a standard smart account as well. For the sake of simplicity, this analysis does not consider compression.

The KeyDataMerkleProof struct has 8 fields, so we have 8 * 32 = 256 bytes. The struct itself is embedded within a bytes payload so it has an offset as well, so we have 256 + 32 = 288 bytes. The struct also has 3 dynamic fields, each of which will also have a word dedicated to its length, so we have 288 + 3 * 32 = 384 bytes of fixed calldata. Next, we consider the values of the dynamic fields:

  • exclusionExtraData: If this is an exclusion proof, that is an extra 97 bytes overhead.
  • keyData: This is the data stored on the keystore and varies with keystore account type. For the m-of-n ECDSA keystore account type, this is 97 + amountOfSigners * 32 bytes.
  • proof: This is the IMT proof. It scales logarithmically with the number of initialized keystoreAddresses. If we pessimistically assume that the keystore has 1_000_000_000 initialized keystoreAddresses, this would yield 30 * 32 = 960 bytes.

Thus, the estimated calldata cost is 384 + 97 * isExclusion + keyData.length + 960.

In terms of execution, the overhead arises from the IMT proof verification. We do not attribute key data consumer execution as overhead since the execution of this logic would take place in a vanilla smart account as well. For a Merkle proof of length 30, the execution cost is ~10k gas, which is quite negligible on L2. The only other dynamic operation is hashing of the keyData, however, for most reasonably sized keyDatas, this cost should be negligible.

Example

To make the cost analysis more concrete, we consider an example of an exclusion proof of an m-of-n ECDSA keystore account with 5 signers. Let us also assume the IMT tree depth is 30.

  • 384 bytes of fixed calldata
  • 97 bytes of exclusionExtraData
  • 97 + 5 * 32 = 242 bytes of keyData
  • 30 * 32 = 960 bytes of proof

This yields a total of 384 + 97 + 242 + 960 = 1683 bytes of calldata overhead along with ~10K L2 gas overhead.

Example userOp Signature

In the following example, we will illustrate how to construct the signature field of the UserOperation. Picking up from the initialization example, we will use the keystoreAddress 0xe979d22d8a2b069d120b91291d1ea9037b7eeefd1bf8950c331590271ae52578.

Using this, we can query the keystore_getProof RPC endpoint to get the data necessary to construct the KeyDataMerkleProof.

bash
cast rpc keystore_getProof 0xe979d22d8a2b069d120b91291d1ea9037b7eeefd1bf8950c331590271ae52578 "latest" --rpc-url $KEYSTORE_NODE_RPC | jq

Example output:

json
{
  "state": {
    "dataHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
    "vkeyHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
    "data": "0x",
    "vkey": "0x"
  },
  "proof": {
    "isExclusionProof": true,
    "siblings": [
      {
        "hash": "0xc9f5552d9569dd89e1d02dc3bf98fdc07286bd2dcc3626352687df01ee3b477d",
        "isLeft": true
      },
      {
        "hash": "0xb37bfb3180841fd014e4a9d5da40ffb5c9152347904f8bca66e61c29bd04ea6a",
        "isLeft": false
      },
      {
        "hash": "0xb1fbecb867e573053f62a8df4cc4972ad0418f26011a56006725aac3b99bf99f",
        "isLeft": true
      },
      {
        "hash": "0x666c4870f1d2267a8090a56f165356a96468bd5fa0ad3a21707e62777448d108",
        "isLeft": true
      },
      {
        "hash": "0xf5cc1b91d30a0d084215185681585f5b7f455bb3592012bcd9609368817e1e3a",
        "isLeft": false
      },
      {
        "hash": "0xd06b101ce144caa95103b6425103615db9acd1eff7f49b110f09bfad2fedfd73",
        "isLeft": true
      },
      {
        "hash": "0xce477fdf7144faace89a5ad438b3b6deaefe55d8a6db2c8abef70d9c4ebc3302",
        "isLeft": false
      },
      {
        "hash": "0x89b68ece23c3c86f20ab9b9d9fa443cdf58531f77d2f912b0f3af3d6c638b70d",
        "isLeft": false
      },
      {
        "hash": "0x46c02db5326229fc8321e53f46b87ebd9c1a1456125fbf282838b16134a7f335",
        "isLeft": false
      },
      {
        "hash": "0xf7e2b3faab9d3959fa6d9a256a91d78a239de95dc7aad3feb744fdafc6b68918",
        "isLeft": false
      }
    ],
    "leaf": {
      "hash": "0x1d8733c41077b730c5d5645ae22b8a6ba286788bca5efda0ee1bac6c26268362",
      "keyPrefix": "0x01",
      "key": "0x138a5e749867f7dd06b9c02f3ae1abcf833d3815de674f2641e908277ca5e501",
      "nextKeyPrefix": "0x01",
      "nextKey": "0x179337bf129328381c3d545bd76b20caaed4247f0d0afb6eb9bd900141ec5c38",
      "value": "0x0000000000000000000000000000000000000000000000000000000000000001"
    }
  }
}

Since in this example we are using a counterfactual keystoreAddress, we will need to construct the exclusionExtraData field.

Typescript
const salt =
  "0x000000000000000000000000000000000000000000000000000000000000ffff";

const imtProof = await(await fetchImtProof()).json();

const exclusionExtraData = concat([
  imtProof.proof.leaf.keyPrefix,
  imtProof.proof.leaf.key,
  salt,
  keccak256(imtProof.proof.leaf.value),
]);

We also need to assemble the isLeft field, which is a bit packed form of the isLeft flags from the siblings array.

Typescript
const isLeft = imtProof.proof.siblings.reduce((acc, sibling, index) => {
  return acc | (BigInt(sibling.isLeft ? 1 : 0) << BigInt(index));
}, BigInt(0));

Let us also get the list of signatures which will serve as the authData for this m-of-n validation.

Typescript
const userOpHash = await fetchUserOpHash();

// Since the threshold we setup is only 1, we will only need to get 1 signature.
const userOpSigs = await signer.sign({ hash: userOpHash });

Finally, we can construct the signature field.

Typescript
const signature = encodeAbiParameters(
  [
    {
      type: "tuple",
      name: "keyDataProof",
      components: [
        {
          type: "bool",
          name: "isExclusion",
        },
        {
          type: "bytes",
          name: "exclusionExtraData",
        },
        {
          type: "bytes1",
          name: "nextDummyByte",
        },
        {
          type: "bytes32",
          name: "nextImtKey",
        },
        {
          type: "bytes32",
          name: "vkeyHash",
        },
        {
          type: "bytes",
          name: "keyData",
        },
        {
          type: "bytes32[]",
          name: "proof",
        },
        {
          type: "uint256",
          name: "isLeft",
        },
      ],
    },
    { type: "bytes", name: "signatures" },
  ],
  [
    {
      isExclusion: imtProof.proof.isExclusionProof,
      exclusionExtraData,
      nextDummyByte: imtProof.proof.leaf.nextKeyPrefix,
      nextImtKey: imtProof.proof.leaf.nextKey,
      vkeyHash,
      keyData: data,
      proof: imtProof.proof.siblings.map((sibling) => sibling.hash),
      isLeft,
    },
    userOpSigs,
  ]
);

And that’s it! Constructing the rest of the UserOperation remains exactly the same.

In the final section, we will overview how keys on the keystore can be rotated/recovered.