🔐

zkSync でマルチシグコントラクトアカウントを作ってみる

2024/07/13に公開

はじめに

zkSync は zk-rollups を利用した Layer2 ソリューションです。EVM と概ね互換性を持ちつつもいくつか EVM や Ethereum と異なる点が存在します。(参考: zkSync と Ethereum は何が違うのか)異なる点の一つに、zkSync にはプロトコルレベルで Account Abstraction が実装されている点があります。

この記事では zkSync の特徴の一つである Native Account Abstraction(Native AA) を利用して、2-of-2 マルチシグコントラクトアカウントを実装する方法を解説します。
ソースコードはこちらのチュートリアルのものを利用しつつ、適宜 zkSync 特有の内容などチュートリアルには詳細な記載が無い部分も補いながら確認していきます。こちらのリポジトリを手元にクローンしておくとよりわかりやすいと思います。

マルチシグコントラクトアカウントの開発の流れは以下のようになります。

  1. マルチシグアカウントのコントラクトを実装
  2. AA Factory コントラクトを実装
  3. AA Factory のデプロイスクリプトを実装
  4. マルチシグアカウントのデプロイスクリプトを実装

マルチシグコントラクトの実装(Account Abstraction)

コード全体(Full example)
TwoUserMultisig.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IAccount.sol";
import "@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol";

import "@openzeppelin/contracts/interfaces/IERC1271.sol";

// Used for signature validation
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

// Access ZKsync system contracts for nonce validation via NONCE_HOLDER_SYSTEM_CONTRACT
import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol";
// to call non-view function of system contracts
import "@matterlabs/zksync-contracts/l2/system-contracts/libraries/SystemContractsCaller.sol";

contract TwoUserMultisig is IAccount, IERC1271 {
    // to get transaction hash
    using TransactionHelper for Transaction;

    // state variables for account owners
    address public owner1;
    address public owner2;

    bytes4 constant EIP1271_SUCCESS_RETURN_VALUE = 0x1626ba7e;

    modifier onlyBootloader() {
        require(
            msg.sender == BOOTLOADER_FORMAL_ADDRESS,
            "Only bootloader can call this function"
        );
        // Continue execution if called from the bootloader.
        _;
    }

    constructor(address _owner1, address _owner2) {
        owner1 = _owner1;
        owner2 = _owner2;
    }

    function validateTransaction(
        bytes32,
        bytes32 _suggestedSignedHash,
        Transaction calldata _transaction
    ) external payable override onlyBootloader returns (bytes4 magic) {
        return _validateTransaction(_suggestedSignedHash, _transaction);
    }

    function _validateTransaction(
        bytes32 _suggestedSignedHash,
        Transaction calldata _transaction
    ) internal returns (bytes4 magic) {
        // Incrementing the nonce of the account.
        // Note, that reserved[0] by convention is currently equal to the nonce passed in the transaction
        SystemContractsCaller.systemCallWithPropagatedRevert(
            uint32(gasleft()),
            address(NONCE_HOLDER_SYSTEM_CONTRACT),
            0,
            abi.encodeCall(INonceHolder.incrementMinNonceIfEquals, (_transaction.nonce))
        );

        bytes32 txHash;
        // While the suggested signed hash is usually provided, it is generally
        // not recommended to rely on it to be present, since in the future
        // there may be tx types with no suggested signed hash.
        if (_suggestedSignedHash == bytes32(0)) {
            txHash = _transaction.encodeHash();
        } else {
            txHash = _suggestedSignedHash;
        }

        // The fact there is enough balance for the account
        // should be checked explicitly to prevent user paying for fee for a
        // transaction that wouldn't be included on Ethereum.
        uint256 totalRequiredBalance = _transaction.totalRequiredBalance();
        require(totalRequiredBalance <= address(this).balance, "Not enough balance for fee + value");

        if (isValidSignature(txHash, _transaction.signature) == EIP1271_SUCCESS_RETURN_VALUE) {
            magic = ACCOUNT_VALIDATION_SUCCESS_MAGIC;
        } else {
            magic = bytes4(0);
        }
    }

    function executeTransaction(
        bytes32,
        bytes32,
        Transaction calldata _transaction
    ) external payable override onlyBootloader {
        _executeTransaction(_transaction);
    }

    function _executeTransaction(Transaction calldata _transaction) internal {
        address to = address(uint160(_transaction.to));
        uint128 value = Utils.safeCastToU128(_transaction.value);
        bytes memory data = _transaction.data;

        if (to == address(DEPLOYER_SYSTEM_CONTRACT)) {
            uint32 gas = Utils.safeCastToU32(gasleft());

            // Note, that the deployer contract can only be called
            // with a "systemCall" flag.
            SystemContractsCaller.systemCallWithPropagatedRevert(gas, to, value, data);
        } else {
            bool success;
            assembly {
                success := call(gas(), to, value, add(data, 0x20), mload(data), 0, 0)
            }
            require(success);
        }
    }

    function executeTransactionFromOutside(Transaction calldata _transaction)
        external
        payable
    {
        bytes4 magic = _validateTransaction(bytes32(0), _transaction);
        require(magic == ACCOUNT_VALIDATION_SUCCESS_MAGIC, "NOT VALIDATED");
        _executeTransaction(_transaction);
    }

    function isValidSignature(bytes32 _hash, bytes memory _signature)
        public
        view
        override
        returns (bytes4 magic)
    {
        magic = EIP1271_SUCCESS_RETURN_VALUE;

        if (_signature.length != 130) {
            // Signature is invalid anyway, but we need to proceed with the signature verification as usual
            // in order for the fee estimation to work correctly
            _signature = new bytes(130);

            // Making sure that the signatures look like a valid ECDSA signature and are not rejected rightaway
            // while skipping the main verification process.
            _signature[64] = bytes1(uint8(27));
            _signature[129] = bytes1(uint8(27));
        }

        (bytes memory signature1, bytes memory signature2) = extractECDSASignature(_signature);

        if(!checkValidECDSASignatureFormat(signature1) || !checkValidECDSASignatureFormat(signature2)) {
            magic = bytes4(0);
        }

        address recoveredAddr1 = ECDSA.recover(_hash, signature1);
        address recoveredAddr2 = ECDSA.recover(_hash, signature2);

        // Note, that we should abstain from using the require here in order to allow for fee estimation to work
        if(recoveredAddr1 != owner1 || recoveredAddr2 != owner2) {
            magic = bytes4(0);
        }
    }

    // This function verifies that the ECDSA signature is both in correct format and non-malleable
    function checkValidECDSASignatureFormat(bytes memory _signature) internal pure returns (bool) {
        if(_signature.length != 65) {
            return false;
        }

        uint8 v;
  bytes32 r;
  bytes32 s;
  // Signature loading code
  // we jump 32 (0x20) as the first slot of bytes contains the length
  // we jump 65 (0x41) per signature
  // for v we load 32 bytes ending with v (the first 31 come from s) then apply a mask
  assembly {
   r := mload(add(_signature, 0x20))
   s := mload(add(_signature, 0x40))
   v := and(mload(add(_signature, 0x41)), 0xff)
  }
  if(v != 27 && v != 28) {
            return false;
        }

  // EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature
        // unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines
        // the valid range for s in (301): 0 < s < secp256k1n ÷ 2 + 1, and for v in (302): v ∈ {27, 28}. Most
        // signatures from current libraries generate a unique signature with an s-value in the lower half order.
        //
        // If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value
        // with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or
        // vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept
        // these malleable signatures as well.
        if(uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
            return false;
        }

        return true;
    }

    function extractECDSASignature(bytes memory _fullSignature) internal pure returns (bytes memory signature1, bytes memory signature2) {
        require(_fullSignature.length == 130, "Invalid length");

        signature1 = new bytes(65);
        signature2 = new bytes(65);

        // Copying the first signature. Note, that we need an offset of 0x20
        // since it is where the length of the `_fullSignature` is stored
        assembly {
            let r := mload(add(_fullSignature, 0x20))
   let s := mload(add(_fullSignature, 0x40))
   let v := and(mload(add(_fullSignature, 0x41)), 0xff)

            mstore(add(signature1, 0x20), r)
            mstore(add(signature1, 0x40), s)
            mstore8(add(signature1, 0x60), v)
        }

        // Copying the second signature.
        assembly {
            let r := mload(add(_fullSignature, 0x61))
            let s := mload(add(_fullSignature, 0x81))
            let v := and(mload(add(_fullSignature, 0x82)), 0xff)

            mstore(add(signature2, 0x20), r)
            mstore(add(signature2, 0x40), s)
            mstore8(add(signature2, 0x60), v)
        }
    }

    function payForTransaction(
        bytes32,
        bytes32,
        Transaction calldata _transaction
    ) external payable override onlyBootloader {
        bool success = _transaction.payToTheBootloader();
        require(success, "Failed to pay the fee to the operator");
    }

    function prepareForPaymaster(
        bytes32, // _txHash
        bytes32, // _suggestedSignedHash
        Transaction calldata _transaction
    ) external payable override onlyBootloader {
        _transaction.processPaymasterInput();
    }

    fallback() external {
        // fallback of default account shouldn't be called by bootloader under no circumstances
        assert(msg.sender != BOOTLOADER_FORMAL_ADDRESS);

        // If the contract is called directly, behave like an EOA
    }

    receive() external payable {
        // If the contract is called directly, behave like an EOA.
        // Note, that is okay if the bootloader sends funds with no calldata as it may be used for refunds/operator payments
    }
}

コントラクトに実装する関数

関数名 実行制限 IAccount EIP-1271
validateTransaction onlyBootloader ⚪️
executeTransaction onlyBootloader ⚪️
executeTransactionFromOutside ⚪️
isValidSignature ⚪️
checkValidECDSASignatureFormat internal
extractECDSASignature internal
payForTransaction onlyBootloader ⚪️
prepareForPaymaster onlyBootloader ⚪️

今回作るマルチシグコントラクトアカウントが持つ関数の一覧です。(先頭にアンダースコアが付いただけの関数は除いています。)
zkSync ではコントラクトアカウントは必ず IAccount インターフェースを持つ必要があります。さらに、複数のアカウントによる署名に対応するために、EIP-1271 に対応する必要もあるためそれらも実装します。
どちらにも属さない checkValidECDSASignatureFormatextractECDSASignatureisValidSignature を実装するためのヘルパー関数になります。

修飾詞の onlyBootloader は bootloader だけがその関数を呼び出せるように制限します。

isValidSignature の実装(Signature Validation)

isValidSignature では与えられた署名がこのアカウントのオーナーのものであるかの検証を行います。
検証には OpenZeppelin の ECDSA ライブラリを使用します。

署名を検証は以下の流れで行われます。

  • 受け取った署名の長さが正しいか確認する。今回の場合は2人のマルチシグのため署名の長さは130になります。
  • extractECDSASignature を使ってマルチシグから2つの署名を抽出する
  • checkValidECDSASignatureFormat を使って2つの署名が有効か確認する
  • ECDSA.recover を使ってトランザクションハッシュとそれぞれの署名から署名したアドレスを抽出する
  • 抽出したアドレスがアカウントのオーナーと一致しているか確認する
  • 上記の一連の処理が成功した場合は EIP1271_SUCCESS_RETURN_VALUE を、失敗した場合は bytes4(0) を返す
function isValidSignature(bytes32 _hash, bytes memory _signature)
    public
    view
    override
    returns (bytes4 magic)
{
    magic = EIP1271_SUCCESS_RETURN_VALUE;

    if (_signature.length != 130) {
        // Signature is invalid, but we need to proceed with the signature verification as usual
        // in order for the fee estimation to work correctly
        _signature = new bytes(130);

        // Making sure that the signatures look like a valid ECDSA signature and are not rejected rightaway
        // while skipping the main verification process.
        _signature[64] = bytes1(uint8(27));
        _signature[129] = bytes1(uint8(27));
    }

    (bytes memory signature1, bytes memory signature2) = extractECDSASignature(_signature);

    if(!checkValidECDSASignatureFormat(signature1) || !checkValidECDSASignatureFormat(signature2)) {
        magic = bytes4(0);
    }

    address recoveredAddr1 = ECDSA.recover(_hash, signature1);
    address recoveredAddr2 = ECDSA.recover(_hash, signature2);

    // Note, that we should abstain from using the require here in order to allow for fee estimation to work
    if(recoveredAddr1 != owner1 || recoveredAddr2 != owner2) {
        magic = bytes4(0);
    }
}

トランザクションの検証(Transaction Validation)

トランザクションの検証では nonce のインクリメントとトランザクションの署名の検証の2つを行います。

nonce のインクリメント

system contract の NONCE_HOLDER_SYSTEM_CONTRACTincrementMinNonceIfEquals 関数を使用します。この関数はトランザクションの nonce を受け取り、それが NONCE_HOLDER_SYSTEM_CONTRACT の nonce と一致するかチェックします。同じであれば nonce をインクリメントし、そうでなければトランザクションを拒否し実行前の状態に戻します。

トランザクションの署名の検証

TransactionHelper ライブラリを使用して、署名するトランザクションハッシュを取得します。(独自の署名スキームを実装し、トランザクションの署名に別のコミットメントを使用することもできますが、ここではライブラリが提供するハッシュを使用します。)取得したハッシュを前項で作った isValidSignature に渡し検証します。
検証に成功した場合は ACCOUNT_VALIDATION_SUCCESS_MAGIC を返し、失敗した場合は bytes4(0) を返します。

function _validateTransaction(
    bytes32 _suggestedSignedHash,
    Transaction calldata _transaction
) internal returns (bytes4 magic) {
    // Incrementing the nonce of the account.
    // Note, that reserved[0] by convention is currently equal to the nonce passed in the transaction
    SystemContractsCaller.systemCallWithPropagatedRevert(
        uint32(gasleft()),
        address(NONCE_HOLDER_SYSTEM_CONTRACT),
        0,
        abi.encodeCall(INonceHolder.incrementMinNonceIfEquals, (_transaction.nonce))
    );

    bytes32 txHash;
    // While the suggested signed hash is usually provided, it is generally
    // not recommended to rely on it to be present, since in the future
    // there may be tx types with no suggested signed hash.
    if (_suggestedSignedHash == bytes32(0)) {
        txHash = _transaction.encodeHash();
    } else {
        txHash = _suggestedSignedHash;
    }

    // The fact there is enough balance for the account
    // should be checked explicitly to prevent user paying for fee for a
    // transaction that wouldn't be included on Ethereum.
    uint256 totalRequiredBalance = _transaction.totalRequiredBalance();
    require(totalRequiredBalance <= address(this).balance, "Not enough balance for fee + value");

    if (isValidSignature(txHash, _transaction.signature) == EIP1271_SUCCESS_RETURN_VALUE) {
        magic = ACCOUNT_VALIDATION_SUCCESS_MAGIC;
    } else {
        magic = bytes4(0);
    }
}

payForTransaction の実装(Paying Fees for the Transaction)

トランザクションに paymaster が設定されていない場合、bootloader がこの関数を実行しトランザクションの実行者から Gas 代を徴収します。必ず paymaster を設定することが事前にわかっている場合はこの関数は不要のため実装しないことも可能です。
TransactionHelper ライブラリには、_transaction.maxFeePerGas * _transaction.gasLimit ETH を bootloader に送信する payToTheBootloader 関数がすでに提供されています。そのためこれを呼び出すだけで実装は完了です。

function payForTransaction(
        bytes32,
        bytes32,
        Transaction calldata _transaction
    ) external payable override onlyBootloader {
        bool success = _transaction.payToTheBootloader();
        require(success, "Failed to pay the fee to the operator");
    }

prepareForPaymaster の実装(Implementing Paymaster Support)

トランザクションの Gas 代を実行者では無いアカウントが払う場合に使用します。トランザクションの paymaster フィールドにアカウントを設定すると、システムが prepareForPaymaster 関数を実行します。
Account Abstract プロトコルは任意の処理を実装できる一方で、 paymaster とやり取りをする際は EOA のサポートが組み込まれたいくつかの一般的なパターンがあります。アカウントに特別な paymaster の使用方法を用意したり、paymaster の使用を制限するような場合を除き、EOA との一貫性を保つことをお勧めします。
TransactionHelper ライブラリには一般的なパターンの機能を提供する processPaymasterInput が用意されているため、こちらもこの関数を呼び出すだけで実装完了です。

function prepareForPaymaster(
        bytes32, // _txHash
        bytes32, // _suggestedSignedHash
        Transaction calldata _transaction
    ) external payable override onlyBootloader {
        _transaction.processPaymasterInput();
    }

executeTransaction の実装(Transaction Execution)

トランザクションデータを抽出し実行します。トランザクションの宛先が ContractDeployer の場合とそうで無い場合で処理に違いがあるため分岐していますが、実装内容はシンプルです。

function _executeTransaction(Transaction calldata _transaction) internal {
    address to = address(uint160(_transaction.to));
    uint128 value = Utils.safeCastToU128(_transaction.value);
    bytes memory data = _transaction.data;

    if (to == address(DEPLOYER_SYSTEM_CONTRACT)) {
        uint32 gas = Utils.safeCastToU32(gasleft());

        // Note, that the deployer contract can only be called
        // with a "systemCall" flag.
        SystemContractsCaller.systemCallWithPropagatedRevert(gas, to, value, data);
    } else {
        bool success;
        assembly {
            success := call(gas(), to, value, add(data, 0x20), mload(data), 0, 0)
        }
        require(success);
    }
}

AA Factory コントラクトの実装(The Factory)

次に、ここまでで実装したマルチシグコントラクトアカウントをデプロイするコントラクト(AAFactory)を実装します。

zkSync では contract のデプロイは bytecode ではなく、_aaBytecodeHash と呼ばれる bytecode のハッシュを通じて行われます。bytecode 自体は factoryDeps フィールドを通じて operator に渡されます。

_aaBytecodeHash の形式は次のルールに従っている必要があります。
・sha256 でハッシュ化されていること
・ハッシュ化後、最初の2つのバイトはバイトコードの長さに置き換えられること

次の AA Factory をデプロイの項で _aaBytecodeHash にマルチシグコントラクトアカウントの BytecodeHash を計算して設定します。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol";
import "@matterlabs/zksync-contracts/l2/system-contracts/libraries/SystemContractsCaller.sol";

contract AAFactory {
    bytes32 public aaBytecodeHash;

    constructor(bytes32 _aaBytecodeHash) {
        aaBytecodeHash = _aaBytecodeHash;
    }

    function deployAccount(
        bytes32 salt,
        address owner1,
        address owner2
    ) external returns (address accountAddress) {
        (bool success, bytes memory returnData) = SystemContractsCaller
            .systemCallWithReturndata(
                uint32(gasleft()),
                address(DEPLOYER_SYSTEM_CONTRACT),
                uint128(0),
                abi.encodeCall(
                    DEPLOYER_SYSTEM_CONTRACT.create2Account,
                    (salt, aaBytecodeHash, abi.encode(owner1, owner2), IContractDeployer.AccountAbstractionVersion.Version1)
                )
            );
        require(success, "Deployment failed");

        (accountAddress) = abi.decode(returnData, (address));
    }
}

AA Factory をデプロイ(Deploy the Factory)

コントラクトの実装が終わったので、ここからは実装したコントラクトのデプロイを進めます。まずはコントラクトアカウントを作成するための AA Factory コントラクトをデプロイします。
以下はデプロイするためのコードです。デプロイの際にマルチシグコントラクトアカウントの BytecodeHash を計算して設定していることがわかります。
<WALLET_PRIVATE_KEY> の部分は自身のウォレットの秘密鍵を設定してください。

import { utils, Wallet } from "zksync-ethers";
import * as ethers from "ethers";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { Deployer } from "@matterlabs/hardhat-zksync-deploy";

export default async function (hre: HardhatRuntimeEnvironment) {
  // Private key of the account used to deploy
  const wallet = new Wallet("<WALLET_PRIVATE_KEY>");
  const deployer = new Deployer(hre, wallet);
  const factoryArtifact = await deployer.loadArtifact("AAFactory");
  const aaArtifact = await deployer.loadArtifact("TwoUserMultisig");

  // Getting the bytecodeHash of the account
  const bytecodeHash = utils.hashBytecode(aaArtifact.bytecode);

  const factory = await deployer.deploy(factoryArtifact, [bytecodeHash], undefined, [
    // Since the factory requires the code of the multisig to be available,
    // we should pass it here as well.
    aaArtifact.bytecode,
  ]);

  const factoryAddress = await factory.getAddress();

  console.log(`AA factory address: ${factoryAddress}`);
}

こちらのコマンドで上記のスクリプトを実行します。

yarn hardhat compile
yarn hardhat deploy-zksync --script deploy-factory.ts

成功すると以下のような内容が表示されます。

AA factory address: 0x70696950F71BB1cCF36Dbd1B77Ae54f96a79b005
✨  Done in 15.10s.

アカウントの作成とトランザクションの実行(Working with Accounts)

それではマルチシグコントラクトアカウントをデプロイしてトランザクションを実行してみましょう。このセクションを試すためには、事前に <WALLET-PRIVATE-KEY> に設定したアカウントに Gas 代を払うための ETH を用意する必要があります。zkSync Sepolia の faucet を利用するか、zkSync の ETH を持っていない場合は他のチェーンで Sepolia の ETH を取得して、zkSync にブリッジするのが良いでしょう。

コード全体(Full Example)
import { utils, Wallet, Provider, EIP712Signer, types } from "zksync-ethers";
import * as ethers from "ethers";
import { HardhatRuntimeEnvironment } from "hardhat/types";

// Put the address of your AA factory
const AA_FACTORY_ADDRESS = "<FACTORY-ADDRESS>"; //sepolia

export default async function (hre: HardhatRuntimeEnvironment) {
  const provider = new Provider("https://sepolia.era.zksync.dev");
  // Private key of the account used to deploy
  const wallet = new Wallet("<WALLET-PRIVATE-KEY>").connect(provider);

  const factoryArtifact = await hre.artifacts.readArtifact("AAFactory");

  const aaFactory = new ethers.Contract(AA_FACTORY_ADDRESS, factoryArtifact.abi, wallet);

  // The two owners of the multisig
  const owner1 = Wallet.createRandom();
  const owner2 = Wallet.createRandom();

  // For the simplicity of the tutorial, we will use zero hash as salt
  const salt = ethers.ZeroHash;

  // deploy account owned by owner1 & owner2
  const tx = await aaFactory.deployAccount(salt, owner1.address, owner2.address);
  await tx.wait();

  // Getting the address of the deployed contract account
  // Always use the JS utility methods
  const abiCoder = new ethers.AbiCoder();

  const multisigAddress = utils.create2Address(
    AA_FACTORY_ADDRESS,
    await aaFactory.aaBytecodeHash(),
    salt,
    abiCoder.encode(["address", "address"], [owner1.address, owner2.address])
  );
  console.log(`Multisig account deployed on address ${multisigAddress}`);

  console.log("Sending funds to multisig account");
  // Send funds to the multisig account we just deployed
  await (
    await wallet.sendTransaction({
      to: multisigAddress,
      // You can increase the amount of ETH sent to the multisig
      value: ethers.parseEther("0.008"),
      nonce: await wallet.getNonce(),
    })
  ).wait();

  let multisigBalance = await provider.getBalance(multisigAddress);

  console.log(`Multisig account balance is ${multisigBalance.toString()}`);

  // Transaction to deploy a new account using the multisig we just deployed
  let aaTx = await aaFactory.deployAccount.populateTransaction(
    salt,
    // These are accounts that will own the newly deployed account
    Wallet.createRandom().address,
    Wallet.createRandom().address
  );

  const gasLimit = await provider.estimateGas({ ...aaTx, from: wallet.address });
  const gasPrice = await provider.getGasPrice();

  aaTx = {
    ...aaTx,
    // deploy a new account using the multisig
    from: multisigAddress,
    gasLimit: gasLimit,
    gasPrice: gasPrice,
    chainId: (await provider.getNetwork()).chainId,
    nonce: await provider.getTransactionCount(multisigAddress),
    type: 113,
    customData: {
      gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
    } as types.Eip712Meta,
    value: 0n,
  };

  const signedTxHash = EIP712Signer.getSignedDigest(aaTx);

  // Sign the transaction with both owners
  const signature = ethers.concat([ethers.Signature.from(owner1.signingKey.sign(signedTxHash)).serialized, ethers.Signature.from(owner2.signingKey.sign(signedTxHash)).serialized]);

  aaTx.customData = {
    ...aaTx.customData,
    customSignature: signature,
  };

  console.log(`The multisig's nonce before the first tx is ${await provider.getTransactionCount(multisigAddress)}`);

  const sentTx = await provider.broadcastTransaction(types.Transaction.from(aaTx).serialized);
  console.log(`Transaction sent from multisig with hash ${sentTx.hash}`);

  await sentTx.wait();

  // Checking that the nonce for the account has increased
  console.log(`The multisig's nonce after the first tx is ${await provider.getTransactionCount(multisigAddress)}`);

  multisigBalance = await provider.getBalance(multisigAddress);

  console.log(`Multisig account balance is now ${multisigBalance.toString()}`);
}

アカウントのデプロイ(Deploying an Account)

以下はコントラクトアカウントをデプロイするスクリプトです。AA Factory コントラクトの deployAccount 関数を呼び出しています。マルチシグのオーナーのアドレスはランダムに生成しています。

deploy-multisig.ts
import { utils, Wallet, Provider, EIP712Signer, types } from "zksync-ethers";
import * as ethers from "ethers";
import { HardhatRuntimeEnvironment } from "hardhat/types";

// Put the address of your AA factory
const AA_FACTORY_ADDRESS = "<FACTORY-ADDRESS>";

export default async function (hre: HardhatRuntimeEnvironment) {
  const provider = new Provider("https://sepolia.era.zksync.dev");
  // Private key of the account used to deploy
  const wallet = new Wallet("<WALLET-PRIVATE-KEY>").connect(provider);

  const factoryArtifact = await hre.artifacts.readArtifact("AAFactory");

  const aaFactory = new ethers.Contract(AA_FACTORY_ADDRESS, factoryArtifact.abi, wallet);

  // The two owners of the multisig
  const owner1 = Wallet.createRandom();
  const owner2 = Wallet.createRandom();

  // For the simplicity of the tutorial, we will use zero hash as salt
  const salt = ethers.ZeroHash;

  // deploy account owned by owner1 & owner2
  const tx = await aaFactory.deployAccount(salt, owner1.address, owner2.address);
  await tx.wait();

  // Getting the address of the deployed contract account
  // Always use the JS utility methods
  const abiCoder = new ethers.AbiCoder();
  const multisigAddress = utils.create2Address(
    AA_FACTORY_ADDRESS,
    await aaFactory.aaBytecodeHash(),
    salt,
    abiCoder.encode(["address", "address"], [owner1.address, owner2.address])
  );
  console.log(`Multisig account deployed on address ${multisigAddress}`);
}

トランザクションの実行(Start a Transaction from the Account)

トランザクションを実行する前に、Gas 代として ETH をアカウントに用意する必要があります。
以下のコードで 0.008 ETH をコントラクトアカウントに送金します。

console.log("Sending funds to multisig account");

// Send funds to the multisig account we just deployed
await(
  await wallet.sendTransaction({
    to: multisigAddress,
    // You can increase the amount of ETH sent to the multisig
    value: ethers.parseEther("0.008"),
    nonce: await wallet.getNonce(),
  })
).wait();

let multisigBalance = await provider.getBalance(multisigAddress);

console.log(`Multisig account balance is ${multisigBalance.toString()}`);

これでコントラクトアドレスでトランザクションを実行する準備が整いました。今回は AA Factory コントラクトでマルチシグコントラクトを作成するトランザクションを実行してみます。
トランザクション実行の実験のため、ここでもオーナーアドレスにはランダムなアドレスを設定します。

let aaTx = await aaFactory.deployAccount.populateTransaction(
  salt,
  // These are accounts that will own the newly deployed account
  Wallet.createRandom().address,
  Wallet.createRandom().address
);

トランザクションの各フィールドに値を埋めます。

const gasLimit = await provider.estimateGas({ ...aaTx, from: wallet.address });
const gasPrice = await provider.getGasPrice();

aaTx = {
  ...aaTx,
  // deploy a new account using the multisig
  from: multisigAddress,
  gasLimit: gasLimit,
  gasPrice: gasPrice,
  chainId: (await provider.getNetwork()).chainId,
  nonce: await provider.getTransactionCount(multisigAddress),
  type: 113,
  customData: {
    gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
  } as types.Eip712Meta,
  value: 0n,
};

トランザクションに署名し、customeData にセットします。

const signedTxHash = EIP712Signer.getSignedDigest(aaTx);

// Sign the transaction with both owners
const signature = ethers.concat([ethers.Signature.from(owner1.signingKey.sign(signedTxHash)).serialized, ethers.Signature.from(owner2.signingKey.sign(signedTxHash)).serialized]);

aaTx.customData = {
  ...aaTx.customData,
  customSignature: signature,
};

最後にトランザクションをブロックチェーンに送信します。

console.log(`The multisig's nonce before the first tx is ${await provider.getTransactionCount(multisigAddress)}`);

const sentTx = await provider.broadcastTransaction(types.Transaction.from(aaTx).serialized);
console.log(`Transaction sent from multisig with hash ${sentTx.hash}`);
await sentTx.wait();

// Checking that the nonce for the account has increased
console.log(`The multisig's nonce after the first tx is ${await provider.getTransactionCount(multisigAddress)}`);

multisigBalance = await provider.getBalance(multisigAddress);

console.log(`Multisig account balance is now ${multisigBalance.toString()}`);

一連の処理に成功すると以下のような内容が表示されます。作成したマルチシグコントラクトでトランザクションを実行することができました。トランザクションの内容は Block Explorer でも確認することもできます。

実行結果
❯ yarn hardhat deploy-zksync --script deploy-multisig.ts
yarn run v1.22.10
$ /Users/hogehoge/custom-aa-tutorial/node_modules/.bin/hardhat deploy-zksync --script deploy-multisig.ts
Multisig account deployed on address 0x****************************************
Sending funds to multisig account
Multisig account balance is 8000000000000000
The multisig's nonce before the first tx is 0
Transaction sent from multisig with hash 0x***************************************************************
The multisig's nonce after the first tx is 1
Multisig account balance is now 7964499900000000

Discussion