📘

Ethereumの既存コントラクトをzkSyncで動かす

2022/05/02に公開

zkSync is 何

zkSyncはZK Rollupsを用いたL2ネットワークである。現在のEthereumのスケーリングやガス代の問題に対する有望な解決策の一つ。

これを利用すると何が嬉しいかというと従来のEthereumのセキュリティを維持したまま多くのトランザクションを処理できたりガス代がほどほどに安くしたりできること。ざっくりとした仕組みとしては計算処理やストレージデータはzkSyncで保持実行し、そのトランザクションをまとめてEthereumに送り、そのトランザクション履歴のみ保持するようにする。このように出来るだけEthereumの責務を他のネットワークに逃しトランザクションの回数を減らすことで負荷を抑え余計なコストを減らしながらEthereumのセキュリティは利用できるということが可能になる。このようなZK Rollupsを用いたソリューションはzkSyncだけでなく他にもStarkware(のStarkNet)やScrollといった企業のネットワークもある。STARKやSNARKを使っているという手法の違いはあるがそれぞれ基本的な仕組みは同じなはず。

ZK is 何

そもそもZK RollupsのZKとは何かというとゼロ知識証明のことである(Zero Knowledge ProofだからZKPと略されることが多い)。これについて理論的な部分を説明するにはまた別の記事になってしまうくらいややこしくて、自分も厳密な数学的な証明まで理解しきれていないので割愛。概念的な理解だけならそこまで難しくないので下記のリンクを読んでみると良い。
https://blog.goodaudience.com/understanding-zero-knowledge-proofs-through-simple-examples-df673f796d99

STARKやSNARKって何?ということならこの記事とか。
https://consensys.net/blog/blockchain-explained/zero-knowledge-proofs-starks-vs-snarks/

Starkware社がSTARKについての数学的な理解を解説してくれてるハンズオンの動画もあるので気になった人はこれも良いかも。
https://starkware.co/stark-101/

なぜzkSync

ZK Rollups系のネットワークならどれでも良かったがzkSyncを使ってみようと思ったのは既存のSolidityで書かれたコントラクトを再利用できるから。StarkNetも有望なネットワークだがコントラクトをcairoという専用の言語で書かないといけない。zkSyncなら新しい言語やエコシステムを覚えたりしなくともすでに持っている既存の資産を活かせる。
https://docs.zksync.io/dev/contracts/

本題

以前Ropsten上にフルオンチェーンのTwitterもどきを作るという記事を書いた。内容としてはEthereumをAPIを生やせるただのDBと見立ててツイートやユーザー情報を全て保持するコントラクトを作成しそのAPIを叩いてTwitter風のフロントエンドを実装したという話。
https://zenn.dev/razokulover/articles/067fd5cf55292e

今回はそこで利用したコントラクトをそのままzkSyncに移行してみたのでその手順を例にどうやったら既存コントラクトを移行できるのかについて書いてみる。

環境構築

何はともあれまずは環境構築から。ローカル環境はDockerとdocker-composeで立ち上がる。L1ノードとL2ノードとその他諸々が一気に作成される。

まずはローカル環境用のリポジトリのclone。

git clone https://github.com/matter-labs/local-setup.git

そして環境の作成。

cd local-setup
./start.sh

これだけ。

依存関係のインストール

必要な依存関係を一気にインストールする。

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

zkSync用のコントラクトへコンパイルしてデプロイするために必要なpackageとしてはまず下記2つ。開発環境としてはhardhatが推奨されているのでHardhatも必須。

またzksync-web3はGethやコントラクトとやりとりするweb3のzkSync版。

その他のhardhat-XXXX系はテストに必要なhardhat拡張。

typescript/ts-node/@typesに関してはお好みの環境に合わせて調整。

hardhatの設定

hardhat.config.tsにzkSyncを利用するよう書き換えを行う。特筆すべきはzkSyncDeployのところ。NODE_ENVtestの時はローカルに立ち上げたノードにデプロイするようにしている。こうすることでローカル環境でコントラクトのテストができるようになる。

hardhat.config.ts
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,
    },
  },
};

テスト

準備ができたので手始めに既存コントラクトをローカルでzkSync用にコンパイルしてテストが通るか確認したい(テストがないコントラクトとか存在しないよね...?)。

今回テストするコントラクトはcontracts/TwitterV1.sol。TwitterもどきのAPIやデータの保持を行っているメインコントラクト。

元のテストはcontracts/test/twitter-test.js。このテストを下記のテストに書き直して実行。

書き直したテスト(長い)
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,eyJuYW1lIjoiVHdlZXQgIzEiLCAiZGVzY3JpcHRpb24iOiJIZWxsbywgd29ybGQhIiwgImltYWdlIjogImRhdGE6aW1hZ2Uvc3ZnK3htbDtiYXNlNjQsUEhOMlp5QjRiV3h1Y3owaWFIUjBjRG92TDNkM2R5NTNNeTV2Y21jdk1qQXdNQzl6ZG1jaUlIQnlaWE5sY25abFFYTndaV04wVW1GMGFXODlJbmhOYVc1WlRXbHVJRzFsWlhRaUlIWnBaWGRDYjNnOUlqQWdNQ0F6TlRBZ016VXdJajQ4Y21WamRDQjNhV1IwYUQwaU1UQXdKU0lnYUdWcFoyaDBQU0l4TURBbElpQm1hV3hzUFNJallXRmlPR015SWo0OEwzSmxZM1ErUEhOM2FYUmphRDQ4Wm05eVpXbG5iazlpYW1WamRDQjRQU0l3SWlCNVBTSXdJaUIzYVdSMGFEMGlNVEF3SlNJZ2FHVnBaMmgwUFNJeE1EQWxJajQ4Y0NCNGJXeHVjejBpYUhSMGNEb3ZMM2QzZHk1M015NXZjbWN2TVRrNU9TOTRhSFJ0YkNJZ1ptOXVkQzF6YVhwbFBTSXhNbkI0SWlCemRIbHNaVDBpWm05dWRDMXphWHBsT2pFd2NIZzdjR0ZrWkdsdVp6bzFjSGc3SWo1VWQyVmxkQ014UEdKeUx6NUlaV3hzYnl3Z2QyOXliR1FoUEdKeUx6NDhhVzFuSUhOeVl6MGlJaTgrUEM5d1Bqd3ZabTl5WldsbmJrOWlhbVZqZEQ0OEwzTjNhWFJqYUQ0OEwzTjJaejQ9In0="
      );
    });
  });
});

主な変更点としては@matterlabs/hardhat-zksync-deployzksync-web3を用いてコントラクトのコンパイルとデプロイを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 };
}

これでzkSync用のコントラクトでテストが実行される。特に詰まることもなく無事に通るはず。

コンパイル

テストネットへデプロイする前にまずはコントラクトをコンパイルする。テストが通っていれば下記を実行するだけで終わり。

yarn hardhat compile

デプロイ

デプロイスクリプトは下記を使用。テストの時と同じように@matterlabs/hardhat-zksync-deployzksync-web3を使ってコントラクトをロードしてデプロイを行う。zkSyncのテストネットはhttps://zksync2-testnet.zksync.devなのでProviderのnetworkとして指定。

PRIVATE_KEYは自分のmetamaskのprivate keyを指定する。

deployer.zkWallet.depositという箇所が普通のEthereumの場合と異なるが、ここはzkSyncでトランザクションを実行するためにもちろんトークンが必要で、そのためにいくらかL1から送金しているだけ。

あとテストネットではL1としてGoerliを使うので事前にGoerliのfaucetなどからETHやUSDCを得ておく(重要)。

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);
};

ここまでできればあとは下記を実行する。

yarn hardhat deploy-zksync

これで5分ほど待つとデプロイが完了となる。

zkScanというexplorerのテストネット版があるのでここに自分のウォレットアドレスを検索するとzkSyncへのデプロイの様子が確認できるはず。
https://zksync2-testnet.zkscan.io/

フロントエンドとの統合

コントラクトのデプロイが終わったのでzkSyncにデプロイされたコントラクトへフロントエンド側の既存コードから接続できるようにする。

ポイントは2つ。これさえ気をつければ大体大丈夫なはず。

zkSyncのテストネットのMetamask設定を行う

Connecting to Metamask & bridging tokens to zkSyncを参考にwalletにネットワークを追加する。以後フロントエンドでは基本的にこのzkSyncのネットワークにconnectしてトランザクションなど行う。

試しているときに間違ってGoerliのネットワークに接続してトランザクションを実行させてもなぜかうまくいってしまうことがあり、zkSync側で成功したと思っていたらGoeriに直接実行してたみたいなこと混乱が発生した。

zksync-web3を使う

既存のフロントエンドでコントラクトに接続するクライアント部分をzksync-web3を使うように変更する。例えばこんな感じ。

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");
};

https://github.com/YuheiNakasaka/twitter-eth/blob/zksync/packages/frontend/src/utils/contract_client.ts

ハマりどころ

コントラクトがどこにデプロイされてどこで実行されているのか、EthereumとzkSyncがどういう関係なのかを理解しないと自分が何をやってるかわからなくなりがちなので整理するとよい。コントラクトはzkSyncにデプロイされる、トランザクションの実行やストレージデータの保存はzkSync上で行われる、なのでzkSync上でのトランザクションコストは払わないといけない、そのためにEthereumから幾らか資金をブリッジする必要がある、Ethereumにはトラザンクションの履歴が記録されるだけなのでユーザーとしては特に関わることはない、という感じ。

あとはzkSyncのEVM互換のzkEVMは完全互換ではないという点も注意。自分がハマった例としてはopenzeppelinの_safeMintをコントラクトで利用していたが、このメソッドが内部で使っているコントラクト判定処理EXTCODESIZEというものがあり、これがzkEVMでは非対応。なので_safeMintは使えない。代わりに_mintに差し替えると動くようになる。zkSyncのdiscordでも書かれていたが割とよくあるハマりポイントのようだ。

最後に

これでzkSyncのコントラクトをコンパイル/デプロイし、フロントエンドと統合することができた。

Demoはここで確認できる。
https://twitter-eth-git-zksync-yuheinakasaka.vercel.app/

コードは下記に置いてある。
https://github.com/YuheiNakasaka/twitter-eth/tree/zksync

いくつかハマりどころもあったが既存のSoildityのコントラクトがほぼそのまま動かせるzkEVMはセンスが良いなと感じた。まだzkSync 2.0はTestnetしかないがmainnetがローンチされれば導入するプロトコルも増えるんじゃないだろうか。

とりあえず動かしてみたいという人はTutorialでHello Worldするところから始めると良さそう。
https://v2-docs.zksync.io/dev/guide/hello-world.html

正直まだ自分でもわかってないzkSyncの機能やzkEVMの問題なども結構ありそうな雰囲気はあるものの、今後も動向は追っていきたい。

リンク

https://zksync.io/
https://v2-docs.zksync.io/dev/
https://docs.zksync.io/userdocs/tech.html#zk-rollup-architecture
https://v2-docs.zksync.io/api/hardhat/testing.html
https://zksync2-testnet.zkscan.io/
https://blog.goodaudience.com/understanding-zero-knowledge-proofs-through-simple-examples-df673f796d99
https://consensys.net/blog/blockchain-explained/zero-knowledge-proofs-starks-vs-snarks/
https://starkware.co/stark-101/
https://github.com/YuheiNakasaka/twitter-eth/tree/zksync

Discussion