How ZKsync Era charges for pubdata

ZKsync Era is a state diff-based rollup. It means that it is not possible to know how much pubdata a transaction will take before its execution. We could charge for pubdata the following way: whenever a user does an operation that emits pubdata (writes to storage, publishes an L2->L1 message, etc.), we charge pubdata_bytes_published * gas_per_pubdata directly from the context of the execution.

However, such an approach has the following disadvantages:

  • This would inherently make execution very divergent from EVM.
  • It is prone to unneeded overhead. For instance, in the case of reentrancy locks, the user will still have to pay the initial price for marking the lock as used. The price will get refunded in the end, but it still worsens the UX.
  • If we want to impose any sort of limit on how much computation a transaction could take (let's call this limit MAX_TX_GAS_LIMIT), it would mean that no more than MAX_TX_GAS_LIMIT / gas_per_pubdata could be published in a transaction, making this limit either too small or forcing us to increase baseFee to prevent the number from growing too much.

To avoid the issues above we need to somehow decouple the gas spent on pubdata from the gas spent on execution. While calldata-based rollups precharge for calldata, we cannot do it, since the exact state diffs are known only after the transaction is finished. We'll use the approach of post-charging. Basically, we'll keep a counter that tracks how much pubdata has been spent and charge the user for the calldata at the end of the transaction.

A problem with post-charging is that the user may spend all their gas within the transaction so we'll have no gas to charge for pubdata from. Note, however, that if the transaction is reverted, all the state changes that were related to it will be reverted too. That's why whenever we need to charge the user for pubdata, but it doesn't provide enough gas, the transaction will get reverted. The user will pay for the computation, but no state changes (and thus, pubdata) will be produced by the transaction.

So it will work the following way:

  1. Firstly, we fix the amount of pubdata published so far. Let's denote it as basePubdataSpent.
  2. We execute the validation of the transaction.
  3. We check whether (getPubdataSpent() - basePubdataSpent) * gasPerPubdata <= gasLeftAfterValidation. If it is not, then the transaction does not cover enough funds for itself, so it should be rejected (unlike revert, which means that the transaction is not even included in the block).
  4. We execute the transaction itself.
  5. We do the same check as in (3), but now if the transaction does not have enough gas for pubdata, it is reverted, i.e., the user still pays the fee to cover the computation for its transaction.
  6. (optional, in case a paymaster is used). We repeat steps (4-5), but now for the postTransaction method of the paymaster.

On the internal level, the pubdata counter is modified in the following way:

  • When there is a storage write, the operator is asked to provide by how much to increment the pubdata counter. Note that this value can be negative if, as in the example with a reentrancy guard, the storage diff is being reversed. There is currently no limit on how much the operator can charge for the pubdata.
  • Whenever there is a need to publish a blob of bytes to L1 (for instance, when publishing a bytecode), the responsible system contract would increment the pubdata counter by bytes_to_publish.
  • Whenever there is a revert in a frame, the pubdata counter gets reverted too, similar to storage & events.

The approach with post-charging removes the unneeded overhead and decouples the gas used for the execution from the gas used for data availability, which removes any caps on gasPerPubdata.

Security considerations for protocol

Now it has become easier for a transaction to use up more pubdata than what can be published within a batch. In such a case, we'll revert the transaction as well.

Security considerations for users

The approach with post-charging introduces one distinctive feature: it is not trivial to know the final price for a transaction at the time of its execution. When a user does .call{gas: some_gas} the final impact on the price of the transaction may be higher than some_gas since the pubdata counter will be incremented during the execution and charged only at the end of the transaction.

While for the average user, this limitation is not relevant, some specific applications may receive certain issues.

Example for a queue of withdrawals

Imagine that there is the following contract:

struct Withdrawal {
   address token;
   address to;
   uint256 amount;
}

Withdrawals[] queue;
uint256 lastProcessed;

function processNWithdrawals(uint256 N) external nonReentrant {
  uint256 current = lastProcessed + 1;
  uint256 lastToProcess = current + N - 1;

  while(current <= lastToProcess) {
    // If the user provided some bad token that takes more than MAX_WITHDRAWAL_GAS
    // to transfer, it is the problem of the user and it will stall the queue, so
    // the `_success` value is ignored.
    Withdrawal storage currentQueue = queue[current];
    (bool _success, ) = currentQueue.token.call{gas: MAX_WITHDRAWAL_GAS}(abi.encodeWithSignature("transfer(to,amount)", currentQueue.to, currentQueue.amount));
    current += 1;
  }
  lastProcessed = lastToProcess;
}

The contract above supports a queue of withdrawals. This queue supports any type of token, including potentially malicious ones. However, the queue will never get stuck, since the MAX_WITHDRAWAL_GAS ensures that even if the malicious token does a lot of computation, it will be bound by this number and so the caller of the processNWithdrawals won't spend more than MAX_WITHDRAWAL_GAS per token.

The above assumptions work in the pre-charge model (calldata based rollups) or pay-as-you-go model (pre-1.5.0 Era). However, in the post-charge model, the MAX_WITHDRAWAL_GAS limits the amount of computation that can be done within the transaction, but it does not limit the amount of pubdata that can be published. Thus, if such a function publishes a very large L1→L2 message, it might make the entire top transaction fail. This effectively means that such a queue would be stalled.

How to prevent this issue on the users' side

If a user really needs to limit the amount of gas that the subcall takes, all the subcalls should be routed through a special contract, that will guarantee that the total cost of the subcall wont be larger than the gas provided (by reverting if needed).

An implementation of this special contract can be seen here. Note, that this contract is not a system one and it will be deployed on some fixed, but not kernel space address.

1. Case of when a malicious contract consumes a large, but processable amount of pubdata

In this case, the topmost transaction will be able to sponsor such subcalls. When a transaction is processed, at most 80M gas is allowed to be passed to the execution. The rest can only be spent on pubdata during the post-charging.

2. Case of when a malicious contract consumes an unprocessable amount of pubdata

In this case, the malicious callee published so much pubdata, that such a transaction can not be included into a batch. This effectively means that no matter how much money the topmost transaction willing to pay, the queue is stalled.

The only way how it is combated is by setting some minimal amount of ergs that still have to be consumed with each emission of pubdata (basically to make sure that it is not possible to publish large chunks of pubdata while using negligible computation). Unfortunately, setting this minimal amount to cover the worst possible case (i.e. 80M ergs spent with maximally 100k of pubdata available, leading to 800 L2 gas / pubdata byte) would likely be too harsh and will negatively impact average UX. Overall, this is the way to go, however for now the only guarantee will be that a subcall of 1M gas is always processable, which will mean that at least 80 gas will have to be spent for each published pubdata byte. Even if higher than real L1 gas costs, it is reasonable even in the long run, since all the things that are published as pubdata are state-related and so they have to be well-priced for long-term storage.

In the future, we will guarantee the processability of subcalls of larger size by increasing the number of pubdata that can be published per batch.

Limiting the gas_per_pubdata

As already mentioned, the transactions on ZKsync depend on volatile L1 gas costs to publish the pubdata for batch, verify proofs, etc. For this reason, ZKsync-specific EIP712 transactions contain the gas_per_pubdata_limit field, denoting the maximum gas_per_pubdata that the operator can charge the user for a single byte of pubdata.

For Ethereum transactions (which do not contain this field), the block's gas_per_pubdata is used.

Improvements in the upcoming releases

The fee model explained above, while fully functional, has some known issues. These will be tackled with the following upgrades.

L1->L2 transactions do not pay for their execution on L1

The executeBatches operation on L1 is executed in O(N) where N is the number of priority ops that we have in the batch. Each executed priority operation will be popped and so it incurs cost for storage modifications. As of now, we do not charge for it.

ZKsync Era Fee Components (Revenue & Costs)

  1. On-Chain L1 Costs
    • L1 Commit Batches: The commit batch transaction submits pubdata (which is the list of updated storage slots) to L1. The cost of a commit transaction is calculated as constant overhead + price of pubdata. The constant overhead cost is evenly distributed among L2 transactions in the L1 commit transaction, but only at higher transaction loads. As for the price of pubdata, it is known how much pubdata each L2 transaction consumed, therefore, they are charged directly for that. Multiple L1 batches can be included in a single commit transaction.
    • L1 Prove Batches: Once the off-chain proof is generated, it is submitted to L1 to make the rollup batch final. Currently, each proof contains only one L1 batch.
    • L1 Execute Batches: The execute batches transaction processes L2 -> L1 messages and marks executed priority operations as such. Multiple L1 batches can be included in a single execute transaction.
    • L1 Finalize Withdrawals: While not strictly part of the L1 fees, the cost to finalize L2 → L1 withdrawals are covered by Matter Labs. The finalize withdrawals transaction processes user token withdrawals from ZKsync Era to Ethereum. Multiple L2 withdrawal transactions are included in each finalize withdrawal transaction.
  2. On-Chain L2 Revenue
    • L2 Transaction Fee: This fee is what the user pays to complete a transaction on ZKsync Era. It is calculated as gasLimit x baseFeePerGas - refundedGas x baseFeePerGas, or more simply, gasUsed x baseFeePerGas.
  3. Profit = L2 Revenue - L1 Costs - Off-Chain Infrastructure Costs

Made with ❤️ by the ZKsync Community