Layer 2 & Scaling
zkSync Development: Building on Zero-Knowledge Layer 2
TL;DR
zkSync Era is the most technically ambitious Layer 2 on Ethereum. It uses zero-knowledge proofs — validity proofs, specifically — instead of the fraud proof model that Arbitrum, Optimism, and Base rely on. That gives you real finality in about an hour instead of seven days, and it opens the door to native account abstraction that works at the protocol level, not bolted on as an afterthought. But building on zkSync is not the same as building on a standard EVM chain. The compiler is different, some opcodes behave differently, and the gas model has its own logic. I have deployed contracts on zkSync Era and hit every one of these rough edges firsthand. This guide covers what you actually need to know to ship on zkSync — from environment setup to deployment to understanding when it is the right chain for your project. If you need help building on zkSync or choosing the right L2, check out my Web3 development services.
What Makes zkSync Different
Most developers hear "zkSync" and think "faster Ethereum." That undersells what is actually happening under the hood. zkSync Era is a zkEVM — a virtual machine that executes smart contracts and then generates a cryptographic proof that the execution was correct. That proof gets submitted to Ethereum L1, where a verifier contract checks it. If the math checks out, the state transition is accepted as final.
This is fundamentally different from how optimistic rollups work. On Arbitrum or Base, the sequencer posts transaction data to L1 and everyone just assumes it is correct. If someone spots fraud, they have seven days to submit a fraud proof and challenge it. That assumption of honesty is why you wait seven days to withdraw from an optimistic rollup.
On zkSync, there is no assumption. The validity proof is the guarantee. Either the math is correct or it is not. There is no challenge period because there is nothing to challenge.
What this means for you as a developer:
- Faster finality. Withdrawals to L1 finalize in roughly one hour — the time it takes to generate and verify the ZK proof — instead of seven days.
- Stronger security guarantees. The security model does not depend on watchers monitoring for fraud. The proof itself is the security.
- Different execution environment. zkSync compiles your Solidity through its own compiler (
zksolc) to a custom bytecode format. This is where the developer experience diverges from a standard EVM chain. - Native account abstraction. Every account on zkSync is a smart contract account by default. This is not ERC-4337 layered on top — it is built into the protocol.
I will be honest — the first time I deployed on zkSync after months on Arbitrum, I was caught off guard by how many small things were different. Not broken, just different. The rest of this guide covers exactly what those differences are and how to handle them.
ZK Rollups vs Optimistic Rollups
Before we get into code, it is worth understanding the architectural split clearly, because it affects every design decision you make.
| ZK Rollups (zkSync) | Optimistic Rollups (Arbitrum, Base, OP) | |
|---|---|---|
| Proof mechanism | Validity proof (math proves correctness) | Fraud proof (assumed correct, challengeable) |
| Finality | ~1 hour (proof generation + verification) | ~7 days (challenge window) |
| Data availability | Compressed state diffs on L1 | Full transaction calldata on L1 |
| EVM compatibility | zkEVM (95%+ compatible, some differences) | Full EVM equivalence |
| Gas costs | Higher compute, lower data | Lower compute, higher data |
| Account model | Native account abstraction | EOA-based (ERC-4337 optional) |
| Maturity | Younger ecosystem, rapidly evolving | Battle-tested, deep liquidity |
The key tradeoff is compatibility vs cryptographic guarantees. Optimistic rollups give you a near-identical EVM experience. ZK rollups give you stronger security properties and faster finality, but you pay for it with a different compiler pipeline and some behavioral quirks.
In my experience, the compatibility gap has narrowed significantly through 2025. Most standard Solidity contracts deploy on zkSync without modification. But "most" is not "all," and the edge cases will bite you if you are not aware of them.
Setting Up for zkSync Development
The toolchain for zkSync is built on top of Hardhat and Foundry, but with zkSync-specific plugins. Here is how I set up a new project.
Using Hardhat (Recommended for zkSync)
mkdir my-zksync-project && cd my-zksync-project
npm init -y
npm install --save-dev \
@matterlabs/hardhat-zksync-solc \
@matterlabs/hardhat-zksync-deploy \
@matterlabs/hardhat-zksync-verify \
@matterlabs/hardhat-zksync-node \
hardhat \
ethers@^6 \
zksync-ethers@^6 \
typescript \
ts-node \
@types/nodeYour hardhat.config.ts needs zkSync-specific configuration:
import { HardhatUserConfig } from "hardhat/config";
import "@matterlabs/hardhat-zksync-solc";
import "@matterlabs/hardhat-zksync-deploy";
import "@matterlabs/hardhat-zksync-verify";
const config: HardhatUserConfig = {
zksolc: {
version: "1.5.6",
settings: {
// enables optimization at the zkSync compiler level
optimizer: {
enabled: true,
mode: "3", // aggressive optimization
},
},
},
solidity: {
version: "0.8.24",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
viaIR: true,
},
},
networks: {
zkSyncTestnet: {
url: "https://sepolia.era.zksync.dev",
ethNetwork: "sepolia",
zksync: true,
verifyURL:
"https://explorer.sepolia.era.zksync.dev/contract_verification",
},
zkSyncMainnet: {
url: "https://mainnet.era.zksync.dev",
ethNetwork: "mainnet",
zksync: true,
verifyURL:
"https://zksync2-mainnet-explorer.zksync.io/contract_verification",
},
},
defaultNetwork: "zkSyncTestnet",
};
export default config;Notice zksync: true on the network config. That flag tells Hardhat to compile with zksolc instead of the standard solc compiler. Miss that flag and your contracts will compile to standard EVM bytecode that will not run on zkSync.
Using Foundry with zkSync
Foundry added zkSync support through foundry-zksync, a fork maintained by Matter Labs:
# install foundry-zksync
curl -L https://raw.githubusercontent.com/matter-labs/foundry-zksync/main/install-foundry-zksync | bash
# initialize project
forge init my-zksync-project --template matter-labs/foundry-zksync-templateI use Hardhat for zkSync deployment and Foundry for local testing and fuzzing. The dual-toolchain approach sounds annoying, but it gives me the best of both worlds — Foundry's speed for tests and Hardhat's mature zkSync plugin ecosystem for deployments.
Solidity Differences on zkSync
This is where most developers get tripped up. Your Solidity code compiles through zksolc, which transforms it into zkEVM bytecode. The language is the same. The behavior is almost the same. But "almost" matters.
What Works Differently
`block.timestamp` and `block.number` have different semantics. On zkSync, block.number returns the L2 batch number, not a per-transaction block number. Batches can contain hundreds of transactions. If your contract logic depends on block-by-block granularity, you need to rethink it.
`EXTCODECOPY`, `EXTCODESIZE`, and `EXTCODEHASH` behave differently for accounts. Because every account on zkSync is a smart contract account, these opcodes return values for all accounts, not just contracts. Code that checks extcodesize > 0 to determine "is this a contract?" will get false positives.
// This pattern BREAKS on zkSync
function isContract(address account) internal view returns (bool) {
uint256 size;
assembly {
size := extcodesize(account)
}
return size > 0; // returns true for ALL accounts on zkSync
}`tx.origin` should not be used for authentication — this is true everywhere, but on zkSync it is even more dangerous because of native account abstraction. Every transaction can originate from a smart contract.
`CREATE` and `CREATE2` work but with different address derivation. The deployed contract address is computed differently because zkSync uses a different bytecode format. If your protocol pre-computes deployment addresses, you need to use zkSync's address derivation formula.
// zkSync CREATE2 address derivation
// keccak256(
// 0xff ++ deployerAddress ++ salt ++ keccak256(bytecodeHash) ++ keccak256(constructorInput)
// )What Works the Same
Standard Solidity patterns — mappings, structs, events, modifiers, inheritance, interfaces — all work identically. OpenZeppelin contracts deploy without modification in most cases. I have deployed AccessControl, ReentrancyGuard, ERC20, ERC721, and Ownable on zkSync without touching a line. The standard patterns you rely on daily are solid.
Native Account Abstraction
This is zkSync's killer feature. On Ethereum L1 and optimistic rollups, you have two types of accounts: externally owned accounts (EOAs controlled by private keys) and smart contract accounts. EOAs initiate transactions. Smart contracts cannot initiate transactions on their own.
On zkSync, every account is a smart contract account. The protocol supports account abstraction natively, which means:
- Users can pay gas fees in any ERC-20 token, not just ETH. A Paymaster contract handles the conversion.
- Transaction validation is programmable. You can implement multi-sig, social recovery, spending limits, session keys — all at the account level.
- Batched transactions are native. A single user action can trigger multiple contract calls atomically.
Implementing a Custom Paymaster
This is a real pattern I have used — a Paymaster that lets users pay gas fees in USDC:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {
IPaymaster,
ExecutionResult,
PAYMASTER_VALIDATION_SUCCESS_MAGIC
} from "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymaster.sol";
import {IPaymasterFlow} from "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymasterFlow.sol";
import {
TransactionHelper,
Transaction
} from "@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol";
import {BOOTLOADER_FORMAL_ADDRESS} from "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract USDCPaymaster is IPaymaster {
IERC20 public immutable usdc;
address public owner;
uint256 public constant PRICE_FOR_PAYING_FEES = 1e6; // 1 USDC
modifier onlyBootloader() {
require(
msg.sender == BOOTLOADER_FORMAL_ADDRESS,
"Only bootloader can call"
);
_;
}
constructor(address _usdc) {
usdc = IERC20(_usdc);
owner = msg.sender;
}
function validateAndPayForPaymasterTransaction(
bytes32,
bytes32,
Transaction calldata _transaction
)
external
payable
onlyBootloader
returns (bytes4 magic, bytes memory context)
{
magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC;
require(
_transaction.paymasterInput.length >= 4,
"Invalid paymaster input"
);
bytes4 paymasterInputSelector = bytes4(
_transaction.paymasterInput[0:4]
);
if (paymasterInputSelector == IPaymasterFlow.approvalBased.selector) {
(address token, uint256 amount, ) = abi.decode(
_transaction.paymasterInput[4:],
(address, uint256, bytes)
);
require(token == address(usdc), "Only USDC accepted");
require(amount >= PRICE_FOR_PAYING_FEES, "Insufficient allowance");
address userAddress = address(uint160(_transaction.from));
usdc.transferFrom(userAddress, address(this), PRICE_FOR_PAYING_FEES);
uint256 requiredETH = _transaction.gasLimit * _transaction.maxFeePerGas;
(bool success, ) = payable(BOOTLOADER_FORMAL_ADDRESS).call{
value: requiredETH
}("");
require(success, "ETH transfer to bootloader failed");
} else {
revert("Unsupported paymaster flow");
}
}
function postTransaction(
bytes calldata,
Transaction calldata,
bytes32,
bytes32,
ExecutionResult,
uint256
) external payable onlyBootloader {}
receive() external payable {}
}This is a simplified version — a production Paymaster needs price oracle integration, rate limiting, and proper access controls. But the pattern shows how native account abstraction gives you capabilities that would require massive infrastructure on an optimistic rollup.
I built a gasless onboarding flow for a client using this exact pattern. New users signed up, received a small USDC airdrop, and could immediately interact with the protocol without ever holding ETH. Try doing that on Arbitrum without a centralized relayer — you cannot.
Deploying Contracts
Deployment on zkSync uses zksync-ethers instead of the standard ethers.js. The deploy script pattern is different from what you are used to.
Hardhat Deploy Script
import { Deployer } from "@matterlabs/hardhat-zksync-deploy";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { Wallet } from "zksync-ethers";
export default async function (hre: HardhatRuntimeEnvironment) {
const wallet = new Wallet(process.env.PRIVATE_KEY!);
const deployer = new Deployer(hre, wallet);
// load the artifact compiled by zksolc
const artifact = await deployer.loadArtifact("MyContract");
// deploy with constructor args
const contract = await deployer.deploy(artifact, [
"0xUSDC_ADDRESS", // constructor arg
]);
await contract.waitForDeployment();
const address = await contract.getAddress();
console.log(`MyContract deployed to: ${address}`);
// verify on zkSync explorer
await hre.run("verify:verify", {
address,
constructorArguments: ["0xUSDC_ADDRESS"],
});
}Run it with:
npx hardhat deploy-zksync --script deploy.ts --network zkSyncTestnetKey Deployment Differences
- Artifacts are different.
zksolcproduces artifacts that include the zkEVM bytecode alongside the standard ABI. You must usedeployer.loadArtifact()instead of reading the artifact JSON directly. - Factory dependencies matter. If your contract deploys other contracts (factory pattern), you need to declare the dependencies explicitly so the deployer knows to include their bytecode.
- Gas estimation works differently. The zkSync sequencer estimates gas using its own model. Always test deployments on testnet before mainnet — gas estimates can surprise you.
// factory pattern — declaring dependencies
const artifact = await deployer.loadArtifact("MyFactory");
const childArtifact = await deployer.loadArtifact("ChildContract");
const contract = await deployer.deploy(artifact, [constructorArg], {
factoryDeps: [childArtifact.bytecode], // include child bytecode
});Gas Model
The zkSync gas model tripped me up more than anything else when I first started deploying. It is not just "cheaper Ethereum gas." The cost structure is fundamentally different because ZK proof generation has its own computational overhead.
How Gas Works on zkSync
Gas on zkSync has two components:
- Computation gas — the cost of executing your transaction in the zkEVM. This is analogous to L1 gas but priced differently because the zkEVM circuit has different costs per operation.
- Pubdata gas — the cost of publishing state diffs to Ethereum L1. This is where zkSync's compression shines — instead of posting full calldata like optimistic rollups, zkSync posts compressed state diffs.
Storage writes are expensive. The first write to a storage slot costs significantly more than subsequent writes because new slots expand the state tree that must be included in the ZK proof. If you are designing a protocol, minimize cold storage writes wherever possible.
Computation is relatively cheaper than on L1. Math-heavy operations — hashing, signature verification, complex calculations — cost less on zkSync relative to storage operations. This is the opposite of the L1 cost profile where storage and compute are more balanced.
Gas Optimization Tips for zkSync
// Pack storage variables (same as L1, but even more impactful on zkSync)
// BAD - 3 storage slots
uint256 amount; // slot 0
bool isActive; // slot 1
address owner; // slot 2
// GOOD - 2 storage slots
uint256 amount; // slot 0
address owner; // slot 1 (20 bytes)
bool isActive; // slot 1 (1 byte, packed with owner)// Use events instead of storage for data you only need off-chain
// Storage write on zkSync: ~5000-20000 gas
// Event emission: ~375-750 gas
// Instead of storing historical data in mappings,
// emit events and index them with The Graph or a custom indexer
emit TradeExecuted(msg.sender, tokenIn, tokenOut, amountIn, amountOut);In practice, I have found zkSync gas costs to be roughly 2-5x more expensive than Arbitrum for simple transfers and token swaps, but competitive for complex DeFi operations where the computation-to-storage ratio is high. If your protocol does heavy math and minimal storage writes, zkSync's gas model actually works in your favor.
Testing zkSync Locally
You cannot just run anvil or hardhat node and expect your zkSync contracts to behave correctly. The standard EVM dev nodes do not simulate zkSync's custom opcodes or account abstraction.
zkSync Local Node
Matter Labs provides era_test_node, a local zkSync node for development:
# install and run the local node
npx zksync-cli dev start
# or run directly
era_test_node runThis gives you a local zkSync environment with pre-funded accounts, the same zkEVM execution, and account abstraction support. It is significantly slower than anvil — proof generation overhead exists even locally — but the behavioral accuracy is worth it.
Testing Strategy
Here is the approach I use:
- Unit tests in Foundry for pure Solidity logic that does not depend on zkSync-specific behavior. Fast iteration, great fuzzing.
- Integration tests on `era_test_node` for anything that touches account abstraction, Paymasters, or zkSync system contracts.
- Testnet deployment on zkSync Sepolia before mainnet. Always. The testnet catches deployment-specific issues that local nodes miss.
import { Provider, Wallet, Contract } from "zksync-ethers";
describe("MyContract on zkSync", () => {
let provider: Provider;
let wallet: Wallet;
let contract: Contract;
beforeAll(async () => {
provider = new Provider("http://localhost:8011"); // era_test_node
wallet = new Wallet(
"0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110",
provider
);
// deploy and initialize contract
});
it("should handle account abstraction correctly", async () => {
// test with smart contract accounts, not just EOAs
// this is critical — behavior differs from standard EVM
});
});The biggest testing gap I see developers fall into is testing only with EOAs on a standard Hardhat node and then being surprised when smart contract accounts behave differently on zkSync. Test with both account types. Always.
The zkSync Ecosystem
The zkSync ecosystem is smaller than Arbitrum's or Base's, but it is growing and it has some genuinely differentiated projects.
DeFi
- SyncSwap — the dominant DEX on zkSync Era, handling the majority of on-chain swap volume.
- Maverick Protocol — a directional liquidity AMM that chose zkSync as a primary deployment chain.
- ZeroLend — the largest lending protocol on zkSync, a fork of Aave V3.
- Holdstation — a DeFi wallet with native account abstraction integration.
Infrastructure
- zkSync Bridge — the canonical L1-to-L2 bridge. Withdrawals finalize in about an hour once the ZK proof is verified.
- LayerZero and Hyperlane — cross-chain messaging protocols with zkSync support.
- The Graph — subgraph indexing is available for zkSync Era, which is essential for any serious dApp.
Developer Tooling
- zkSync CLI — project scaffolding, local node management, and deployment.
- Block Explorer — zkSync has its own explorer at
explorer.zksync.iowith verified contract support. - Hardhat plugins — the
@matterlabs/hardhat-zksync-*suite covers compilation, deployment, and verification.
The ecosystem is real but not yet deep. If your protocol needs to compose with a dozen existing DeFi protocols on day one, Arbitrum is still the safer bet. If you are building something that leverages account abstraction or needs fast finality as a core feature, zkSync gives you capabilities no optimistic rollup can match.
When to Choose zkSync
After deploying on all the major L2s through 2025, here is my framework for when zkSync is the right choice.
Choose zkSync When
You need fast finality. If your application involves bridging, cross-chain messaging, or any flow where users need to move assets back to L1 quickly, zkSync's one-hour finality versus seven-day withdrawal windows is a genuine competitive advantage. I built a cross-chain settlement system where this difference was the entire business case.
Account abstraction is core to your product. If you are building a wallet, a gasless onboarding experience, or any UX that requires session keys, social recovery, or gas abstraction, zkSync's native account abstraction is the cleanest implementation available. You are working with the protocol, not around it.
You are building for the long term. ZK proofs are where Ethereum scaling is headed. Vitalik has said it repeatedly. Even Optimism is working on ZK proving for their stack. Building on zkSync now means you are ahead of the curve when ZK becomes the default.
Privacy features matter. ZK technology opens the door to private transactions and selective disclosure in future upgrades. If your roadmap includes privacy, starting on a ZK chain gives you a head start.
Choose a Different L2 When
You need maximum DeFi composability today. Arbitrum has deeper liquidity and more protocols to compose with.
You need the cheapest possible transactions. Base currently offers the lowest gas costs for simple operations, especially with Coinbase's infrastructure optimizations.
You want zero friction deploying existing Solidity code. If you have a battle-tested codebase and zero appetite for dealing with compiler differences, Arbitrum's full EVM equivalence is the path of least resistance.
You are building an appchain. Optimism's OP Stack is the leading framework for launching your own rollup. The Superchain vision is compelling.
Key Takeaways
- zkSync uses validity proofs, not fraud proofs. This gives you one-hour finality instead of seven days, but it means a different compiler and execution environment.
- The zkEVM is 95%+ compatible but not 100%. Watch out for
extcodesizechecks,block.numbersemantics, and CREATE2 address derivation. - Native account abstraction is the killer feature. Every account is a smart contract account. Paymasters enable gasless UX. This is not an afterthought — it is the protocol.
- Gas costs favor computation over storage. Math-heavy contracts perform well. Storage-heavy contracts cost more relative to optimistic rollups.
- Test on the right environment. Use
era_test_nodefor integration tests. Standard EVM nodes will miss zkSync-specific behavior. - The ecosystem is growing but not yet deep. If you need to compose with many existing protocols, Arbitrum is still the default. If you are building something new that leverages ZK-native features, zkSync is the most technically capable chain available.
- ZK is the future of Ethereum scaling. Building on zkSync today positions you ahead of where the entire ecosystem is moving.
zkSync is not the easiest L2 to build on. It is the most technically interesting one. If you are a developer who wants to work at the frontier of what is possible on Ethereum, this is where the action is.
Need help navigating the L2 landscape or building on zkSync? I have deployed production contracts across all the major Layer 2s. Let's talk about your project.
*Written by Uvin Vindula↗ — Web3 and AI engineer building from Sri Lanka and the UK. I write Solidity, break things on testnets, and ship production protocols on Ethereum Layer 2s. Follow my work at @IAMUVIN↗ or explore my projects at uvin.lk↗.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.