📧

zkSync から Ethereum にメッセージを送る方法

2024/09/03に公開

本記事では zkSync から Ethereum にメッセージを送る方法を紹介します。

zkSync とは

zkSync は zk-rollups を利用した Layer2 ソリューションです。2020年に決済に特化した zkSync Lite(zkSync 1.0) がリリースされ、2023年3月に zkEVM に対応した zkSync Era(zkSync 2.0) が発表されました。

L2→L1のメッセージ送信の処理の流れ

以下のような流れで zkSync からメインネットに任意の文字列を送ることが可能です。

  1. zkSync で L1_MESSANGER コントラクトの sendToL1 を実行
  2. zkRollup の batch 処理でメインネットにトランザクションの要約データを送信し、proof を生成
  3. proof を使用してメッセージがメインネットに取り込まれているかを検証

zkSync からメインネットに資産をブリッジする際などにこの仕組みが利用されています。

実装例

こちらで開発したものに追加する形で実装を行います。
https://zenn.dev/taka101/articles/490d3d224174cb#クラウド
※以下のサンプルコードについて、可読性の観点では本来いくつかのファイルに分けるべき内容ではありますが、ここでは敢えて一つのファイルで実装しています。

メッセージを Messanger コントラクトに送る

zkSync では system contract として L1_MESSANGER コントラクトが用意されています。このコントラクトの sendToL1 メソッドに送りたい任意のメッセージを渡して実行することで、Layer2(zkSync) を経由して メインネット(Ethereum) に送られます。

SendMessageToL1.ts
import { utils } from "zksync-ethers";
import {
  useWriteContract,
  useWaitForTransactionReceipt,
  type BaseError,
} from "wagmi";
import { stringToHex } from "viem";

export const SendMessageToL1 = () => {

	const abi = [
		{
            type: "function",
			name: "sendToL1",
			stateMutability: "nonpayable",
			inputs: [{ name: "_message", type: "bytes" }],
			outputs: [{ type: "bytes32" }],
		},
	];

  const { data: hash, error, isPending, writeContract } = useWriteContract();

  async function submit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.target as HTMLFormElement);
    const message = formData.get("message") as string;
    writeContract({
      abi,
      address: utils.L1_MESSENGER_ADDRESS as `0x${string}`,
      functionName: "sendToL1",
      args: [stringToHex(message)],
    });
  }

  const { isLoading: isConfirming, isSuccess: isConfirmed } =
    useWaitForTransactionReceipt({
      hash,
    });

  return (
    <center>
      <form onSubmit={submit} className="form-example">
        <center>
          <div className="form-example">
            <label htmlFor="message">Message: </label>
            <input
              name="message"
              placeholder="Some from L2 to L1 message"
              required
            />
          </div>
        </center>
        <div>
          <button disabled={isPending} type="submit">
            {isPending ? "Confirming..." : "Send a message to L1"}
          </button>
        </div>
        {hash && <div>Transaction Hash: {hash}</div>}
        {isConfirming && <div>Waiting for confirmation...</div>}
        {isConfirmed && <div>Transaction confirmed.</div>}
        {error && (
          <div>Error: {(error as BaseError).shortMessage || error.message}</div>
        )}
      </form>
    </center>
  );
};

メッセージがブロックチェーンに取り込まれたかを検証

L1_Messanger コントラクトに送られたメッセージは zkRollup を通じてメインネットに取り込まれますが、ロールアップでメインネットに取り込まれるものはメッセージそのものではなくその要約データとなります。そのため、メインネットからメッセージそのものを取得するわけではなく、ロールアップの際に別途出力される proof を利用して送信したメッセージがメインネットに取り込まれたかを検証することとなります。

VerifyMessage.ts
import { useEffect, useState } from "react";
import { Provider } from "zksync-ethers";
import { useAccount } from "wagmi";
import { stringToHex } from "viem";
import { readContract, http, createConfig } from "@wagmi/core";
import { sepolia } from "wagmi/chains";

const l2Provider = new Provider("https://sepolia.era.zksync.dev");

async function getTransactionDetails(hash: string) {
  console.log(`Getting L2 tx details for transaction ${hash}`);
  const l2Receipt = await l2Provider.getTransactionReceipt(hash);
  console.log(`Receipt is: `, l2Receipt);
  return l2Receipt;
}

async function getL2LogProof(hash: string, index: number) {
  console.log(
    `Getting L2 message proof for transaction ${hash} and index ${index}`
  );
  const proof = await l2Provider.getLogProof(hash, index);
  console.log(`Proof is: `, proof);
  return proof;
}

const config = createConfig({
  chains: [sepolia],
  transports: {
    [sepolia.id]: http(),
  },
});

export const abi = [
  {
    type: "function",
    name: "proveL2MessageInclusion",
    stateMutability: "view",
    inputs: [
      {
        internalType: "uint256",
        name: "_batchNumber",
        type: "uint256",
      },
      {
        internalType: "uint256",
        name: "_index",
        type: "uint256",
      },
      {
        components: [
          {
            internalType: "uint16",
            name: "txNumberInBatch",
            type: "uint16",
          },
          {
            internalType: "address",
            name: "sender",
            type: "address",
          },
          {
            internalType: "bytes",
            name: "data",
            type: "bytes",
          },
        ],
        internalType: "struct L2Message",
        name: "_message",
        type: "tuple",
      },
      {
        internalType: "bytes32[]",
        name: "_proof",
        type: "bytes32[]",
      },
    ],
    outputs: [
      {
        internalType: "bool",
        name: "",
        type: "bool",
      },
    ],
  },
] as const;

export const VerifyMessage = () => {
  const { address } = useAccount();
  const [error, setError] = useState<string | null>(null);
  useEffect(() => {
    console.log(error);
  }, [error]);

  async function submit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.target as HTMLFormElement);
    const txHash = formData.get("txHash") as string;
    const message = formData.get("message") as string;
    let l2Receipt;
    try {
      l2Receipt = await getTransactionDetails(txHash);
    } catch (e: unknown) {
      if (e instanceof Error) {
        setError(e.message);
      }
      return;
    }
    if (!l2Receipt) {
      setError(`No L2 transaction found for hash ${txHash}`);
      return;
    }

    let l2Proof;
    try {
      l2Proof = await getL2LogProof(txHash, l2Receipt.index);
    } catch (e: unknown) {
      if (e instanceof Error) {
        setError(e.message);
      }
      return;
    }
    if (!l2Proof) {
      setError(`No L2 proof found for hash ${txHash}`);
      return;
    }

    const zkAddress = await l2Provider.getMainContractAddress();
    const resultVerification = await readContract(config, {
      abi,
      address: zkAddress as `0x${string}`,
      functionName: "proveL2MessageInclusion",
      args: [
        l2Receipt.l1BatchNumber as any,
        l2Proof.id as any,
        {
          txNumberInBatch: l2Receipt.l1BatchTxIndex ?? 0,
          sender: address as `0x${string}`,
          data: stringToHex(message),
        },
        l2Proof.proof as any,
      ],
      chainId: sepolia.id,
    });
    alert(`the result of the verification:  ${resultVerification}`);
    console.log(`Verification result: ${resultVerification}`);
  }

  return (
    <center>
      <form onSubmit={submit} className="form-example">
        <center>
          <div className="form-example">
            <label htmlFor="message">Tx Hash of the message: </label>
            <input name="txHash" placeholder="0x..." required />
          </div>
          <div className="form-example">
            <label htmlFor="message">Sent message: </label>
            <input
              name="message"
              placeholder="Some from L2 to L1 message"
              required
            />
          </div>
        </center>
        <div>
          <button type="submit">Verify the message</button>
        </div>
        {error && <div>Error: {error}</div>}
      </form>
    </center>
  );
};

デモサイト

上記のコードを追加実装したサイトがこちらになります。
https://takahiro-kimura.github.io/walletconnect-tutorial/

使い方


①のフォームに任意の文字列を入力してトランザクションを実行すると、その文字列(の要約データ)がメインネットに書き込まれます。
②のフォームに①をで実行したトランザクションの hash と、その時に送信した文字列を入れてボタンを押すと、true と表示され、実際にメインネットに指定した文字列が取り込まれていることを確認できます。①と異なる文字列をフォームに入れた場合は false と表示されます。

注意点

rollup によるメインネットへのデータの反映は平均して24時間程度の時間が必要です。そのため送信したメッセージがメインネットに取り込まれたかを確認できるようになるのも、送信から24時間程度経過してからになります。
参考: zkSync のトランザクションライフサイクル

Discussion