I’ve shipped on L1, banged my head on gas limits, and watched protocols grind their UX down to the TPS ceiling. zkSync Era actually fixes a lot of this. In this post I’ll show you how I build, deploy, and pay for gas with ERC‑20s on Era today, plus what ZK does (and does not) give you for privacy.
Who this is for
If you’re comfortable with Solidity/Hardhat or Foundry and want a fast track to production on a ZK rollup, this is for you. I’ll keep it practical and opinionated.
TL;DR
- zkSync Era is a zk‑rollup that’s EVM‑compatible and actually cheap to use. You get L1 security, L2 speed, and native account abstraction.
- You’ll compile with zksolc (or run Era’s EVM interpreter), deploy with Hardhat plugins, and interact via zksync‑ethers .
- Fees have a new knob: gas per pubdata . Understand it and your app gets predictable costs.
- Privacy isn’t automatic. Mainnet Era is public. If you want privacy you need a different DA mode (validium/volition) or a private chain.
How Era scales: the quick mental model
- Validity proofs : batches of L2 txs are proven, then verified on Ethereum. No challenge window.
- State diffs not txs : Era publishes compressed state changes instead of full tx calldata. That’s why pubdata matters.
- EIP‑4844 blobs : when available, batches can post data to blobs, dropping costs further.
- EraVM + EVM compatibility : you can compile with zksolc for native EraVM, or run EVM bytecode via the interpreter. In practice I use zksolc for production because you unlock the ZK‑friendly optimisations and tooling.
Dev setup in 5 minutes (Hardhat)
Node 18+, pnpm/yarn/npm, and a Sepolia key in your
.env
.
# 1) Bootstrap a project with the official template
npx zksync-cli create era-demo --template hardhat_solidity
cd era-demo && pnpm i
# 2) Add the all-in-one plugin (wraps compile/deploy/verify)
pnpm add -D @matterlabs/hardhat-zksync
# Optional: if you prefer explicit plugins
pnpm add -D @matterlabs/hardhat-zksync-solc @matterlabs/hardhat-zksync-deploy @matterlabs/hardhat-zksync-verify @matterlabs/hardhat-zksync-ethers
# 3) Set your deployer key
cp .env.example .env
# edit WALLET_PRIVATE_KEY=0x...
hardhat.config.ts
minimal config I actually ship with:
import "@matterlabs/hardhat-zksync";
import { HardhatUserConfig } from "hardhat/config";
import * as dotenv from "dotenv";
dotenv.config();
const config: HardhatUserConfig = {
defaultNetwork: "ZKsyncEraSepolia",
zksolc: {
version: "1.5.15",
settings: {
codegen: "yul",
optimizer: {
mode: "z",
fallback_to_optimizing_for_size: true,
},
},
},
networks: {
ZKsyncEraSepolia: {
url: "https://sepolia.era.zksync.dev",
ethNetwork: "sepolia",
zksync: true,
verifyURL: "https://explorer.sepolia.era.zksync.dev/contract_verification",
accounts: process.env.WALLET_PRIVATE_KEY ? [process.env.WALLET_PRIVATE_KEY] : [],
},
ZKsyncEraMainnet: {
url: "https://mainnet.era.zksync.io",
ethNetwork: "mainnet",
zksync: true,
verifyURL: "https://zksync2-mainnet-explorer.zksync.io/contract_verification",
accounts: process.env.WALLET_PRIVATE_KEY ? [process.env.WALLET_PRIVATE_KEY] : [],
},
// Local dev nodes you can toggle into when running integration tests
anvilZKsync: { url: "http://127.0.0.1:8011", ethNetwork: "http://localhost:8545", zksync: true },
dockerizedNode: { url: "http://localhost:3050", ethNetwork: "http://localhost:8545", zksync: true },
},
};
export default config;
A tiny contract and deployment flow
Create contracts/Greeter.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Greeter {
string private greeting;
event Greeted(address indexed caller, string newGreeting);
constructor(string memory initial) {
greeting = initial;
}
function greet() external view returns (string memory) {
return greeting;
}
function setGreeting(string calldata g) external {
greeting = g;
emit Greeted(msg.sender, g);
}
}
Deployment script scripts/deploy.ts
:
import { ethers, network } from "hardhat";
async function main() {
const CONTRACT_NAME = "Greeter";
const ARGS = ["Hey zkSync 👋"];
console.log(`Deploying ${CONTRACT_NAME} to ${network.name}`);
const contract = await ethers.deployContract(CONTRACT_NAME, ARGS);
await contract.waitForDeployment();
const addr = await contract.getAddress();
console.log(`${CONTRACT_NAME} deployed at ${addr}`);
}
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
Compile and deploy:
pnpm hardhat compile
pnpm hardhat run scripts/deploy.ts --network ZKsyncEraSepolia
Verify on the explorer (one‑liner):
pnpm hardhat verify --network ZKsyncEraSepolia <DEPLOYED_ADDRESS>
Note: on Era, deployments are EIP‑712 transactions. Under the hood the bytecode travels in
factoryDeps
. Hardhat plugins take care of this.
Interacting from a dapp: zksync‑ethers, gas in ERC‑20s
I like zksync‑ethers v6 for client apps. It mirrors ethers, but adds Era‑specific customData
and helpers for account abstraction and paymasters.
import { Provider, Wallet, Contract, utils } from "zksync-ethers";
import { abi } from "../artifacts-zk/contracts/Greeter.sol/Greeter.json";
const provider = new Provider("https://sepolia.era.zksync.dev");
const sender = new Wallet(process.env.WALLET_PRIVATE_KEY!, provider);
const greeter = new Contract("<DEPLOYED_ADDRESS>", abi, sender);
// Example: pay fees with an ERC-20 via an ApprovalBased paymaster
// You’ll need a token address and a paymaster address on the same network
const token = "0xYourTestToken";
const testnetPaymaster = "0xYourPaymaster";
async function setGreetingWithPaymaster(newGreeting: string) {
const gasPrice = await provider.getGasPrice();
const gasLimit = await greeter.estimateGas.setGreeting(newGreeting);
const fee = gasPrice * BigInt(gasLimit);
const paymasterParams = utils.getPaymasterParams(testnetPaymaster, {
type: "ApprovalBased",
token,
minimalAllowance: fee,
innerInput: new Uint8Array(),
});
const tx = await greeter.setGreeting(newGreeting, {
maxFeePerGas: gasPrice,
maxPriorityFeePerGas: 0n,
customData: {
gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
paymasterParams,
},
});
await tx.wait();
}
Why this matters: with native AA + paymasters, you can sponsor txs or make users pay in your token. Yes, including mobile users with zero ETH.
Bridging funds for testing
You can bridge via CLI or the web bridge.
CLI deposit ETH to L2 testnet:
# prompts for chain, private key, amount, recipient
npx zksync-cli bridge deposit
Withdraw back to L1:
npx zksync-cli bridge withdraw
I usually keep a tiny ETH balance on L2 for when a paymaster isn’t available.
Local testing options
When CI needs deterministic runs:
- anvil‑zksync or the dockerized L2+L1 stack for proper L1↔L2 messaging flows.
- Hardhat’s
@matterlabs/hardhat-zksync-node
is handy for smoke tests.
Fees on Era: the pubdata knob
On Ethereum you mostly juggle gas price and gas limit. On Era there’s a third parameter: gas per pubdata . Pubdata is the payload that must be published to L1 (state diffs, L2→L1 messages, bytecode). The network charges for it separately because it’s the scarce resource. The SDK sets a safe default, but for high‑volume contracts you’ll want to understand and tune it during deployment and writes.
Practical notes:
- Many txs don’t publish much pubdata at all, so execution dominates. Others (deployments, heavy storage writes) are pubdata‑heavy.
- Use the SDK’s
DEFAULT_GAS_PER_PUBDATA_LIMIT
unless you really know better. It signs a generous ceiling; the operator can use less.
EraVM vs straight EVM
- With zksolc you get EraVM bytecode with ZK‑friendly optimisations and some differences in opcodes/gas costs.
- With the EVM bytecode interpreter you can lift‑and‑shift some contracts compiled with vanilla solc. Great for quick ports, but you’ll miss native features and size optimisations.
My rule: ship with zksolc unless you have a specific reason not to.
Common gotchas I keep seeing
- Missing
factoryDeps
when deploying libraries or factories. Let the Hardhat plugins compile and deploy; they include deps automatically. If you roll your own deployer, you must supply deps manually. - Contract too large : zksolc has an instruction cap. Use
optimizer.mode = "z"
, split modules, and trim dead code. - Verification mismatches : make sure you compiled with the same zksolc version and settings used at deploy time.
- EIP‑712 vs EIP‑1559 confusion : Era transactions are EIP‑712 with L2‑specific
customData
. Still setmaxFeePerGas
andmaxPriorityFeePerGas
like normal. - RIP‑7212 P‑256 signatures: available via precompile, but gas costs differ from Ethereum. Test on Era specifically.
- CLI download hiccups for zksolc behind corporate proxies: set your proxy or pin a local compiler binary.
Privacy: the honest bit
Zero‑knowledge proofs give you integrity , not automatic privacy. On zkSync Era mainnet today, your tx data is still public. If you need privacy:
- Choose another DA mode when you control the chain: run a validium or volition via ZK Stack so pubdata isn’t posted to L1. You trade off trust assumptions for lower cost and data secrecy.
- Use a private chain : ZKsync’s Prividium runs as a permissioned validium with API‑level access control, selective disclosure, and proofs anchored to Ethereum.
- Build app‑level privacy : shielded pools, note commitments, or L3s where the privacy logic lives above Era.
For consumer dapps on public Era, assume everything you write to storage is visible.
When to pick zkSync Era
- You want native AA + paymasters for real UX.
- You’re porting an EVM app but want lower fees without a 7‑day exit.
- You plan to graduate to your own chain later. ZK Stack lets you branch into an L3 or a validium with the same tooling.
If your app is L1‑settlement critical with heavy L1 data dependence, or you need on‑chain privacy without extra infra, pick something else or plan an L3.
Foundry quick notes
Prefer Foundry? Use the zksync‑enabled toolchain:
# foundry.toml
[profile.default]
solc_version = "0.8.28"
# zksolc is configured via the zksync foundry plugin docs
The same rules apply: watch instruction count, include factory deps on deploy, and mind pubdata in your gas accounting.