Tutorial Information
Required Tools
- Node.js (v16+)
- Hardhat
- zkSync Hardhat plugins
- Browser wallet (e.g., MetaMask)
Materials
- Laptop or desktop with internet access
- Testnet wallet with a small amount of ETH on Goerli or Sepolia
Steps
- Get a feel for what ZK-rollups are and when to use them: Read through the core concepts so you know what you’re building on and why.
- Set up a zkSync-ready development environment: Install Node.js, Hardhat, zkSync plugins, and create a clean project folder.
- Write and understand a simple token contract for zkSync: Create a basic ERC‑20 style token and see what changes on a ZK-rollup versus L1.
- Deploy your token to zkSync testnet: Use Hardhat and a funded testnet wallet to deploy and verify your contract.
- Add batched operations to see rollup benefits: Deploy a batch processor contract to group multiple token operations into one transaction.
- Wire in L1/L2 messaging concepts: Walk through a simple pattern for passing messages between Ethereum L1 and zkSync L2.
Step 1 – Get a feel for what ZK-rollups are and when to use them
Before we touch any code, it’s worth anchoring what you’re actually building on. When I help teams decide between L2 options, the questions are usually “how fast does it finalize?” and “how painful are withdrawals?” not “which proving system is used.”
Zero-knowledge rollups (ZK-rollups) are a type of Layer 2 that:
- Bundle many L2 transactions into a single proof on Ethereum L1.
- Use a succinct proof to show that all transactions were valid.
- Keep Ethereum’s security assumptions while making each individual transaction cheaper.
At a high level, ZK-rollups are especially useful when:
- You care about fast finality (no week-long withdrawal windows).
- You expect a lot of complex transactions (DEXes, games, NFT mints).
- You want cheaper transactions without leaving Ethereum’s security umbrella.
You don’t need to understand every math detail to build on zkSync, but you should be comfortable that you’re still “on Ethereum,” just via a higher-capacity lane.
Step 2 – Set up a zkSync-ready development environment
Let’s start by setting up everything we need to build on zkSync, one of the leading ZK-Rollup protocols.
Install Required Tools
First, ensure you have Node.js (v16+) and npm installed. Then create a new project directory:
mkdir zksync-democd zksync-demonpm init -yInstall the necessary dependencies:
npm install --save-dev typescript ts-node [email protected] zksync-web3 hardhat @matterlabs/hardhat-zksync-solc @matterlabs/hardhat-zksync-deployIf hardhat isn’t found later, double-check that node_modules/.bin is on your path or call it via npx hardhat.
Configure Hardhat for zkSync
Create a hardhat.config.ts file. This file tells Hardhat how to compile contracts and which networks (including zkSync) it can talk to:
import { HardhatUserConfig } from "hardhat/config";import "@matterlabs/hardhat-zksync-deploy";import "@matterlabs/hardhat-zksync-solc";
const config: HardhatUserConfig = { zksolc: { version: "1.3.5", compilerSource: "binary", settings: {}, }, defaultNetwork: "zkSyncTestnet", networks: { hardhat: { zksync: true, }, zkSyncTestnet: { url: "https://zksync2-testnet.zksync.dev", ethNetwork: "goerli", // or "sepolia" zksync: true, }, }, solidity: { version: "0.8.17", },};
export default config;Quick read-through:
zksolcblock wires in zkSync’s Solidity compiler.defaultNetworkis set tozkSyncTestnetsodeploy-zksyncknows where to go.- The
zkSyncTestnetnetwork usesethNetwork: "goerli"(orsepolia); make sure that matches whichever testnet you actually use and have funds on.
Setup Project Structure
Create the following directories:
mkdir -p contracts deployAt this point you should be able to run npx hardhat and see the zkSync-related tasks listed. If that works, you’re ready to write some contracts.
Step 3 – Write and understand a simple token contract for zkSync
Now, let’s create a simple token contract that will live on zkSync. The Solidity itself looks almost identical to an L1 token; the big differences are in where and how you deploy it.
Create the Token Contract
Create a file at contracts/ZkToken.sol:
// SPDX-License-Identifier: MITpragma solidity ^0.8.17;
contract ZkToken { string public name = "ZkToken"; string public symbol = "ZKT"; uint8 public decimals = 18; uint256 public totalSupply = 1000000 * 10**18; // 1 million tokens
mapping(address => uint256) private _balances; mapping(address => mapping(address => uint256)) private _allowances;
event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value);
constructor() { _balances[msg.sender] = totalSupply; }
function balanceOf(address account) public view returns (uint256) { return _balances[account]; }
function transfer(address recipient, uint256 amount) public returns (bool) { require(_balances[msg.sender] >= amount, "Insufficient balance");
_balances[msg.sender] -= amount; _balances[recipient] += amount;
emit Transfer(msg.sender, recipient, amount); return true; }
function approve(address spender, uint256 amount) public returns (bool) { _allowances[msg.sender][spender] = amount; emit Approval(msg.sender, spender, amount); return true; }
function allowance(address owner, address spender) public view returns (uint256) { return _allowances[owner][spender]; }
function transferFrom(address sender, address recipient, uint256 amount) public returns (bool) { require(_balances[sender] >= amount, "Insufficient balance"); require(_allowances[sender][msg.sender] >= amount, "Insufficient allowance");
_balances[sender] -= amount; _balances[recipient] += amount; _allowances[sender][msg.sender] -= amount;
emit Transfer(sender, recipient, amount); return true; }}Understanding zkSync Optimizations
While this contract looks like a standard ERC-20, when deployed on zkSync it benefits from:
- Lower Gas Costs: Transactions cost a fraction of L1 gas fees
- Faster Confirmations: Near-instant finality once proofs are generated
- Scalability: The same contract can support many more users and transactions simply by living on the rollup.
Key takeaway: you don’t need a “special” Solidity dialect to start; the main changes are in tooling and deployment targets, not the core language.
Step 4 – Deploy your token to zkSync testnet
Let’s deploy our contract to the zkSync testnet.
Create a Wallet and Get Testnet Funds
First, create a deployment script at deploy/deploy.ts. This script is what you actually run to push ZkToken to zkSync:
import { Wallet } from "zksync-web3";import { HardhatRuntimeEnvironment } from "hardhat/types";import { Deployer } from "@matterlabs/hardhat-zksync-deploy";import * as dotenv from "dotenv";dotenv.config();
export default async function (hre: HardhatRuntimeEnvironment) { console.log("Running deployment script for ZkToken");
// Initialize the wallet. const privateKey = process.env.PRIVATE_KEY || ""; if (!privateKey) throw new Error("Private key not provided");
const wallet = new Wallet(privateKey); const deployer = new Deployer(hre, wallet);
// Load artifact const artifact = await deployer.loadArtifact("ZkToken");
// Deploy console.log("Deploying ZkToken..."); const zkToken = await deployer.deploy(artifact, []);
// Show contract info console.log(`ZkToken deployed to ${zkToken.address}`);
// Verify contract console.log("Verification not yet available on testnet");}What’s happening:
- We read
PRIVATE_KEYfrom.envso the key never sits in the script. Wallet(fromzksync-web3) is a signer that knows how to talk to zkSync.Deployerwraps Hardhat + zkSync deployment logic so you don’t have to handcraft transactions.
Create a .env file for your private key (never commit this to git):
PRIVATE_KEY=your_private_key_hereInstall dotenv and set up environment loading:
npm install dotenvThen, update your deployment script to load the environment:
import * as dotenv from "dotenv";dotenv.config();
// Rest of the script...Before deploying, get some testnet ETH from the zkSync faucet or bridge some from Goerli/Sepolia.
Deploy the Contract
Run the deployment script:
npx hardhat deploy-zksyncYou should see output with your contract’s address on zkSync testnet.
Once you have that address, you can:
- Add it to your wallet’s custom token list.
- Try a small transfer on zkSync’s testnet block explorer.
This is usually the “aha” moment where teams realize they’re still just dealing with normal Ethereum-style contracts—just with different URLs and gas prices.
Step 5 – Add batched operations to see rollup benefits
One of the key advantages of ZK-Rollups is the ability to batch multiple transactions. Let’s create a simple contract that demonstrates this capability.
Create a BatchProcessor Contract
Create a new file at contracts/BatchProcessor.sol:
// SPDX-License-Identifier: MITpragma solidity ^0.8.17;
interface IERC20 { function transfer(address recipient, uint256 amount) external returns (bool); function balanceOf(address account) external view returns (uint256);}
contract BatchProcessor { address public owner;
constructor() { owner = msg.sender; }
// Batch transfer tokens to multiple recipients function batchTransfer( address token, address[] calldata recipients, uint256[] calldata amounts ) external { require(msg.sender == owner, "Only owner"); require(recipients.length == amounts.length, "Arrays length mismatch");
IERC20 tokenContract = IERC20(token);
for (uint i = 0; i < recipients.length; i++) { tokenContract.transfer(recipients[i], amounts[i]); } }
// Process multiple token operations in a single transaction function multiTokenTransfer( address[] calldata tokens, address[] calldata recipients, uint256[] calldata amounts ) external { require(msg.sender == owner, "Only owner"); require(tokens.length == recipients.length, "Arrays length mismatch"); require(recipients.length == amounts.length, "Arrays length mismatch");
for (uint i = 0; i < tokens.length; i++) { IERC20 tokenContract = IERC20(tokens[i]); tokenContract.transfer(recipients[i], amounts[i]); } }}Deploy the BatchProcessor
Create a deployment script for the batch processor at deploy/deploy-batch.ts:
import { Wallet } from "zksync-web3";import * as ethers from "ethers";import { HardhatRuntimeEnvironment } from "hardhat/types";import { Deployer } from "@matterlabs/hardhat-zksync-deploy";import * as dotenv from "dotenv";dotenv.config();
export default async function (hre: HardhatRuntimeEnvironment) { console.log("Running deployment script for BatchProcessor");
// Initialize the wallet. const privateKey = process.env.PRIVATE_KEY || ""; if (!privateKey) throw new Error("Private key not provided");
const wallet = new Wallet(privateKey); const deployer = new Deployer(hre, wallet);
// Load artifact const artifact = await deployer.loadArtifact("BatchProcessor");
// Deploy console.log("Deploying BatchProcessor..."); const batchProcessor = await deployer.deploy(artifact, []);
// Show contract info console.log(`BatchProcessor deployed to ${batchProcessor.address}`);}Deploy with:
npx hardhat deploy-zksync --script deploy-batch.tsUnderstanding the Benefits
This batched approach provides significant benefits on zkSync:
- Cost Savings: Instead of paying for 10 separate L1 transactions, you pay for a single transaction with the cost of generating one ZK proof
- Time Efficiency: All operations are processed together, reducing wait times
- Atomic Execution: Either all transactions succeed or all fail, ensuring consistency
You don’t have to build batch logic into every app, but seeing it once helps you understand why rollups are a natural fit for high-throughput workflows.
Step 6 – Wire in L1/L2 messaging concepts
A powerful feature of zkSync is the ability to interact with Ethereum L1 contracts. Let’s implement a bridge pattern.
Create a Cross-Chain Messenger
Create a new contract at contracts/CrossChainMessenger.sol:
// SPDX-License-Identifier: MITpragma solidity ^0.8.17;
interface IL1Bridge { function receiveMessage(string calldata message) external;}
contract CrossChainMessenger { address public owner; address public l1Target;
event MessageSent(string message, uint256 timestamp);
constructor(address _l1Target) { owner = msg.sender; l1Target = _l1Target; }
function sendMessageToL1(string calldata message) external returns (bytes32) { require(msg.sender == owner, "Only owner can send messages");
// This will initiate an L2->L1 message bytes32 messageHash = keccak256(abi.encodePacked(message, block.timestamp));
// In a real implementation, we would use zkSync's native cross-layer messaging // This is a simplified example
emit MessageSent(message, block.timestamp); return messageHash; }
// This function would be called by the zkSync bridge system function receiveMessageFromL1(string calldata message) external { // In a real implementation, this would verify that the message came from L1 // through the zkSync bridge system
// Process the message emit MessageSent(message, block.timestamp); }}Note: This is a simplified example. In a real zkSync application, you would use the zksync-web3 library’s specific cross-layer messaging functions.
Actual L1-L2 Communication Example
For actual L1-L2 communication on zkSync, you would use code like this:
// L1 to L2 messaging exampleasync function sendMessageFromL1ToL2() { // L1 provider and wallet const l1Provider = new ethers.providers.JsonRpcProvider(L1_RPC_URL); const wallet = new ethers.Wallet(PRIVATE_KEY, l1Provider);
// Get zkSync provider const zksyncProvider = new Provider("https://zksync2-testnet.zksync.dev");
// Bridge contracts are already deployed const l1Bridge = new ethers.Contract(L1_BRIDGE_ADDRESS, L1_BRIDGE_ABI, wallet);
// Send a message from L1 to L2 const tx = await l1Bridge.sendMessage(L2_RECEIVER_ADDRESS, "Hello from L1!"); await tx.wait();
// Get the L2 transaction and wait for it to be processed const l2Hash = await zksyncProvider.getL2TransactionFromPriorityOp(tx);
// Wait for the L2 transaction const l2Receipt = await zksyncProvider.waitForTransaction(l2Hash); console.log("Message delivered to L2:", l2Receipt.transactionHash);}This snippet omits imports and constants to keep the focus on the flow. In a real file you would need:
import { Provider } from "zksync-web3";import * as ethers from "ethers";
const L1_RPC_URL = "https://your-l1-rpc.example";const L1_BRIDGE_ADDRESS = "0x..."; // from zkSync docsconst L1_BRIDGE_ABI = [...];const L2_RECEIVER_ADDRESS = "0x...";const PRIVATE_KEY = process.env.PRIVATE_KEY!;Common pitfalls I see here:
- Wrong RPC URLs or network mismatch (Goerli vs Sepolia) causing “network not supported” errors.
- Not waiting for the L1 transaction to finalize before trying to look up the L2 side.
- Using the wrong bridge contract address; always pull bridge addresses from the current zkSync docs.
Conclusion
In this tutorial, we’ve explored Zero-Knowledge Rollups and implemented several practical examples using zkSync. You’ve learned how to:
- Understand ZK-Rollup technology and its advantages over other scaling solutions
- Set up a development environment for zkSync
- Build and deploy a token contract to zkSync’s testnet
- Implement batch transactions for gas optimization
- Create cross-layer communication between Ethereum L1 and zkSync L2
ZK-Rollups represent the cutting edge of blockchain scaling technology, offering a compelling combination of security, scalability, and user experience. By leveraging zero-knowledge proofs, they maintain the robust security guarantees of Ethereum while drastically improving transaction throughput and reducing costs.
As the ecosystem continues to evolve, we’ll see more sophisticated tooling, greater interoperability, and increased adoption of ZK-Rollup technology across the blockchain landscape.
For further exploration, consider:
- Implementing a more complex application like a DEX or NFT marketplace on zkSync
- Exploring account abstraction features unique to zkSync
- Experimenting with other ZK-Rollup platforms like StarkNet or Polygon zkEVM
Happy building in the Zero-Knowledge world!
Resources and further reading
| Source | Title |
|---|---|
| | zkSync Era Docs – Getting Started Official zkSync documentation with up-to-date guides on deployment, tooling, and L1/L2 messaging. |
| | Ethereum.org – Layer 2 Rollups Overview of rollups on Ethereum, including the differences between optimistic and ZK-rollups. |
| | Rollup (blockchain) – Wikipedia Neutral background on how rollups work and why they matter for scaling. |
| | zkSync Era Hardhat Plugins Reference for the Hardhat plugins used in this tutorial, with configuration and usage examples. |
| | zkSync Testnet Faucet Source for testnet ETH to deploy and test your contracts on zkSync networks. |