Keystore accounts support deposit and withdrawal operations to transfer funds between Ethereum (L1) and the keystore rollup. Deposits move ETH from L1 to a keystore account, while withdrawals transfer funds from a keystore account back to L1.

Deposits

Deposit is the process of moving funds from L1 to a keystore account on the rollup, which is done by executing a DEPOSIT transaction on the keystore. DEPOSIT transaction are L1-initiated transactions. A transaction must be submitted to the rollup bridge on L1 using the initiateL1Transaction function. The bridge performs validation to ensure sufficient funds are deposited.

L1-Initiated Transactions Format

L1-initiated transactions follow this structure defined in the keystore bridge contract:

enum KeystoreTxType {
    DEPOSIT,
    WITHDRAW,
    UPDATE
}

struct L1InitiatedTransaction {
    KeystoreTxType txType;
    bytes data;
}

Here, the fields are given by:

  • txType is the transaction type. For DEPOSIT transactions, it is KeystoreTxType.DEPOSIT.
  • data is the transaction data, serialized differently depending on the transaction type. For DEPOSIT transactions, it represents the keystore address.

Deposit transaction amount is determined by the L1-initiated transaction value.

Withdrawals

Withdrawal is the process of moving funds from keystore to L1, which is done by executing a WITHDRAW transaction on the keystore. A WITHDRAW transaction is serialized in the following format:

struct KeystoreAccount {
    bytes32 keystoreAddress;
    bytes32 salt;
    bytes32 dataHash;
    bytes vkey;
}

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);

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.

Then, the serialized transaction can be sent to the sequencer via the keystore_sendRawTransaction RPC endpoint.

Once a withdrawal transaction is finalized on L1, it is possible to withdraw funds from the keystore bridge contract to the requested L1 Ethereum address, which is determined by the to field of the withdrawal transaction. This process is called finalizing a withdrawal.

To finalize a withdrawal, call the finalizeWithdrawal method on the keystore bridge contract, which is defined as:

struct OutputRootPreimage {
    bytes32 stateRoot;
    bytes32 withdrawalsRoot;
    bytes32 lastValidBlockhash;
}

function finalizeWithdrawal(
    uint256 batchIndex,
    OutputRootPreimage calldata preimage,
    bytes32 keystoreAddress,
    uint256 withdrawAmount,
    uint256 nonce,
    address to,
    bytes32[] calldata proof,
    uint256 isLeft
)

Let say we want to finalize withdrawal transaction tx. Assume tx was included in block k, and k was committed in batch c. Also assume c is finalized on L1.

To finalize tx (to withdraw funds from bridge contract) user needs to call finalizeWithdrawal method on the contract with following arguments:

  • batchIndex such batchIndex >= c, such that there is a batch h where h.batchIndex = batchIndex.
  • preimage must be a OutputRootPreImage where:
    • preimage.stateRoot = h.lastBlock.stateRoot
    • preimage.withdrawalsRoot = h.lastBlock.withdrawalsRoot
    • preimage.lastValidBlockhash = h.lastBlock.blockhash
  • keystoreAddress = t.userAcct.keystoreAddress
  • withdrawAmount = t.amt
  • nonce = t.nonce
  • to = t.to
  • proof to be a Merkle Proof showing (withdrawalHash, withdrawal) pair exists in the withdrawals state at block h.lastBlock, where:
    • withdrawalHash = keccak256(abi.encodePacked(t.userAcct.keystoreAddress, t.nonce))
    • withdrawal = Withdrawal(t.to, t.amt)
  • isLeft is a uint256 that packs all the isLeft booleans in the Merkle proof returned by the node JSONRPC server.

The Axiom SDK simplifies this process by providing a single method, buildFinalizeWithdrawalArgs that prepares the required arguments for finalizeWithdrawal.

Latency

The latency of deposits is mainly determined by the L1-initiated transaction inclusion time. This refers to the total time from the submission of an L1-initiated transaction to L1 until it is included in an L2 block. This timing depends on how closely the sequencer follows L1. In keystore, the sequencer lags behind L1 blocks by 10–30 blocks.

The latency of a withdrawal can be broken down into two parts:

  1. Proof generation time: the total time it takes for the signature prover to generate the ZK proof(s) for a transaction.
  2. Transaction finalization: the total time from the submission of a transaction to the sequencer until it is finalized on the L1 bridge.

Deposit and Withdrawals with the SDK

We provide a Typescript SDK for helping interacting with the keystore. You can find an example of a key rotation using this SDK here.