Zk Rollups
In this tutorial, you'll learn zk-rollups including zero-knowledge proofs, validity proofs, zk-SNARKs, zk-STARKs, and how zkSync and StarkNet scale Ethereum with cryptographic guarantees. Why it matters: zk-rollups are the most advanced Ethereum scaling solution, offering the highest security guarantees (mathematical proof) with the lowest gas costs, processing thousands of transactions per second while maintaining Ethereum-level security. By the end, you'll understand how zk-rollups work and deploy on one.
A zk-rollup is a Layer 2 scaling solution that executes transactions off-chain and submits a validity proof (zero-knowledge proof) to Ethereum L1, providing instant finality with full cryptographic security guarantees.
How zk-Rollups Work
zk-rollups batch thousands of off-chain transactions and generate a single validity proof that is verified on Ethereum.
flowchart LR
subgraph "Layer 2 - zk-Rollup"
TX1[Transaction 1]
TX2[Transaction 2]
TXN[Transaction N]
SEQ[Sequencer]
PROOF[Proof Generator]
STATE[State Tree]
end
subgraph "Layer 1 - Ethereum"
SM[zk-Rollup Contract]
VER[Verifier Contract]
end
TX1 --> SEQ
TX2 --> SEQ
TXN --> SEQ
SEQ --> STATE
STATE --> PROOF
PROOF --> SM
SM -->|Submit proof| VER
VER -->|Verify in ms| SM
SM -->|Update state root| SM
SEQ -->|Transaction data| SM
SM -->|DA: Data availability| SM
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// Simplified zk-rollup L1 contract
contract ZKRollupL1 {
// Verifier contract (verifies zk-SNARK proofs)
IVerifier public verifier;
// Current state root (Merkle root of all accounts)
bytes32 public stateRoot;
// Queue of forced transactions (for censorship resistance)
bytes32[] public forcedQueue;
event StateUpdated(bytes32 newRoot, uint256 batchId);
event TransactionForced(address indexed user, bytes data);
constructor(address _verifier) {
verifier = IVerifier(_verifier);
}
// Submit batch with validity proof
function submitBatch(
bytes32 _newRoot,
bytes calldata _transactionsData,
uint256[2] memory a,
uint256[2][2] memory b,
uint256[2] memory c,
uint256[] memory publicInputs
) external {
// Verify the zero-knowledge proof
require(verifier.verifyProof(a, b, c, publicInputs), "Invalid proof");
// Store previous state root as public input to the proof
// Proof should include:
// - Previous state root
// - New state root
// - Batch hash of transactions
stateRoot = _newRoot;
emit StateUpdated(_newRoot, block.number);
}
// Forced transaction (anti-censorship)
function forceTransaction(bytes calldata _txData) external {
forcedQueue.push(keccak256(_txData));
emit TransactionForced(msg.sender, _txData);
}
// Withdraw to L1 (exit from rollup)
function withdraw(
bytes32[] calldata _proof,
uint256 _amount,
address _recipient
) external {
// Verify Merkle proof that user has funds
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, _amount));
require(verifyMerkleProof(_proof, stateRoot, leaf), "Invalid proof");
// Transfer ETH from the rollup contract
payable(_recipient).transfer(_amount);
}
}
zk-SNARKs vs zk-STARKs
Two main types of zero-knowledge proofs used in rollups.
| Feature | zk-SNARK | zk-STARK |
|---|---|---|
| Full Name | Zero-Knowledge Succinct Non-Interactive Argument of Knowledge | Zero-Knowledge Scalable Transparent Argument of Knowledge |
| Proof Size | ~200 bytes | ~100 KB |
| Verification Time | Milliseconds | Milliseconds |
| Trusted Setup | Required | Not required |
| Quantum Resistant | No | Yes |
| Gas Cost on L1 | Lower | Higher |
| Example | zkSync Era, Loopring | StarkNet, dYdX |
Deploying on zkSync Era
zkSync Era is an EVM-compatible zk-rollup, meaning Solidity contracts deploy with minimal changes.
# Install zkSync Hardhat plugin
npm install -D @matterlabs/hardhat-zksync-deploy @matterlabs/hardhat-zksync-solc
// hardhat.config.ts
import { HardhatUserConfig } from "hardhat/config";
import "@matterlabs/hardhat-zksync-deploy";
import "@matterlabs/hardhat-zksync-solc";
const config: HardhatUserConfig = {
zksolc: {
version: "1.3.17",
compilerSource: "binary",
settings: {
optimizer: { enabled: true, runs: 200 },
},
},
networks: {
zkSyncSepolia: {
url: "https://sepolia.era.zksync.dev",
ethNetwork: "sepolia",
zksync: true,
},
},
solidity: {
version: "0.8.19",
},
};
export default config;
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract ZKCounter {
uint256 public count;
event CountIncremented(uint256 newCount);
function increment() external {
count++;
emit CountIncremented(count);
}
function getCount() external view returns (uint256) {
return count;
}
}
// deploy.ts
import { Wallet } from "zksync-ethers";
import { Deployer } from "@matterlabs/hardhat-zksync-deploy";
import { ethers } from "hardhat";
async function main() {
const wallet = new Wallet("YOUR_PRIVATE_KEY");
const deployer = new Deployer(hre, wallet);
const artifact = await deployer.loadArtifact("ZKCounter");
const counter = await deployer.deploy(artifact);
console.log("ZKCounter deployed to:", await counter.getAddress());
// Verify
await hre.run("verify:verify", {
address: await counter.getAddress(),
contract: "contracts/ZKCounter.sol:ZKCounter",
});
}
main().catch(console.error);
Expected output:
ZKCounter deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
// Test interaction
const { expect } = require("chai");
const { Wallet, Provider } = require("zksync-ethers");
describe("ZKCounter", function () {
it("should increment count and emit event on zkSync", async function () {
const provider = new Provider("https://sepolia.era.zksync.dev");
const wallet = new Wallet("PRIVATE_KEY", provider);
const counter = new ethers.Contract(
"0x5Fb...",
["function increment()", "function getCount() view returns (uint256)", "event CountIncremented(uint256)"],
wallet
);
// Increment
const tx = await counter.increment();
const receipt = await tx.wait();
console.log("Gas used:", receipt.gasUsed.toString());
// Verify count
const count = await counter.getCount();
expect(count).to.equal(1);
// Gas on zkSync is ~100x cheaper than L1
console.log("Equivalent L1 gas would be ~50,000");
console.log("Actual zkSync gas: ~300");
});
});
Expected output:
Gas used: 320
Equivalent L1 gas would be ~50,000
Actual zkSync gas: ~300
StarkNet and Cairo
StarkNet uses a different programming language (Cairo) and offers higher throughput.
# Cairo contract example (StarkNet)
%lang starknet
from starkware.cairo.common.cairo_builtins import HashBuiltins
from starkware.starknet.common.syscalls import get_caller_address
from starkware.cairo.common.uint256 import Uint256
@storage_var
func balance(account: felt) -> (res: Uint256):
end
@external
func deposit{syscall_ptr: felt*, pedersen_ptr: HashBuiltins*, range_check_ptr}():
let (caller) = get_caller_address()
let (current) = balance.read(caller)
let (amount) = Uint256(1, 0) # Deposit 1 token
let new_balance = Uint256(current.low + amount.low, current.high + amount.high)
balance.write(caller, new_balance)
return ()
end
@view
func get_balance{syscall_ptr: felt*, pedersen_ptr: HashBuiltins*, range_check_ptr}(
account: felt
) -> (res: Uint256):
let (bal) = balance.read(account)
return (bal)
end
Expected behavior: The Cairo contract stores balances and allows deposits. StarkNet compiles Cairo to Sierra (Safe Intermediate Representation) for L2 execution and generates STARK proofs for L1 verification.
L1-L2 Interaction
zk-rollups support communication between L1 and L2.
| Direction | Method | Delay | Cost |
|---|---|---|---|
| L1 to L2 | Deposit via bridge | Minutes | L1 gas + L2 gas |
| L2 to L1 | Withdrawal via proof | ~1 hour (finality) | L1 verification gas |
| L1 to L2 message | Governance/DAO calls | Minutes | L1 gas |
| L2 to L1 message | Oracle updates | Batch dependent | Covered by sequencer |
// L1 contract that sends a message to zkSync L2
contract L1Messenger {
IL1Bridge public bridge;
function sendMessageToL2(address _l2Contract, bytes calldata _message) external {
// Bridge forwards the message to the zkSync mailbox
bridge.deposit(_l2Contract, _message);
}
}
// L2 contract receiving messages from L1
contract L2Receiver {
address public immutable L1_MESSENGER;
modifier onlyL1Messenger() {
require(msg.sender == L1_MESSENGER, "Not L1 messenger");
_;
}
function onMessageReceived(bytes calldata _data) external onlyL1Messenger {
// Process the message from L1
}
}
Common Errors and Misunderstandings
1. Confusing zk-Rollups with Optimistic Rollups
zk-rollups use validity proofs (instant finality, math-based security). Optimistic rollups use fraud proofs (7-day delay, game-theoretic security). zk-rollups have higher development complexity but better capital efficiency.
2. Assuming Full EVM Equivalence
zkSync Era is EVM-compatible but not identical. Some opcodes differ, and gas costs vary. Not all Solidity code compiles without modification. StarkNet uses Cairo, a different language entirely.
3. Underestimating Proof Generation Cost
Generating zero-knowledge proofs requires significant computation (GPUs). Proof generation time and cost affect how frequently batches are submitted.
4. Forgetting Withdrawal Delay
While zk-rollups have instant L2-to-L2 transfers and fast L1-to-L2, L2-to-L1 withdrawals require waiting for proof generation and L1 finality (~1 hour).
5. Ignoring Data Availability Costs
zk-rollups still post transaction data to L1 (for data availability). While cheaper than L1 execution, this is still a significant cost. EIP-4844 (blobs) reduces this further.
Practice Questions
What is a validity proof in zk-rollups? A cryptographic proof (zk-SNARK or zk-STARK) that mathematically proves all transactions in a batch were executed correctly. The proof is verified on L1 in milliseconds.
How do zk-rollups differ from optimistic rollups? zk-rollups provide instant finality via validity proofs. Optimistic rollups assume validity and have a 7-day challenge period. zk-rollups are more capital efficient but harder to build.
What is the role of a sequencer in a zk-rollup? The sequencer receives L2 transactions, orders them, executes them, produces a new state root, and generates the validity proof. It submits the proof and batch data to L1.
Why do zk-rollups need data availability on L1? So anyone can reconstruct the L2 state from L1 data. This ensures users can always withdraw their funds, even if the sequencer disappears.
What is the difference between zk-SNARKs and zk-STARKs? SNARKs have smaller proofs (~200 bytes) but require a trusted setup. STARKs have larger proofs (~100 KB) but are transparent (no trusted setup) and quantum-resistant.
Challenge
Deploy a complete ERC-20 token bridge between Ethereum Sepolia and zkSync Sepolia using Solidity contracts on both sides. Implement deposit (L1 to L2), execute L2 mint via zkSync's L1->L2 messaging, and withdrawal (L2 to L1) with Merkle proof verification. Test the full round trip using Hardhat with the zkSync plugin.
Real-World Task
Build a multi-asset portfolio dashboard using React that tracks user balances across Ethereum L1, zkSync Era, Arbitrum, and Polygon. Display gas costs for common operations on each network, show cross-chain transfer capabilities with estimated time and cost, and provide a bridge interface using official bridge contracts for each rollup.
Frequently Asked Questions
Next Steps
After mastering zk-rollups, explore Wallet Development for managing assets across rollups, then dive into DeFi Protocols deployed on L2 networks.
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro