Skip to content

Building a MultiSig Wallet on ZKSync Era: A Complete Guide 2025

Build your own MultiSig wallet on ZKsync era

Introduction

Security in the world of Web3 is not a feature but a given. Multi-signature wallets probably represent one of the robust ways to secure one’s digital assets, where the need for several parties to approve transactions is baked into their design. Today, we will build a secure MultiSig wallet on ZKSync Era, which combines multisignature security with Layer 2 scaling efficiency. But before we dive deep into the code, let’s first understand what we are going to build and why each component matters.

What is a MultiSig Wallet?

A multisig wallet is like the digital equivalent of having a safe with multiple keyholders.  Consider this as like a corporate treasury where no one person should have complete control over the funds. Instead of needing just one signature to validate a transaction, a multisig wallet requires a predetermined number of authorized signatures before any transaction can execute.

Working of MultiSig wallet explained
Image by sCrypt

For instance, in a three-owner multisig wallet that requires two signatures before funds can be transferred, any two of the owners would have to agree on such a transfer. This significantly limits the risks of:

  • Unauthorized transactions
  • Single points of failure
  • Internal fraud
  • Key compromise

Why the ZKsync Era?

ZKSync Era is a Layer 2 scaling solution that offers several advantages for our MultiSig implementation:

  1. Cost Efficiency: Transactions cost significantly less than on Ethereum mainnet
  2. Speed: Faster transaction confirmation times
  3. Security: Inherits Ethereum’s security through ZK rollup technology
  4. EVM Compatibility: Allows us to use familiar Solidity patterns and tools.

    We have a very fun course on ZKsync. Learn to build a Decentralized Music Streaming and Tipping Platform on ZKsync.

Project Setup

Let’s start by setting up our development environment. Create a new project directory:

mkdir zksync-multisig
cd zksync-multisig
npm init -y

Project Structure

Our project will follow this structure. We will first create a directory named zksync-multisig, inside which we have a contracts folder, a deploy folder, scripts , and configuration files for hardhat, typescript, a package.json, and a .env file.

zksync-multisig/
├── contracts/
│   └── MultiSigWallet.sol
├── deploy/
│   └── deploy.ts
├── scripts/
│   └── compile.ts
├── hardhat.config.ts
├── tsconfig.json
├── package.json
└── .env

Installing Dependencies

Install the necessary packages:

npm install --save-dev @matterlabs/hardhat-zksync-deploy @matterlabs/hardhat-zksync-solc @openzeppelin/contracts zksync-web3 ethers hardhat @types/node @types/chai typescript ts-node dotenv

Each package serves a specific purpose:

  • @matterlabs/hardhat-zksync-deploy: Enables deployment to ZKSync
  • @matterlabs/hardhat-zksync-solc: Compiles contracts for ZKSync
  • @openzeppelin/contracts: Provides secure, audited contract components
  • zksync-web3: Facilitates interaction with ZKSync network

Understanding the Contract Components

Let’s break down our MultiSig wallet contract into its core components. Each piece serves a specific security or functional purpose.

State Variables

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

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract MultiSigWallet is ReentrancyGuard {
    address[] public owners;
    mapping(address => bool) public isOwner;
    uint public numConfirmationsRequired;

Here’s why each state variable matters:

  • owners: Stores the list of wallet owners’ addresses
  • isOwner: Provides O(1) lookup to verify if an address is an owner
  • numConfirmationsRequired: Defines how many owners must confirm a transaction

Transaction Structure

    struct Transaction {
        address to;
        uint value;
        bytes data;
        bool executed;
        uint numConfirmations;
    }

    Transaction[] public transactions;
    mapping(uint => mapping(address => bool)) public isConfirmed;

This structure is crucial because:

  • It tracks all transaction details including recipient, value, and execution status
  • The nested mapping isConfirmed prevents double confirmations
  • The array transactions maintains an ordered history of all proposals

Events and Modifiers

    event Deposit(address indexed sender, uint amount, uint balance);
    event SubmitTransaction(
        address indexed owner,
        uint indexed txIndex,
        address indexed to,
        uint value,
        bytes data
    );

    modifier onlyOwner() {
        require(isOwner[msg.sender], "not owner");
        _;
    }

    modifier txExists(uint _txIndex) {
        require(_txIndex < transactions.length, "tx does not exist");
        _;
    }

Events and modifiers enhance our contract by:

  • Providing transparent transaction tracking
  • Implementing access control
  • Ensuring transaction validity
  • Preventing execution of non-existent transactions

Core Functionality Implementation

Constructor

    constructor(address[] memory _owners, uint _numConfirmationsRequired) {
        require(_owners.length > 0, "owners required");
        require(
            _numConfirmationsRequired > 0 &&
                _numConfirmationsRequired <= _owners.length,
            "invalid number of required confirmations"
        );

        for (uint i = 0; i < _owners.length; i++) {
            address owner = _owners[i];
            require(owner != address(0), "invalid owner");
            require(!isOwner[owner], "owner not unique");

            isOwner[owner] = true;
            owners.push(owner);
        }

        numConfirmationsRequired = _numConfirmationsRequired;
    }

The constructor implements crucial validation:

  • Ensures there’s at least one owner
  • Validates the number of required confirmations
  • Prevents duplicate owners
  • Rejects zero addresses

Submitting Transactions

    function submitTransaction(
        address _to,
        uint _value,
        bytes memory _data
    ) public onlyOwner {
        uint txIndex = transactions.length;

        transactions.push(
            Transaction({
                to: _to,
                value: _value,
                data: _data,
                executed: false,
                numConfirmations: 0
            })
        );

        emit SubmitTransaction(msg.sender, txIndex, _to, _value, _data);
    }

This function:

  • Creates new transaction proposals
  • Initializes confirmation count to zero
  • Emits an event for off-chain tracking

Confirmation and Execution

    function confirmTransaction(
        uint _txIndex
    ) public onlyOwner txExists(_txIndex) notExecuted(_txIndex) notConfirmed(_txIndex) {
        Transaction storage transaction = transactions[_txIndex];
        transaction.numConfirmations += 1;
        isConfirmed[_txIndex][msg.sender] = true;

        emit ConfirmTransaction(msg.sender, _txIndex);
    }

    function executeTransaction(
        uint _txIndex
    ) public onlyOwner txExists(_txIndex) notExecuted(_txIndex) nonReentrant {
        Transaction storage transaction = transactions[_txIndex];

        require(
            transaction.numConfirmations >= numConfirmationsRequired,
            "cannot execute tx"
        );

        transaction.executed = true;

        (bool success, ) = transaction.to.call{value: transaction.value}(
            transaction.data
        );
        require(success, "tx failed");

        emit ExecuteTransaction(msg.sender, _txIndex);
    }

These functions implement:

  • Secure transaction confirmation tracking
  • Reentrancy protection during execution
  • Proper state updates
  • Event emission for transparency

Deploying to ZKSync Era

Hardhat Configuration

Create: hardhat.config.ts

import "@matterlabs/hardhat-zksync-deploy";
import "@matterlabs/hardhat-zksync-solc";

module.exports = {
  zksolc: {
    version: "1.3.5",
    compilerSource: "binary",
    settings: {},
  },
  defaultNetwork: "zkSyncTestnet",
  networks: {
    zkSyncTestnet: {
      url: "https://sepolia.era.zksync.dev",
      ethNetwork: "sepolia",
      zksync: true,
      verifyURL: "https://explorer.era.zksync.dev/contract_verification"
    },
  },
  solidity: {
    version: "0.8.17",
  },
};

Deployment Script

Create: deploy/deploy.ts

Note: You can get the test sepolia tokens here: https://faucet.triangleplatform.com/zksync/sepolia

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

export default async function (hre: HardhatRuntimeEnvironment) {
  const wallet = new Wallet("<YOUR_PRIVATE_KEY>");
  const deployer = new Deployer(hre, wallet);

  // Load contract artifact
  const artifact = await deployer.loadArtifact("MultiSigWallet");

  // Deploy contract
  const owners = [wallet.address]; // Add more owners as needed
  const requiredConfirmations = 1;

  const multiSigWallet = await deployer.deploy(artifact, [owners, requiredConfirmations]);

  console.log(`MultiSig wallet deployed to ${multiSigWallet.address}`);
}

Testing the Wallet

Let’s create a basic test script to verify our wallet’s functionality:

import { expect } from "chai";
import { Wallet, Provider } from "zksync-web3";
import * as hre from "hardhat";
import { Deployer } from "@matterlabs/hardhat-zksync-deploy";

describe("MultiSigWallet", function () {
    it("Should deploy and execute a transaction", async function () {
        // Test implementation
    });
});

Interacting with the MultiSig Wallet: A Command-Line Interface

To make our MultiSig wallet more accessible and easier to interact with, we’ve created a CLI tool that simplifies common operations. This script provides a user-friendly interface for submitting, confirming, and executing transactions, as well as viewing transaction details.

Understanding the CLI Tool

The CLI script is built using TypeScript and leverages the zksync-web3 and ethers libraries for blockchain interaction. It provides a menu-driven interface with six primary functions:

  1. Submit new transactions
  2. Confirm pending transactions
  3. Execute transactions that have met confirmation requirements
  4. View detailed transaction information
  5. Check the total transaction count
  6. Exit the application

Here’s how to set up and run the CLI tool:

# First, create a .env file with your private key
echo "WALLET_PRIVATE_KEY=your_private_key_here" > .env

# Install dependencies if you haven't already
npm install zksync-web3 ethers dotenv

# Save the script as multisig-cli.ts
# Run using ts-node
ts-node multisig-cli.ts

Let’s examine how the script works. At its core, the script establishes a connection to ZKSync Era’s Sepolia testnet and initializes a contract instance using your wallet:

const provider = new Provider("https://sepolia.era.zksync.dev");
const wallet = new Wallet(process.env.WALLET_PRIVATE_KEY || "", provider);
const multiSig = new ethers.Contract(MULTISIG_ADDRESS, MULTISIG_ABI, wallet);

When you run the script, you’ll be presented with a menu of options. Each option corresponds to a specific MultiSig wallet function. For example, when submitting a new transaction, the script will:

  1. Prompt for the recipient’s address
  2. Ask for the amount in ETH
  3. Submit the transaction to the blockchain
  4. Provide the transaction hash for tracking

The script includes error handling and user-friendly prompts, making it an ideal tool for both testing and production use. Whether you’re an owner needing to confirm transactions or an administrator monitoring wallet activity, the CLI provides a straightforward interface for all essential MultiSig operations.

Example Usage:

ZKSync Era MultiSig Wallet CLI
===============================
1. Submit Transaction
2. Confirm Transaction
3. Execute Transaction
4. View Transaction Details
5. Get Transaction Count
6. Exit

Enter your choice (1-6):

This tool is particularly useful for wallet owners who prefer command-line interfaces or need to automate certain interactions with the MultiSig wallet. It’s built with security in mind, using environment variables for sensitive data and providing clear feedback for all operations.


Security Considerations

When implementing your MultiSig wallet, consider these security aspects:

  1. Owner Management
  • Implement secure methods for adding/removing owners
  • Consider timelock for owner changes

2. Transaction Safety

  • Add transaction expiration
  • Implement cancellation mechanisms
  • Consider gas limits

3. Access Control

  • Carefully manage owner permissions
  • Implement role-based access if needed

Conclusion

On ZKSync Era, your MultiSig wallet development will bring together the best from both worlds: substantial security thanks to many signatures and fast transactions, courtesy of Layer 2 scaling. We have implemented a secure and extensible implementation to base your digital asset management on in a decentralized context .

Bear in mind that with smart contracts, security should be number one on your checklist. Always:

  • Thoroughly test your implementation
  • Consider having your code audited
  • Start with small amounts when testing in production
  • Monitor your contract for any unusual activity

The complete code for this project is available on GitHub, and you can find a deployed example on the ZKSync Era Sepolia testnet at 0x3f3EdA70B1732644F5C8EA8c88D7De978ecF791f.