Understanding Pubdata Costs in the ZKsync Era
This guide aims to explain how ZKSync Era handles pubdata costs, highlighting the differences from Ethereum's gas model and exploring the implications for users and developers.
What you'll learn:
- Understand ZKSync Era's pubdata cost model.
- Deploy a voting contract on ZKSync Era.
- Experiment with different gas settings on transactions.
Tools:
- - zksync-cli
- - hardhat
- - zksync-ethers
Understanding Pubdata Costs in the ZKsync Era
ZKsync Era, as a state diff-based ZK rollup, introduces a unique approach to charging for pubdata.
Unlike traditional rollups that publish transaction data (calldata), ZKsync Era publishes state changes as pubdata. This includes:
- Modified storage slots
- Smart contract bytecodes
- L2 → L1 messages and logs
The charge is calculated as pubdata_bytes_published * gas_per_pubdata
directly from the context of the execution.
This approach allows for more efficient handling of applications that frequently modify the same storage slot, such as oracles.
These applications can update storage slots multiple times while maintaining a constant footprint on L1 pubdata.
Key Differences from Ethereum
Before diving into ZKsync Era's model, it's important to understand key differences from Ethereum's gas model:
- Volatile L1 Gas Prices: L2 transaction prices depend on fluctuating L1 gas prices, making it impossible to use hardcoded gas costs.
- Zero-Knowledge Proofs: As a zkRollup, ZKsync Era must prove every operation with zero-knowledge proofs, adding complexity to the fee structure.
- Separation of Computation and Pubdata Costs: Unlike Ethereum, where all costs are represented by gas, ZKsync Era distinguishes between computational costs and pubdata costs.
ZKsync Era's Approach to Pubdata Costs
ZKsync Era has developed a unique solution for charging pubdata costs in a state diff rollup:
- Dynamic Pricing: Pubdata costs are calculated dynamically based on current L1 gas prices.
- Post-Execution Pubdata Charging: Users are charged for pubdata based on a counter that tracks pubdata usage during transaction execution. This includes storage writes, bytecode deployment, and L2->L1 messages. The final cost depends on the amount of pubdata used and the gas price per pubdata unit, which is determined at the time of execution.
- Efficient for Repeated Operations: Applications that repeatedly modify the same storage slots benefit from this model, as they're not charged for each individual update.
How ZKsync Era Charges for Pubdata (Process)
ZKsync Era employs a sophisticated method for charging pubdata costs, addressing several challenges inherent to state diff-based rollups:
- Post-Charging Approach: Instead of pre-charging for pubdata (which is impossible due to the nature of state diffs), ZKsync Era uses a post-charging mechanism.
- Pubdata Counter: ZKsync Era uses a pubdata counter that tracks potential pubdata usage during transaction execution.
This counter is modified by the operator for storage writes (which can be positive or negative) and incremented by system contracts for L1 data publication.
The counter can revert along with other state changes. The final value of this counter, combined with the gas price per pubdata unit, determines the
pubdata cost of the transaction.
- The system maintains a counter that tracks how much pubdata has been spent during a transaction.
- Execution Process:
- The current pubdata spent is recorded as basePubdataSpent.
- Transaction validation is executed.
- The system checks if (getPubdataSpent() - basePubdataSpent) * gasPerPubdata <= gasLeftAfterValidation.
- If the check fails, the transaction is rejected (not included in the block). → Note this one.
- The main transaction is executed.
- The pubdata check is repeated. If it fails at this stage, the transaction is reverted (user pays for computation but no state changes occur).
- If a paymaster is used, steps d-f are repeated for the paymaster's postTransaction method.
- Pubdata Counter Modifications:
- Storage writes: The operator specifies the increment for the pubdata counter. Note that this value can be negative if, for example, the storage diff is being reversed, such as in the case of a reentrancy guard.
- Publishing bytes to L1: The counter is incremented by the number of bytes published.
- Transaction revert: The pubdata counter value reverts along with storage and events.
- Advantages of Post-Charging:
- Removes unnecessary overhead.
- Decouples execution gas from data availability gas.
- Eliminates caps on
gasPerPubdata.
Implications for Users and Developers
- Cost Predictability: While costs may vary with L1 gas prices, users can estimate costs based on the state changes their transactions will cause.
- Optimization Opportunities: Developers can optimize their applications to minimize state changes, potentially reducing users' costs.
- Efficient for Certain Use Cases: Applications like oracles or high-frequency trading platforms may find this model particularly cost-effective.
- Transaction Behavior: Users should be aware that transactions may be rejected or reverted based on pubdata costs, even if they seem to have sufficient gas for execution.
- Flexible Pricing: The absence of hard caps on
gasPerPubdata
allows for more flexible pricing models.
The following section provides practical insights into measuring gas costs and setting the DEFAULT_GAS_PER_PUBDATA_LIMIT
in the ZKsync era.
Measuring Gas Costs and Setting Different Values for Pubdata Gas Limit
This guide demonstrates how to measure gas costs and set the DEFAULT_GAS_PER_PUBDATA_LIMIT
in the ZKsync Era using a practical example:
a ZKFest Voting Contract. What is Max Gas Per Pubdata? Max gas per pubdata is a value attached to each transaction on ZKsync Era,
representing the maximum amount of gas a user is willing to pay for each byte of pubdata (public data) published on Ethereum.
Key points:
- Default value: 50,000 gas per pubdata byte
- Can be customized per transaction; in this case, we're testing three cases
- Affects transaction success and cost
Objective
We'll deploy and interact with a smart contract using various gas settings to understand how different parameters affect transaction execution and costs on ZKsync Era.
Running the Experiment
- Set up your environment by creating a new project with ZKsync CLI
- Create the ZKFestVoting contract and the deploy script based on the code below
- Analyze the output for each scenario, paying attention to:
- Successful transactions and their gas usage
- Rejected transactions and their error messages
Step 1: Smart Contract (ZKFestVoting.sol)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract ZKFestVoting {
enum Stage { Culture, DeFi, ElasticChain }
mapping(address => uint8) public participation;
event Voted(address indexed voter, Stage stage);
function vote(string memory stageName) external {
uint256 stageIndex = getStageIndex(stageName);
require(stageIndex < 3, "Invalid stage");
uint256 stageBit = 1 << stageIndex;
require((participation[msg.sender] & stageBit) == 0, "Already voted for this stage");
participation[msg.sender] |= uint8(stageBit);
emit Voted(msg.sender, Stage(stageIndex));
}
// Helper functions omitted for brevity
}
Full code for ZKFestVoting.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract ZKFestVoting {
// Enum: A user-defined type that consts of a set of named constants, we're defining the stages of ZKFest
enum Stage { Culture, DeFi, ElasticChain }
// Mapping: A key-value data structure that allows for efficient data lookup
mapping(address => uint8) public participation;
// Event: A way to emit logs on the blockchain, useful for off-chain applications
event Voted(address indexed voter, Stage stage);
/*
Vote function: allows a user to vote for a stage
- converts stage name to index
- checks for valid state and prevents double voting
- uses bit manipulation (1 << stageIndex) to efficiently store votes in single uint8
- updates participation using bitwise OR (|=)
*/
function vote(string memory stageName) external {
uint256 stageIndex = getStageIndex(stageName);
require(stageIndex < 3, "Invalid stage");
uint256 stageBit = 1 << stageIndex; // 1 left shifted by stageIndex
// Check if the user has already voted for the stage, the participation is checking the transaction sender and the stageBit is the stage the user is voting for
require((participation[msg.sender] & stageBit) == 0, "Already voted for this stage");
// we're updating the participation mapping with the stageBit, the |= is the bitwise OR assignment operator, it's used to update the participation mapping with the stageBit
participation[msg.sender] |= uint8(stageBit);
// emit does not store data, it only logs the event, in this case it's logging the voter and the stage
emit Voted(msg.sender, Stage(stageIndex));
}
// Helper function to get the index of the stage
function getStageIndex(string memory stageName) internal pure returns (uint256) {
// the goal of the if statements is to convert the stageName to an index, the keccak256 is a hash function that converts the stageName to a hash,
// and we're checking if the hash of the stageName is equal to the hash of the string "Culture"
// we're using bytes to convert the stageName to a byte array, because keccak256 expects a byte array
if (keccak256(bytes(stageName)) == keccak256(bytes("Culture"))) return 0;
if (keccak256(bytes(stageName)) == keccak256(bytes("DeFi"))) return 1;
if (keccak256(bytes(stageName)) == keccak256((bytes("ElasticChain")))) return 2;
revert("Invalid stage name");
}
function hasVoted(address voter, Stage stage) external view returns (bool) {
return (participation[voter] & (1 << uint256(stage))) != 0;
// explain in detail what the above line does:
// 1. participation[voter] is the bitmask of the voter's votes, bitmask in solidity is a way to store multiple boolean values in a single variable
// 2. (1 << uint256(stage)) is the bitmask of the stage, it's a 1 left shifted by the stage index
// 3. & is the bitwise AND operator, it's used to check if the voter has voted for the stage
// 4. != 0 is used to check if the voter has voted for the stage
}
function voterStages(address voter) external view returns (bool[3] memory) {
uint8 participationBits = participation[voter];
return [
participationBits & (1 << 0) != 0, // Check Culture stage
participationBits & (1 << 1) != 0, // Check DeFi stage
participationBits & (1 << 2) != 0 // Check ElsticChain stage
];
}
}
The ZKFestVoting
contract allows participants to vote for different stages of ZKFest.
Key features:
- Supports voting for three stages: Culture, DeFi, and ElasticChain.
- Prevents double voting for the same stage.
- Tracks voter participation across all stages.
Step 2: Deployment Script (deploy.ts)
import { deployContract, getProvider, getWallet } from "./utils";
import { Deployer } from "@matterlabs/hardhat-zksync";
import * as hre from "hardhat";
import { ethers } from "ethers";
import { utils } from "zksync-ethers";
export default async function () {
const wallet = getWallet();
const provider = getProvider();
const deployer = new Deployer(hre, wallet);
// Deploy the contract
const artifact = await deployer.loadArtifact("ZKFestVoting");
const deploymentFee = await deployer.estimateDeployFee(artifact, []);
console.log(`Estimated deployment fee: ${ethers.formatEther(deploymentFee)} ETH`);
const contract = await deployContract("ZKFestVoting", [], {
wallet: wallet,
noVerify: false
});
const contractAddress = await contract.getAddress();
console.log(`ZKFestVoting deployed to: ${contractAddress}`);
// Test different scenarios
const stageNames = ["Culture", "DeFi", "ElasticChain"];
let currentStageIndex = 0;
const sendAndExplainTx = async (gasPerPubdata: string | number, gasLimit: string | number) => {
// Implementation details omitted for brevity
}
const createCustomTx = async (gasPerPubdata: string | number, gasLimit: string | number, stageName: string) => {
// Implementation details omitted for brevity
}
// Test different scenarios
await sendAndExplainTx(utils.DEFAULT_GAS_PER_PUBDATA_LIMIT, "2000000");
await sendAndExplainTx("100", "2000000"); // Very low gasPerPubdata
await sendAndExplainTx(utils.DEFAULT_GAS_PER_PUBDATA_LIMIT, "100000000"); // Very high gasLimit
}
Full code for deploy.ts script
import { deployContract, getProvider, getWallet } from "./utils";
import { Deployer } from "@matterlabs/hardhat-zksync";
import * as hre from "hardhat";
import { ethers } from "ethers";
import { utils } from "zksync-ethers";
export default async function () {
console.log("Deploying ZKFestVoting contract...");
// Get the wallet to deploy from
const wallet = getWallet();
const provider = getProvider();
console.log(`Deploying from address: ${wallet.address}`);
const deployer = new Deployer(hre, wallet);
try {
// Deploy the contract
// Note: We're not passing any constructor arguments here
const artifact = await deployer.loadArtifact("ZKFestVoting");
const deploymentFee = await deployer.estimateDeployFee(artifact, []);
console.log(`Estimated deployment fee: ${ethers.formatEther(deploymentFee)} ETH`);
const contract = await deployContract("ZKFestVoting", [], {
wallet: wallet,
// Set to false if you want the contract to be verified automatically
noVerify: false
});
const contractAddress = await contract.getAddress();
console.log(`ZKFestVoting deployed to: ${contractAddress}`);
// Test different scenarios
const stageNames = ["Culture", "DeFi", "ElasticChain"];
let currentStageIndex = 0;
const sendAndExplainTx = async (gasPerPubdata: string | number, gasLimit: string | number) => {
console.log(`\nTesting with gasPerPubdata: ${gasPerPubdata}, gasLimit: ${gasLimit}`);
try {
const stageName = stageNames[currentStageIndex];
currentStageIndex++;
const customTx = await createCustomTx(gasPerPubdata, gasLimit, stageName);
console.log(`Voting for stage: ${stageName}`);
console.log("Custom transaction created, attempting to send...");
const txResponse = await wallet.sendTransaction(customTx);
console.log(`Transaction sent. Hash: ${txResponse.hash}`);
console.log("Transaction sent, waiting for confirmation...");
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Transaction confirmation timeout")), 60000) // 60 second timeout
);
// const receipt = await txResponse.wait();
const receiptPromise = txResponse.wait();
// const receipt = await Promise.race([receiptPromise, timeoutPromise]);
const receipt = await Promise.race([receiptPromise, timeoutPromise]) as ethers.TransactionReceipt;
console.log("Transaction successful!");
console.log(`Gas used: ${receipt.gasUsed}`);
} catch (error) {
console.log("Transaction failed!");
console.error("Error details:", error);
if (error instanceof Error) {
console.error("Error message:", error.message);
}
}
}
const createCustomTx = async (gasPerPubdata: string | number, gasLimit: string | number, stageName: string) => {
// const voteFunctionData = contract.interface.encodeFunctionData("vote", [0]); // Vote for Culture stage
const voteFunctionData = contract.interface.encodeFunctionData("vote", [stageName]); // Vote for Culture stage
// we add stageName as a string to the array because the encodeFunctionData expects an array
const gasPrice = await provider.getGasPrice();
let customTx = {
to: contractAddress,
from: wallet.address,
data: voteFunctionData,
gasLimit: ethers.getBigInt(gasLimit),
gasPrice: gasPrice,
chainId: (await provider.getNetwork()).chainId,
nonce: await provider.getTransactionCount(wallet.address),
type: 113,
customData: {
gasPerPubdata: ethers.getBigInt(gasPerPubdata)
},
value: ethers.getBigInt(0),
};
return customTx;
}
// Test different scenarios
await sendAndExplainTx(utils.DEFAULT_GAS_PER_PUBDATA_LIMIT, "2000000");
await sendAndExplainTx("100", "2000000"); // Very low gasPerPubdata,
await sendAndExplainTx(utils.DEFAULT_GAS_PER_PUBDATA_LIMIT, "100000000"); // Very high gasLimit
// // Get and log the transaction receipt for the vote
// const receipt = await deployContract
} catch (error) {
console.error("Deployment or interaction failed: ", error);
process.exitCode = 1;
}
}
This deployment script serves as a practical example of how to interact with ZKsync Era, showcasing the impact of different gas settings on transaction execution and costs.
This script deploys the contract and tests scenarios with varying gasPerPubdata
and gasLimit
values.
The script includes:
- Deploy the
ZKFestVoting
contract. - Execute a series of test votes with different
gasPerPubdata
andgasLimit
settings. - Log the results of each transaction, including gas usage and any errors encountered.
How It Works
- Transaction Submission: When sending a transaction, specify the max gas per pubdata.
- Execution: ZKsync Era executes the transaction and calculates the actual pubdata cost.
- Comparison: The actual cost is compared against your specified max value.
- Outcome:
- If actual cost ≤ specified max: Transaction succeeds
- If actual cost > specified max: Transaction is rejected
- Which in this case, it's happening with the very low gasPerPubdata example
await sendAndExplainTx("100", "2000000");
- Which in this case, it's happening with the very low gasPerPubdata example
Key Aspects of Gas Measurement and DEFAULT_GAS_PER_PUBDATA_LIMIT
- Deployment Fee Estimation: The script estimates the deployment fee before deploying the contract, giving insight into the initial cost.
- Custom Transaction Creation:
The
createCustomTx
function allows customgasPerPubdata
andgasLimit
values to be set for each transaction. - Testing Different Scenarios:
- failsDefault
DEFAULT_GAS_PER_PUBDATA_LIMIT
with a moderategasLimit
- Very low
gasPerPubdata
to see how it affects transaction execution - Default
DEFAULT_GAS_PER_PUBDATA_LIMIT
with a very highgasLimit
- failsDefault
- Transaction Monitoring: The script logs transaction details, including gas used and any errors encountered.
- Error Handling: Comprehensive error handling and logging provide insights into transaction failures.
Key Takeaways
- The
DEFAULT_GAS_PER_PUBDATA_LIMIT
(accessed via zksync-ethers library onutils.DEFAULT_GAS_PER_PUBDATA_LIMIT
) serves for setting default pubdata gas limits. - Very low gas per pubdata values may lead to transaction rejection due to insufficient pubdata gas.
- In this case, the transaction won’t be included in the blockchain, and the user is not charged anything.
- Extremely high
gasLimit
values may not necessarily improve transaction success rates but could lead to higher upfront costs. - The optimal values depend on the specific operation and current network conditions.
By experimenting with these parameters, developers can find the right balance between transaction success rate cost efficiency for their ZKsync Era applications.