Keystore Transactions

Table of Contents

Transaction Type Overview

There are three transaction types for deposits, withdrawals, and user key data updates. They are represented by the following enum:

enum KeystoreTxType {
    DEPOSIT,
    WITHDRAW,
    UPDATE,
}

See Fee Schedule for details on how the transactions are priced.

EIP-712 and EIP-7730 Compatibility

To maximize compatibility with the existing Ethereum wallet ecosystem, we use EIP-712 for signing transaction message hashes, which will enable the most compatible wallet-level information display for vkeys using EOA signers. The relevant global constants are defined below.

bytes EIP712_REVISION = bytes('1');
bytes32 EIP712_DOMAIN =
    keccak256('EIP712Domain(string name,string version,uint256 chainId');
uint256 CHAIN_ID = 999999999;
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(EIP712_DOMAIN, keccak256("AxiomKeystore"), keccak256(EIP712_REVISION), CHAIN_ID));

We also provide an EIP-7730 compliant descriptor for improved transparency into signing data (if supported by the wallet).

{
  "context": {
    "eip712": {
      "domain": {
        "name": "AxiomKeystore",
        "version": "1",
        "chainId": 999999999
      },
      "schemas": [
        {
          "primaryType": "Withdraw",
          "types": {
            "EIP712Domain": [
              { "name": "name", "type": "string" },
              { "name": "version", "type": "string" },
              { "name": "chainId", "type": "uint256" }
            ],
            "Withdraw": [
              { "name": "userKeystoreAddress", "type": "bytes32" },
              { "name": "nonce", "type": "uint256" },
              { "name": "feePerGas", "type": "bytes" },
              { "name": "to", "type": "address" },
              { "name": "amt", "type": "uint256" }
            ]
          }
        },
        {
          "primaryType": "Update",
          "types": {
            "EIP712Domain": [
              { "name": "name", "type": "string" },
              { "name": "version", "type": "string" },
              { "name": "chainId", "type": "uint256" }
            ],
            "Update": [
              { "name": "userKeystoreAddress", "type": "bytes32" },
              { "name": "nonce", "type": "uint256" },
              { "name": "feePerGas", "type": "bytes" },
              { "name": "newUserData", "type": "bytes" },
              { "name": "newUserVkey", "type": "bytes" }
            ]
          }
        },
        {
          "primaryType": "Sponsor",
          "types": {
            "EIP712Domain": [
              { "name": "name", "type": "string" },
              { "name": "version", "type": "string" },
              { "name": "chainId", "type": "uint256" }
            ],
            "Sponsor": [
              { "name": "sponsorKeystoreAddress", "type": "bytes32" },
              { "name": "userMsgHash", "type": "bytes32" },
              { "name": "userKeystoreAddress", "type": "bytes32" }
            ]
          }
        }
      ]
    }
  },
  "metadata": { "owner": "keystore.axiom.xyz" },
  "display": {
    "formats": {
      "Withdraw": {
        "intent": "Withdraw from Axiom Keystore",
        "fields": [
          {
            "path": "userKeystoreAddress",
            "label": "User Keystore Address",
            "format": "raw"
          },
          { "path": "nonce", "label": "Nonce", "format": "raw" },
          { "path": "feePerGas", "label": "Fee Per Gas", "format": "raw" },
          { "path": "to", "label": "To", "format": "raw" },
          { "path": "amt", "label": "Amount", "format": "raw" }
        ]
      },
      "Update": {
        "intent": "Update data or vkey at keystore address",
        "fields": [
          {
            "path": "userKeystoreAddress",
            "label": "User Keystore Address",
            "format": "raw"
          },
          { "path": "nonce", "label": "Nonce", "format": "raw" },
          { "path": "feePerGas", "label": "Fee Per Gas", "format": "raw" },
          { "path": "newUserData", "label": "New User Data", "format": "raw" },
          { "path": "newUserVkey", "label": "New User Vkey", "format": "raw" }
        ]
      },
      "Sponsor": {
        "intent": "Sponsor a transaction",
        "fields": [
          {
            "path": "sponsorKeystoreAddress",
            "label": "Sponsor Keystore Address",
            "format": "raw"
          },
          {
            "path": "userMsgHash",
            "label": "User Message Hash",
            "format": "raw"
          },
          {
            "path": "userKeystoreAddress",
            "label": "User Keystore Address",
            "format": "raw"
          }
        ]
      }
    }
  }
}

Deposits

Deposit transactions are always L1-initiated and take the following form:

struct DepositTransaction {
    uint256 l1InitiatedNonce;
    uint256 amt;
    bytes32 keystoreAddress;
}

Here the fields are given by:

  • uint256 l1InitiatedNonce: A unique, increasing counter of L1-initiated transactions on L1, which is expected to be tracked by the bridge.
  • uint256 amt: The amount of ETH in wei to deposit.
  • bytes32 keystoreAddress: The keystore address to deposit to.

We serialize the transaction and define the transaction hash by:

bytes transaction = abi.encodePacked(
    KeystoreTxType.DEPOSIT,
    l1InitiatedNonce,
    amt,
    keystoreAddress
);
bytes32 transactionHash = keccak256(transaction);

The deposit transaction has the following transaction logic:

function DEPOSIT(uint256 l1InitiatedNonce, uint256 amt, bytes32 keystoreAddress) {
    balances[keystoreAddress] += amt;
}

The state-independent validity condition is that:

The transaction is correctly encoded with valid fields.

There is no state-dependent validity condition.

Withdrawals

Withdrawal transactions take the following form:

struct WithdrawalTransaction {
    bool isL1Initiated;
    uint256 nonce;
    bytes feePerGas;
    bytes l1InitiatedNonce;
    address to;
    uint256 amt;
    KeystoreAccount userAcct;
    bytes userProof;
}

Here the fields are given by:

  • bool isL1Initiated: Whether the transaction is L1-initiated.
  • bytes l1InitiatedNonce: Represents the uint256 L1-initiated nonce for the transaction if the transaction is L1-initiated and the empty bytestring bytes(0x) otherwise.
  • uint256 nonce: The nonce for the transaction.
  • bytes feePerGas: Represents the uint256 fee per gas for the transaction if the transaction is not L1-initiated and the empty bytestring bytes(0x) otherwise.
  • address to: The L1 Ethereum address to withdraw to.
  • uint256 amt: The amount of ETH in wei to withdraw.
  • KeystoreAccount userAcct: The user account paying for the transaction.
  • bytes userProof: The user's ZK authentication proof for the transaction.

We serialize the transaction and define the transaction hash by:

bytes transaction = abi.encodePacked(
    KeystoreTxType.WITHDRAW,
    isL1Initiated,
    l1InitiatedNonce,
    rlp.encode([
        nonce,
        feePerGas,
        to,
        amt,
        userAcct.keystoreAddress,
        userAcct.salt,
        userAcct.dataHash,
        userAcct.vkey,
        userProof
    ])
);
bytes32 transactionHash = keccak256(transaction);

where rlp.encode represents the RLP encoding.

For EIP-712, we define the type hash by:

bytes32 WITHDRAW_TYPEHASH =
    keccak256('Withdraw(bytes32 userKeystoreAddress,uint256 nonce,bytes feePerGas,address to,uint256 amt)');

The withdrawal transaction implements the following functionality, which depends also on the following fields chosen by the sequencer as part of batch submission for sequencer batches and set to preset values for L1-initiated batches:

  • bytes32 sequencerKeystoreAddress: The keystore address to send fees to.
  • uint256 l1Origin: The L1 block number to which the batch is associated.
  • uint256 baseFeeScalar: The base fee scalar for the batch.
  • uint256 blobBaseFeeScalar: The blob base fee scalar for the batch.
function WITHDRAW(
    bool isL1Initiated,
    bytes l1InitiatedNonce,
    uint256 nonce,
    bytes feePerGas,
    address to,
    uint256 amt,
    KeystoreAccount userAcct,
    bytes userProof,
    bytes32 sequencerKeystoreAddress,
    uint256 l1Origin,
    uint256 baseFeeScalar,
    uint256 blobBaseFeeScalar
) {
    if (isL1Initiated) {
      feePerGas = bytes(0x);
      sequencerKeystoreAddress = L1_INITIATED_SEQUENCER_ADDRESS;
      require(l1InitiatedNonce != bytes(0x));
    } else {
      require(l1InitiatedNonce == bytes(0x));
      require(feePerGas != bytes(0x));
    }

    bytes32 userMsgHash = keccak256(
        abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR,
            keccak256(abi.encode(WITHDRAW_TYPEHASH, userAcct.keystoreAddress, nonce, feePerGas, to, amt))
        )
    );
    authenticateMsg(userAcct.dataHash, userMsgHash, userProof, userAcct.vkey);

    // state-dependent validity
    authenticateKeystoreAccount(userAcct);
    require(nonces[userAcct.keystoreAddress] == nonce);

    bytes memory transaction = abi.encodePacked(
      KeystoreTxType.WITHDRAW,
      isL1Initiated,
      l1InitiatedNonce,
      rlp.encode([
          nonce,
          feePerGas,
          to,
          amt,
          userAcct.keystoreAddress,
          userAcct.salt,
          userAcct.dataHash,
          userAcct.vkey,
          userProof
      ])
    );
    uint256 dataFee = getDataFee(transaction, l1Origin, baseFeeScalar, blobBaseFeeScalar, isL1Initiated);
    uint256 fees = uint256(feePerGas) * WITHDRAW_GAS + dataFee;
    require(balances[userAcct.keystoreAddress] >= amt + fees);

    // deduct fees and increment nonce
    nonces[userAcct.keystoreAddress] = nonce + 1;

    balances[userAcct.keystoreAddress] -= amt + fees;
    balances[sequencerKeystoreAddress] += fees;

    // update the state for a withdrawal
    withdrawals[keccak256(abi.encodePacked(userAcct.keystoreAddress, nonce))] = Withdrawal(to, amt);
}

The state-independent validity condition is that:

The transaction is correctly RLP encoded with valid fields and authenticateMsg(userAcct.dataHash, userMsgHash, userProof, userAcct.vkey) passes.

The state-dependent validity condition is that:

authenticateKeystoreAccount(userAcct), require(nonces[userAcct.keystoreAddress] == nonce), and require(balances[userAcct.keystoreAddress] >= amt + uint256(feePerGas) * WITHDRAW_GAS + dataFee) all pass.

Updates

The update transaction format is as follows.

struct UpdateTransaction {
    bool isL1Initiated;
    uint256 nonce;
    bytes feePerGas;
    bytes l1InitiatedNonce;
    bytes newUserData;
    bytes newUserVkey;
    KeystoreAccount userAcct;
    bytes userProof;
    bytes sponsorAcctBytes;
    bytes sponsorProof;
}

Here the fields are given by:

  • bool isL1Initiated: Whether the transaction is L1-initiated.
  • bytes l1InitiatedNonce: Represents the uint256 L1-initiated nonce for the transaction if the transaction is L1-initiated and the empty bytestring bytes(0x) otherwise.
  • uint256 nonce: The nonce for the transaction.
  • bytes feePerGas: Represents the uint256 fee per gas for the transaction if the transaction is not L1-initiated and the empty bytestring bytes(0x) otherwise.
  • bytes newUserData: The new user data to update to.
  • bytes newUserVkey: The new user vkey to update to.
  • KeystoreAccount userAcct: The user account paying for the transaction.
  • bytes userProof: The user's ZK authentication proof for the transaction.
  • bytes sponsorAcctBytes: Represents the RLP-encoded KeystoreAccount of the sponsor account sponsoring the transaction if the transaction is sponsored and the empty bytestring bytes(0x) otherwise.
  • bytes sponsorProof: Represents the sponsor's ZK authentication proof sponsoring the transaction if the transaction is sponsored and the empty bytestring bytes(0x) otherwise.

We serialize the transaction and define the transaction hash by:

bytes transaction = abi.encodePacked(
    KeystoreTxType.UPDATE,
    isL1Initiated,
    l1InitiatedNonce,
    rlp.encode([
        nonce,
        feePerGas,
        newUserData,
        newUserVkey,
        userAcct.keystoreAddress,
        userAcct.salt,
        userAcct.dataHash,
        userAcct.vkey,
        userProof,
        sponsorAcctBytes,
        sponsorProof
    ])
);
bytes32 transactionHash = keccak256(transaction);

where rlp.encode represents the RLP encoding. The update transaction implements the following functionality.

For EIP-712, we define both the type hash for the user signature and sponsor signature, separately:

bytes32 UPDATE_TYPEHASH =
    keccak256('Update(bytes32 userKeystoreAddress,uint256 nonce,bytes feePerGas,bytes newUserData,bytes newUserVkey)');

bytes32 SPONSOR_TYPEHASH =
    keccak256('Sponsor(bytes32 sponsorKeystoreAddress,bytes32 userMsgHash,bytes32 userKeystoreAddress)');

The update transaction implements the following functionality, which depends also on the following fields chosen by the sequencer as part of batch submission for sequencer batches and set to preset values for L1-initiated batches:

  • bytes32 sequencerKeystoreAddress: The keystore address to send fees to.
  • uint256 l1Origin: The L1 block number to which the batch is associated.
  • uint256 baseFeeScalar: The base fee scalar for the batch.
  • uint256 blobBaseFeeScalar: The blob base fee scalar for the batch.
function UPDATE(
    bool isL1Initiated,
    bytes l1InitiatedNonce,
    uint256 nonce,
    bytes feePerGas,
    bytes newUserData,
    bytes newUserVkey,
    KeystoreAccount userAcct,
    bytes userProof,
    bytes sponsorAcctBytes,
    bytes sponsorProof,
    bytes32 sequencerKeystoreAddress,
    uint256 l1Origin,
    uint256 baseFeeScalar,
    uint256 blobBaseFeeScalar
) {
    if (isL1Initiated) {
      feePerGas = bytes(0x);
      sequencerKeystoreAddress = L1_INITIATED_SEQUENCER_ADDRESS;
      require(l1InitiatedNonce != bytes(0x));
    } else {
      require(l1InitiatedNonce == bytes(0x));
      require(feePerGas != bytes(0x));
    }

    KeystoreAccount memory sponsorAcct;
    if (sponsorAcctBytes != bytes(0x)) {
        require(sponsorProof != bytes(0x));
        sponsorAcct = rlp.decode(sponsorAcctBytes);
    }

    bytes32 userMsgHash = keccak256(
        abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR,
            keccak256(abi.encode(
                UPDATE_TYPEHASH,
                userAcct.keystoreAddress,
                nonce,
                feePerGas,
                keccak256(newUserData),
                keccak256(newUserVkey)
            ))
        )
    );
    authenticateMsg(userAcct.dataHash, userMsgHash, userProof, userAcct.vkey);

    bytes32 payorAddress = sponsorAcctBytes != bytes(0x) ? sponsorAcct.keystoreAddress : userAcct.keystoreAddress;
    if (sponsorAcctBytes != bytes(0x)) {
        bytes32 sponsorMsgHash = keccak256(
            abi.encodePacked(
                "\x19\x01",
                DOMAIN_SEPARATOR,
                keccak256(abi.encode(SPONSOR_TYPEHASH, sponsorAcct.keystoreAddress, userMsgHash, userAcct.keystoreAddress))
            )
        );
        authenticateMsg(sponsorAcct.dataHash, sponsorMsgHash, sponsorProof, sponsorAcct.vkey);
    }

    // state-dependent validity
    authenticateKeystoreAccount(userAcct);
    require(nonces[userAcct.keystoreAddress] == nonce);

    if (sponsorAcctBytes != bytes(0x)) {
        authenticateKeystoreAccount(sponsorAcct);
    }

    bytes memory transaction = abi.encodePacked(
      KeystoreTxType.UPDATE,
      isL1Initiated,
      l1InitiatedNonce,
      rlp.encode([
          nonce,
          feePerGas,
          newUserData,
          newUserVkey,
          userAcct.keystoreAddress,
          userAcct.salt,
          userAcct.dataHash,
          userAcct.vkey,
          userProof,
          sponsorAcctBytes,
          sponsorProof
      ])
    );
    uint256 dataFee = getDataFee(transaction, l1Origin, baseFeeScalar, blobBaseFeeScalar, isL1Initiated);
    uint256 fees = uint256(feePerGas) * UPDATE_GAS + dataFee;
    require(balances[payorAddress] >= fees);

    // deduct fees and increment nonce
    nonces[userAcct.keystoreAddress] = nonce + 1;

    balances[payorAddress] -= fees;
    balances[sequencerKeystoreAddress] += fees;

    // update the state
    state[userAcct.keystoreAddress] = (keccak256(newUserData), keccak256(newUserVkey));
}

The state-independent validity condition is that:

The transaction is correctly RLP encoded with valid fields, authenticateMsg(userAcct.dataHash, userMsgHash, userProof, userAcct.vkey) passes, and authenticateMsg(sponsorAcct.dataHash, sponsorMsgHash, sponsorProof, sponsorAcct.vkey) passes if sponsorAcctBytes != bytes(0x).

The state-dependent validity condition is that:

authenticateKeystoreAccount(userAcct) passes, require(nonces[userAcct.keystoreAddress] == nonce) passes, authenticateKeystoreAccount(sponsorAcct) passes if sponsorAcctBytes != bytes(0x), and require(balances[sponsorAcctBytes != bytes(0x) ? sponsorAcct.keystoreAddress : userAcct.keystoreAddress] >= uint256(feePerGas) * UPDATE_GAS + dataFee) passes.