iTranslated by AI
Running Existing Ethereum Contracts on zkSync

What is zkSync
zkSync is an L2 network that uses ZK Rollups. It is one of the promising solutions to current Ethereum scaling and gas fee issues.
The benefit of using this is that it can process many transactions while maintaining conventional Ethereum security, and keep gas fees reasonably low. Roughly speaking, the mechanism works by holding and executing computational processing and storage data on zkSync, bundling those transactions to send to Ethereum, and maintaining only that transaction history. In this way, by offloading Ethereum's responsibilities to other networks as much as possible and reducing the number of transactions, it becomes possible to use Ethereum's security while suppressing load and reducing unnecessary costs. Solutions using ZK Rollups like this are provided not only by zkSync but also by other company networks such as Starkware (its StarkNet) and Scroll. Although there are differences in the methods, such as using STARK or SNARK, the basic mechanism of each should be the same.
What is ZK
First of all, what does the ZK in ZK Rollups stand for? It stands for Zero-Knowledge Proof (often abbreviated as ZKP). Explaining the theoretical parts of this would be so complicated it would require a whole separate article, and since I don't fully understand the rigorous mathematical proofs myself, I will omit them. If you only want a conceptual understanding, it's not that difficult, so you might want to read the links below.
If you're wondering "What are STARK and SNARK?", check out this article.
Starkware also has a hands-on video explaining the mathematical understanding of STARK, so if you're interested, this might be good too.
Why zkSync
Any ZK Rollup-based network would have been fine, but I decided to try zkSync because I can reuse existing contracts written in Solidity. StarkNet is also a promising network, but contracts must be written in a dedicated language called cairo. With zkSync, you can leverage your existing assets without having to learn a new language or ecosystem.
Main Topic
I previously wrote an article about creating a full on-chain Twitter clone on Ropsten. The content involved treating Ethereum as a simple database where you can add an API, creating a contract to hold all tweets and user information, and implementing a Twitter-like frontend by calling that API.
This time, I've migrated the contract used there to zkSync as it is, so I'll explain the procedure for how to migrate an existing contract using that as an example.
Environment Setup
First of all, let's start with the environment setup. The local environment is set up with Docker and docker-compose. L1 nodes, L2 nodes, and various other components are created all at once.
First, clone the repository for the local setup.
git clone https://github.com/matter-labs/local-setup.git
And then, create the environment.
cd local-setup
./start.sh
That's it.
Installing Dependencies
Install all necessary dependencies at once.
yarn add -D typescript ts-node ethers zksync-web3 hardhat @matterlabs/hardhat-zksync-solc@0.3 @matterlabs/hardhat-zksync-deploy@0.2 @nomiclabs/hardhat-waffle chai-as-promised @nomiclabs/hardhat-ethers @types/chai-as-promised
The following two packages are required to compile and deploy to the zkSync contract. Since Hardhat is the recommended development environment, Hardhat is also essential.
-
@matterlabs/hardhat-zksync-solc
- For compilation
-
@matterlabs/hardhat-zksync-deploy
- For deployment
Also, zksync-web3 is the zkSync version of web3 for interacting with Geth and contracts.
Other hardhat-XXXX packages are Hardhat extensions required for testing.
Adjust typescript/ts-node/@types according to your preferred environment.
Hardhat Configuration
Rewrite hardhat.config.ts to use zkSync. A noteworthy point is the zkSyncDeploy part. When NODE_ENV is test, it is configured to deploy to the locally started node. This allows you to test contracts in the local environment.
import { config as dotEnvConfig } from "dotenv";
dotEnvConfig();
import "@nomiclabs/hardhat-waffle";
import "@matterlabs/hardhat-zksync-deploy";
import "@matterlabs/hardhat-zksync-solc";
const zkSyncDeploy =
process.env.NODE_ENV == "test"
? {
zkSyncNetwork: "http://localhost:3050",
ethNetwork: "http://localhost:8545",
}
: {
zkSyncNetwork: "https://zksync2-testnet.zksync.dev",
ethNetwork: "goerli",
};
module.exports = {
zksolc: {
version: "0.1.0",
compilerSource: "docker",
settings: {
optimizer: {
enabled: true,
},
experimental: {
dockerImage: "matterlabs/zksolc",
},
},
},
zkSyncDeploy,
solidity: {
version: "0.8.10",
},
networks: {
hardhat: {
zksync: true,
},
},
};
Testing
Now that the preparation is complete, I'd like to start by compiling the existing contract for zkSync locally and confirming that the tests pass (there's no such thing as a contract without tests, right...?).
The contract being tested this time is contracts/TwitterV1.sol. This is the main contract that handles the API and data storage for the Twitter clone.
The original test was contracts/test/twitter-test.js. I'll rewrite and run it as shown below.
Rewritten test (long)
import * as hre from "hardhat";
import chai from "chai";
import chaiAsPromised from "chai-as-promised";
import { Wallet, Provider } from "zksync-web3";
import { Deployer } from "@matterlabs/hardhat-zksync-deploy";
chai.use(chaiAsPromised);
const { expect } = chai;
const RICH_WALLET_PK =
"0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110";
async function setup() {
const provider = Provider.getDefaultProvider();
const wallet = new Wallet(RICH_WALLET_PK, provider);
const deployer = new Deployer(hre, wallet);
const artifact = await deployer.loadArtifact("TwitterV1");
const deployed = await deployer.deploy(artifact, []);
return { twitter: deployed, owner: wallet };
}
describe("Twitter", function () {
describe("setTweet", function () {
it("Should return error", async function () {
const { twitter } = await setup();
expect(twitter.setTweet(" ")).to.eventually.be.rejected;
});
});
describe("getTimeline", function () {
it("Should return the tweet", async function () {
const { twitter, owner } = await setup();
const tx = await twitter.setTweet("Hello, world!", "");
await tx.wait();
const tweets = await twitter.getTimeline(0, 10);
const tweet = tweets[0];
expect(tweet.content).to.equal("Hello, world!");
expect(tweet.author).to.equal(owner.address);
});
});
describe("getUserTweets", function () {
it("Should return the tweets order by timestamp desc", async function () {
const { twitter, owner } = await setup();
let tx = await twitter.setTweet("Hello, world!", "");
await tx.wait();
tx = await twitter.setTweet("Hello, new world!", "");
await tx.wait();
const tweets = await twitter.getUserTweets(owner.address);
const tweet = tweets[0];
expect(tweet.content).to.equal("Hello, new world!");
expect(tweet.author).to.equal(owner.address);
});
it("Should return the tweet", async function () {
const { twitter, owner } = await setup();
let tx = await twitter.setTweet(
"Hello, world!",
"data:image/png;base64,XXXX"
);
await tx.wait();
const tweets = await twitter.getUserTweets(owner.address);
const tweet = tweets[0];
expect(tweet.content).to.equal("Hello, world!");
expect(tweet.author).to.equal(owner.address);
expect(tweet.attachment).to.equal("data:image/png;base64,XXXX");
});
});
describe("getTweet", function () {
it("Should return the tweet", async function () {
const { twitter, owner } = await setup();
let tx = await twitter.setTweet("Hello, world!", "");
await tx.wait();
const tweet = await twitter.getTweet(1);
expect(tweet.content).to.equal("Hello, world!");
expect(tweet.author).to.equal(owner.address);
});
});
describe("follow", function () {
it("Should follow user", async function () {
const { twitter, owner } = await setup();
const [_, user] = await hre.ethers.getSigners();
let tx = await twitter.follow(user.address);
await tx.wait();
const followings = await twitter.getFollowings(owner.address);
const following = followings[0];
expect(following.id).to.equal(user.address);
const followers = await twitter.getFollowers(user.address);
const follower = followers[0];
expect(follower.id).to.equal(owner.address);
});
});
describe("getFollowings", function () {
it("Should unfollow user", async function () {
const { twitter, owner } = await setup();
const [_, user, user2] = await hre.ethers.getSigners();
let tx = await twitter.follow(user.address);
await tx.wait();
tx = await twitter.follow(user2.address);
await tx.wait();
let followings = await twitter.getFollowings(owner.address);
expect(followings.length).to.equal(2);
let followers = await twitter.getFollowers(user.address);
expect(followers.length).to.equal(1);
followers = await twitter.getFollowers(user2.address);
expect(followers.length).to.equal(1);
tx = await twitter.unfollow(user.address);
await tx.wait();
followings = await twitter.getFollowings(owner.address);
expect(followings.length).to.equal(1);
followers = await twitter.getFollowers(user.address);
expect(followers.length).to.equal(0);
followers = await twitter.getFollowers(user2.address);
expect(followers.length).to.equal(1);
});
});
describe("isFollowing", function () {
it("Should true if follow the address", async function () {
const { twitter, owner } = await setup();
const [_, user] = await hre.ethers.getSigners();
let tx = await twitter.follow(user.address);
await tx.wait();
const following = await twitter.isFollowing(user.address);
expect(following).to.equal(true);
});
});
describe("addLike", function () {
it("Should add a like to the tweet", async function () {
const { twitter, owner } = await setup();
let tx = await twitter.setTweet("Hello, world!", "");
await tx.wait();
let tweets = await twitter.getUserTweets(owner.address);
let tweet = tweets[0];
expect(tweet.likes.includes(owner.address)).to.be.false;
tx = await twitter.addLike(tweet.tokenId);
await tx.wait();
tweets = await twitter.getUserTweets(owner.address);
tweet = tweets[0];
expect(tweet.likes.includes(owner.address)).to.be.true;
});
});
describe("getLikes", function () {
it("Should return liked tweets", async function () {
const { twitter, owner } = await setup();
let tx = await twitter.setTweet("Hello, world!", "");
await tx.wait();
let tweets = await twitter.getLikes(owner.address);
expect(tweets.length).to.equal(0);
tweets = await twitter.getUserTweets(owner.address);
let tweet = tweets[0];
tx = await twitter.addLike(tweet.tokenId);
await tx.wait();
tweets = await twitter.getLikes(owner.address);
tweet = tweets[0];
expect(tweet.likes.includes(owner.address)).to.be.true;
});
});
describe("changeIconUrl/getUserIcon", function () {
it("Should change icon url", async function () {
const { twitter, owner } = await setup();
let url = await twitter.getUserIcon(owner.address);
expect(url).to.equal("");
let tx = await twitter.changeIconUrl("https://example.com/icon.png");
await tx.wait();
url = await twitter.getUserIcon(owner.address);
expect(url).to.equal("https://example.com/icon.png");
});
});
describe("setComment/getComments", function () {
it("Should add the comment", async function () {
const { twitter, owner } = await setup();
let tx = await twitter.setTweet("Hello, world!", "");
await tx.wait();
tx = await twitter.setComment("Hello, comment!", 1);
await tx.wait();
const comments = await twitter.getComments(1);
const comment = comments[0];
expect(comment.content).to.equal("Hello, comment!");
expect(comment.author).to.equal(owner.address);
});
});
describe("addRetweet", function () {
it("Should add the rt", async function () {
const { twitter, owner } = await setup();
let tx = await twitter.setTweet("Hello, world!", "");
await tx.wait();
tx = await twitter.addRetweet(1);
await tx.wait();
let tweets = await twitter.getTimeline(0, 2);
expect(tweets[1].retweets.includes(owner.address)).to.be.true;
expect(tweets[1].retweetedBy).to.eq(
"0x0000000000000000000000000000000000000000"
);
expect(tweets[0].retweets.includes(owner.address)).to.be.true;
expect(tweets[0].retweetedBy).to.eq(owner.address);
});
});
describe("tokenURI", function () {
it("Should return base64 encoded string", async function () {
const { twitter, owner } = await setup();
let tx = await twitter.setTweet("Hello, world!", "");
await tx.wait();
const tokenURI = await twitter.tokenURI(1);
expect(tokenURI).to.eq(
"data:application/json;base64,eyJuYW1lIjoiVHdlZXQgIzEiLCAiZGVzY3JpcHRpb24iOiJIZWxsbywgd29ybGQhIiwgImltYWdlIjogImRhdGE6aW1hZ2Uvc3ZnK3htbDtiYXNlNjQsUEhOMlp5QjRiV3h1Y3owaWFIUjBjRG92TDNkM2R5NTNNeTV2Y21jdk1qQXdNQzl6ZG1jaUlIQnlaWE5sY25abFFYTndaV04wVW1GMGFXODlJbmhOYVc1WlRXbHVJRzFsWlhRaUlIWnBaWGRDYjNnOUlqQWdNQ0F6TlRBZ016VXdJajQ4Y21WamRDQjNhV1IwYUQwaU1UQXdKU0lnYUdWcFoyaDBQU0l4TURBbElpQm1hV3hzUFNJallXRmlPR015SWo0OEwzSmxZM1ErUEhOM2FYUmphRDQ4Wm05eVpXbG5iazlpYW1WamRDQjRQU0l3SWlCNVBTSXdJaUIzYVdSMGFEMGlNVEF3SlNJZ2FHVnBaMmgwUFNJeE1EQWxJajQ4Y0NCBGJXeHVjejBpYUhSMGNEb3ZMM2QzZHk1M015NXZjbWN2TVRrNU9TOTRhSFJ0YkNJZ1ptOXVkQzF6YVhwbFBTSXhNbkI0SWlCemRIbHNaVDBpWm05dWRDMXphWHBsT2pFd2NIZzdjR0ZrWkdsdVp6bzFjSGc3SWo1VWQyVmxkQ014UEdKeUx6NUlaV3hzYnl3Z2QyOXliR1FoUEdKeUx6NDhhVzFuSUhOeVl6MGlJaTgrUEM5d1Bqd3ZabTl5WldsbmJrOWlhbVZqZEQ0OEwzTjNhWFJqYUQ0OEwzTjJaejQ9In0="
);
});
});
});
The main change is using @matterlabs/hardhat-zksync-deploy and zksync-web3 to compile and deploy the contract for zkSync.
import * as hre from "hardhat";
import chai from "chai";
import chaiAsPromised from "chai-as-promised";
import { Wallet, Provider } from "zksync-web3";
import { Deployer } from "@matterlabs/hardhat-zksync-deploy";
chai.use(chaiAsPromised);
const { expect } = chai;
const RICH_WALLET_PK =
"0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110";
async function setup() {
const provider = Provider.getDefaultProvider();
const wallet = new Wallet(RICH_WALLET_PK, provider);
const deployer = new Deployer(hre, wallet);
const artifact = await deployer.loadArtifact("TwitterV1");
const deployed = await deployer.deploy(artifact, []);
return { twitter: deployed, owner: wallet };
}
With this, the tests will be executed on the zkSync contract. They should pass without any issues.
Compilation
Before deploying to the testnet, first compile the contract. If the tests pass, just run the following and you're done.
yarn hardhat compile
Deployment
The following deployment script is used. Similar to the tests, use @matterlabs/hardhat-zksync-deploy and zksync-web3 to load and deploy the contract. Since the zkSync testnet is https://zksync2-testnet.zksync.dev, specify this as the Provider network.
PRIVATE_KEY specifies your own MetaMask private key.
The part deployer.zkWallet.deposit differs from standard Ethereum; here, tokens are naturally required to execute transactions on zkSync, so it simply bridges some funds from L1 for that purpose.
Also, since Goerli is used as L1 for the testnet, be sure to obtain ETH or USDC from a Goerli faucet in advance (important).
const { Wallet, Provider, utils } = require("zksync-web3");
const { Deployer } = require("@matterlabs/hardhat-zksync-deploy");
const functionName = "TwitterV1";
module.exports = async function (hre) {
console.log("Start deploy!");
const provider = new Provider("https://zksync2-testnet.zksync.dev");
const wallet = new Wallet(`0x${process.env.PRIVATE_KEY}`).connect(provider);
const deployer = new Deployer(hre, wallet);
const artifact = await deployer.loadArtifact(functionName);
const depositAmount = ethers.utils.parseEther("0.001");
const depositHandle = await deployer.zkWallet.deposit({
to: deployer.zkWallet.address,
token: utils.ETH_ADDRESS,
amount: depositAmount,
});
await depositHandle.wait();
const deployed = await deployer.deploy(artifact, []);
const contractAddress = deployed.address;
console.log(`${functionName} deployed to:`, contractAddress);
};
Once you've done this, run the following:
yarn hardhat deploy-zksync
The deployment will complete in about 5 minutes.
There is a testnet version of the explorer called zkScan, where you can check the status of your deployment to zkSync by searching for your wallet address.
Integration with Frontend
Now that the contract deployment is finished, we will enable the existing frontend code to connect to the contract deployed on zkSync.
There are two main points. If you keep these in mind, you should be fine.
Configure MetaMask for the zkSync Testnet
Add the network to your wallet by referring to Connecting to Metamask & bridging tokens to zkSync. From here on, the frontend will basically connect to this zkSync network to perform transactions and other operations.
While testing, I occasionally accidentally connected to the Goerli network and executed transactions. For some reason, they would succeed, causing confusion where I thought it worked on zkSync but it had actually executed directly on Goerli.
Using zksync-web3
Change the client part that connects to the contract in the existing frontend to use zksync-web3. For example:
import { utils } from "ethers";
import { Contract, Web3Provider, Provider, Wallet } from "zksync-web3";
import ABI from "resources/contract-abi.json";
export const contractClient = async (library: any, isSigner: boolean) => {
const inteface = new utils.Interface(ABI.abi);
const signer = new Web3Provider(library.provider).getSigner();
return new Contract(
`${process.env.NEXT_PUBLIC_TWITTER_ETH_CONTRACT_ID}`,
inteface,
signer
);
};
export const contractProvider = (library: any) => {
return new Provider("https://zksync2-testnet.zksync.dev");
};
Common Pitfalls
It's easy to lose track of what you're doing unless you organize your understanding of where the contract is deployed, where it's executed, and the relationship between Ethereum and zkSync. Essentially: the contract is deployed on zkSync; transaction execution and storage data persistence occur on zkSync; therefore, you must pay transaction costs on zkSync; for that, you need to bridge some funds from Ethereum; and since Ethereum only records the transaction history, users don't need to interact with it directly.
Also, keep in mind that zkSync's zkEVM is not 100% EVM compatible. An example I ran into was using OpenZeppelin's _safeMint in the contract. The contract detection logic used internally by this method relies on EXTCODESIZE, which is not supported in zkEVM. Consequently, _safeMint cannot be used. Replacing it with _mint makes it work. According to the zkSync Discord, this seems to be a fairly common pitfall.
Conclusion
With this, we have successfully compiled and deployed a zkSync contract and integrated it with the frontend.
Demo can be checked here:
The code is available below:
Although there were some hurdles, I feel that zkEVM's ability to run existing Solidity contracts almost as-is is a very smart design. While zkSync 2.0 is currently only on testnet, I expect more protocols will adopt it once the mainnet launches.
If you want to try it out for now, it would be good to start with the "Hello World" tutorial.
To be honest, it feels like there are still many zkSync features and zkEVM issues I don't fully understand yet, but I want to keep following its progress.
Links
Discussion