How to Write a Multi-Signatures Contract on Celo using Hardhat | Part 2/2

How to Write a Multi-Signatures Contract on Celo using Hardhat | Part 2/2 https://celo.academy/uploads/default/optimized/2X/2/265503331e6a1f8035de10b475157379b897c3b2_2_1024x576.png
none 0.0 0

Introduction

In Part 1, we wrote a multi-signature smart contract in Solidity using Hardhat. Multi-signatures are a secure way to protect your cryptocurrency assets and the ownership of your smart contract. The multi-signature contract acts as a wallet capable of executing transactions to transfer Ethereum or call other smart contracts.

In this Part 2, we will write tests for our multi-signature contract using Hardhat. We will also deploy our contract on the Celo Alfajores testnet.

Prerequisites

To follow this tutorial, you will need:

  • A GitHub account GitHub

  • Some knowledge of Solidity

  • Understanding of the multi-signatures concept (you can read more about it in What Is a Multisig Wallet?)

  • Familiarity with the following multi-signature contract terms:

    • Owners - Addresses that can sign and execute transactions on this wallet contract.
    • Threshold - The number of owner signatures required for a transaction to be executed.
    • Nonce - A unique number that identifies each request to prevent signatures from being used on more than one transaction.

Requirements

To complete this tutorial, you will need:

Setup Our Test Helpers

To help us write our tests more efficiently, minimize code duplication, improve the maintainability of our repository, and make our tests more readable, we will create a few helper functions that we can reuse in our tests.

To do so, we will create a new folder called helpers inside the test folder. Inside this folder, we will create a few files:

  • errors.js - This file will contain all the error messages that we will use in our tests (error messages from the require statements in our contract).
  • utils.js - This file will contain all the general-purpose functions that we will use in our tests (retrieve our wallets, deploy our contract, etc.).
  • signature.js - This file will contain the functions that we will use to sign the transaction requests before sending them to the contract.
  • test.js - This file will contain the helper functions that we will use to call the contract functions and verify the results.
  • index.js - This file will export all the functions from the other files.

To create the required directories and files, you can run the following commands:

mkdir test/helper
touch test/helper/errors.js test/helper/utils.js test/helper/signature.js test/helper/test.js test/helper/index.js

This should give you the following directory structure:

Errors helper

In the errors.js file, we will add the following code:

module.exports = {
  NOT_SELF: "CeloMultiSig: only this contract can call this function",
  MAX_OWNERS_COUNT_EXCEEDED: "CeloMultiSig: cannot add owner above 2^16 - 1",
  INVALID_SIGNATURE: "CeloMultiSig: invalid signatures",
  INVALID_OWNER: "CeloMultiSig: invalid owner",
  OWNER_ALREADY_SIGNED: "CeloMultiSig: owner already signed",
  NOT_ENOUGH_GAS: "CeloMultiSig: not enough gas",
  OWNER_COUNT_BELOW_THRESHOLD:
    "CeloMultiSig: cannot remove owner below threshold",
  THRESHOLD_IS_ZERO: "CeloMultiSig: threshold must be greater than 0",
  THRESHOLD_GREATER_THAN_OWNERS_COUNT:
    "CeloMultiSig: threshold must be less than or equal to owner count",
  OLD_OWNER_NOT_OWNER: "CeloMultiSig: old owner must be an owner",
  NEW_OWNER_ALREADY_OWNER: "CeloMultiSig: new owner must not be an owner",
  NEW_OWNER_IS_ZERO_ADDRESS:
    "CeloMultiSig: new owner must not be the zero address",
};

These error messages are the same as the ones we used in our contract. We simply copied and pasted them here and assigned them to a variable. This has the advantage of making our tests more readable and easier to maintain. This way, if we change the error message in our contract, we will only have to change it in one place.

This should give you the following result:

Utils helper

In the utils.js file, we will add the following code:

const { ethers, network, addressBook } = require("hardhat");

module.exports = {
  setupProviderAndWallets: async function () {
    const provider = ethers.provider;
    let owner01;
    let owner02;
    let owner03;

    let user01;
    let user02;
    let user03;

    if (network.config.accounts && network.config.accounts.mnemonic) {
      // If the network is configured with a mnemonic, use it to generate the wallets
      owner01 = new ethers.Wallet(
        ethers.Wallet.fromMnemonic(
          network.config.accounts.mnemonic,
          `m/44'/60'/0'/0/0`
        ).privateKey,
        provider
      );
      owner02 = new ethers.Wallet(
        ethers.Wallet.fromMnemonic(
          network.config.accounts.mnemonic,
          `m/44'/60'/0'/0/1`
        ).privateKey,
        provider
      );
      owner03 = new ethers.Wallet(
        ethers.Wallet.fromMnemonic(
          network.config.accounts.mnemonic,
          `m/44'/60'/0'/0/2`
        ).privateKey,
        provider
      );

      user01 = new ethers.Wallet(
        ethers.Wallet.fromMnemonic(
          network.config.accounts.mnemonic,
          `m/44'/60'/0'/0/3`
        ).privateKey,
        provider
      );
      user02 = new ethers.Wallet(
        ethers.Wallet.fromMnemonic(
          network.config.accounts.mnemonic,
          `m/44'/60'/0'/0/4`
        ).privateKey,
        provider
      );
      user03 = new ethers.Wallet(
        ethers.Wallet.fromMnemonic(
          network.config.accounts.mnemonic,
          `m/44'/60'/0'/0/5`
        ).privateKey,
        provider
      );
    } else {
      // If the network is not configured with a mnemonic, use the 3 first accounts as owners and the 3 next as users
      owner01 = new ethers.Wallet(network.config.accounts[0], provider);
      owner02 = new ethers.Wallet(network.config.accounts[1], provider);
      owner03 = new ethers.Wallet(network.config.accounts[2], provider);

      user01 = new ethers.Wallet(network.config.accounts[3], provider);
      user02 = new ethers.Wallet(network.config.accounts[4], provider);
      user03 = new ethers.Wallet(network.config.accounts[5], provider);
    }
    return [provider, owner01, owner02, owner03, user01, user02, user03];
  },
  deployContract: async function (owners, threshold) {
    // Retrieve the contract factory
    const CeloMultiSig = await ethers.getContractFactory("CeloMultiSig");
    // Deploy the contract with the specified parameters for the constructor
    const contract = await CeloMultiSig.deploy(owners, threshold, {
      gasLimit: 10000000,
    });
    // Wait for the contract to be deployed
    await contract.deployed();
    // Save the contract address in the address book
    await addressBook.saveContract(
      "CeloMultiSig",
      contract.address,
      network.name,
      contract.deployTransaction.from,
      network.config.chainId,
      contract.deployTransaction.blockHash,
      contract.deployTransaction.blockNumber,
      undefined,
      {
        owners,
        threshold,
      }
    );
    // Return the contract
    return contract;
  },
};

This file should now look like this:

In this file, we added two functions:

  • setupProviderAndWallets: This function will set up the provider and the wallets we will use in our tests. It will also check if the network is configured with a mnemonic or not. If it is, it will use it to generate the wallets. If it is not, it will use the first 6 accounts in the network configuration.
  • deployContract: This function will deploy the contract with the specified parameters for the constructor. It will also save the contract address in the address book.

Signature helper

In the signature.js file, we will add the following code:

const { network } = require("hardhat");

module.exports = {
  signTransaction: async function (
    contractAddress,
    wallet,
    to,
    value,
    data,
    gas,
    nonce
  ) {
    const signature = await wallet._signTypedData(
      {
        name: "CeloMultiSig",
        version: "1.0",
        chainId: network.config.chainId,
        verifyingContract: contractAddress,
      },
      {
        Transaction: [
          {
            name: "to",
            type: "address",
          },
          {
            name: "value",
            type: "uint256",
          },
          {
            name: "data",
            type: "bytes",
          },
          {
            name: "gas",
            type: "uint256",
          },
          {
            name: "nonce",
            type: "uint96",
          },
        ],
      },
      {
        to,
        value,
        data,
        gas,
        nonce,
      }
    );
    return signature;
  },
};

Note
This function uses _signTypedData to sign the transaction as per ethersJS documentation here. However, in future versions of ethersJS, this function will be deprecated and replaced by signTypedData.

Like this:

Test helper

In the test.js file, we will add the following code:

const { ethers, network } = require("hardhat");
const { expect } = require("chai");

const signature = require("./signatures");

const ZERO = ethers.BigNumber.from(0);

const sendRawTxn = async (input, sender, ethers, provider) => {
  // Get the nonce
  const txCount = await provider.getTransactionCount(sender.address);
  // Prepare the transaction
  const rawTx = {
    chainId: network.config.chainId,
    nonce: ethers.utils.hexlify(txCount),
    to: input.to,
    value: input.value || 0x00,
    gasLimit: ethers.utils.hexlify(3000000),
    gasPrice: ethers.utils.hexlify(25000000000),
    data: input.data,
  };
  // Sign the transaction
  const rawTransactionHex = await sender.signTransaction(rawTx);
  // Send the transaction
  const { hash } = await provider.sendTransaction(rawTransactionHex);
  // Wait for the transaction to be mined
  return await provider.waitForTransaction(hash);
};

const checkRawTxnResult = async (input, sender, error) => {
  let result;
  // Check if the transaction should fail or not
  if (error)
    if (network.name === "hardhat" || network.name === "localhost")
      await expect(
        sendRawTxn(input, sender, ethers, ethers.provider)
      ).to.be.revertedWith(error);
    else expect.fail("AssertionError: " + error);
  else {
    result = await sendRawTxn(input, sender, ethers, ethers.provider);
    expect(result.status).to.equal(1);
  }
  return result;
};

const getEventFromReceipt = async (contract, receipt, eventName) => {
  // Parse the logs
  const log = receipt.logs.map((log) => {
    try {
      return contract.interface.parseLog(log);
    } catch (e) {
      return;
    }
  });
  return log;
};

These functions are used to send raw transactions and check the result of the transaction. They also allow us to get the event from the receipt.

It should now look like this:

Now after the last line you added, add the following code:

const prepareSignatures = async (
  contract,
  owners,
  to,
  value,
  data,
  gas = 30000
) => {
  // Query the next nonce
  const nonce = await contract.nonce();
  let signatures = "0x";
  for (var i = 0; i < owners.length; i++) {
    // For each owners, sign the transaction
    const sig = await signature.signTransaction(
      contract.address,
      owners[i],
      to,
      value,
      data,
      gas,
      nonce
    );
    // Concatenate the signatures
    signatures += sig.substring(2);
  }
  // Return signatures of all owners
  return signatures;
};

This function will prepare the signatures of all the owners of the contract. We will pass an array of owners using their wallets to the function and sign the transaction with each of them. The signatures will be concatenated and returned.

It should now look like this:

We still need to add the main helper function for testing the execution of the transaction. Please add the following code:

const execTransaction = async (
  contract,
  submitter,
  owners,
  to,
  value,
  data,
  gas = 30000,
  errorMsg,
  extraEvents,
  signatures
) => {
  // Prepare signatures if not provided
  if (!signatures)
    signatures = await prepareSignatures(
      contract,
      owners,
      to,
      value,
      data,
      gas
    );
  // Prepare transaction
  const input = await contract
    .connect(submitter)
    .populateTransaction.execTransaction(to, value, data, gas, signatures);

  // Send the transaction and check the result
  const receipt = await checkRawTxnResult(input, submitter, errorMsg);
  if (!errorMsg) {
    // Check the event emitted (if transaction should succeed)
    const event = await getEventFromReceipt(
      contract,
      receipt,
      "TransactionExecuted"
    );
    let found = false;
    for (var i = 0; i < event.length; i++) {
      if (event[i] && event[i].name === "TransactionExecuted") {
        // If the event is found, check the parameters
        expect(event[i].args.sender).to.be.equal(submitter.address);
        expect(event[i].args.to).to.be.equal(to);
        expect(event[i].args.value).to.be.equal(value);
        expect(event[i].args.data).to.be.equal(data);
        expect(event[i].args.txnGas).to.be.equal(gas);
        found = true;
        return receipt;
      } else {
        // If the event is not found, check if the transaction failed
        if (
          extraEvents &&
          extraEvents.find(
            (extraEvent) => extraEvent === "TransactionFailed"
          ) &&
          event[i] &&
          event[i].name === "TransactionFailed"
        ) {
          // If the transaction failed, check the parameters and if we expect a failure
          expect(event[i].args.sender).to.be.equal(submitter.address);
          expect(event[i].args.to).to.be.equal(to);
          expect(event[i].args.value).to.be.equal(value);
          expect(event[i].args.data).to.be.equal(data);
          expect(event[i].args.txnGas).to.be.equal(gas);
          found = true;
          return receipt;
        } else {
          // If the transaction failed but we don't expect it, throw an error
          if (found) expect.fail("TransactionExecuted event not found");
        }
      }
    }
    // If the event is not found, throw an error
    if (event.length == 0) expect.fail("TransactionExecuted event not found");
    // If we expect an extra event, check if it is emitted
    if (extraEvents && extraEvents.length > 0) {
      for (let i = 1; i < extraEvents.length; i++) {
        const eventsFound = await getEventFromReceipt(contract, receipt, event);
        for (var ii = 0; i < eventsFound.length; ii++) {
          if (eventsFound[ii]) {
            expect(submitter.address).to.be.equal(eventsFound[ii].sender);
            return receipt;
          }
        }
      }
    }
  }
};

This function will prepare the transaction, send it, and check the result. It will also check the event emitted by the transaction. This helper function is flexible and can be used to test the execution of a transaction, the failure of a transaction, or the execution of a transaction with extra event.

Please make sure the function looks like this:

Lastly, we need to add the function that will be used to test the addOwner, removeOwner, changeThreshold, and replaceOwner functions. Please add the following code:

const addOwner = async (
  contract,
  submitter,
  owners,
  ownerToAdd,
  gas = 30000,
  errorMsg,
  extraEvents
) => {
  const data = contract.interface.encodeFunctionData("addOwner(address)", [
    ownerToAdd,
  ]);

  await execTransaction(
    contract,
    submitter,
    owners,
    contract.address,
    ZERO,
    data,
    gas,
    errorMsg,
    extraEvents
  );

  if (!errorMsg) expect(await contract.isOwner(ownerToAdd)).to.be.true;
};

const removeOwner = async (
  contract,
  submitter,
  owners,
  ownerToRemove,
  gas = 30000,
  errorMsg,
  extraEvents
) => {
  const data = contract.interface.encodeFunctionData("removeOwner(address)", [
    ownerToRemove,
  ]);

  await execTransaction(
    contract,
    submitter,
    owners,
    contract.address,
    ZERO,
    data,
    gas,
    undefined,
    extraEvents
  );

  if (!errorMsg) expect(await contract.isOwner(ownerToRemove)).to.be.false;
  else expect(await contract.isOwner(ownerToRemove)).to.be.true;
};

const changeThreshold = async (
  contract,
  submitter,
  owners,
  newThreshold,
  gas = 30000,
  errorMsg,
  extraEvents
) => {
  const data = contract.interface.encodeFunctionData(
    "changeThreshold(uint16)",
    [newThreshold]
  );

  await execTransaction(
    contract,
    submitter,
    owners,
    contract.address,
    ZERO,
    data,
    gas,
    errorMsg,
    extraEvents
  );

  if (!errorMsg) expect(await contract.threshold()).to.be.equal(newThreshold);
};

const replaceOwner = async (
  contract,
  submitter,
  owners,
  ownerToAdd,
  ownerToRemove,
  gas = 30000,
  errorMsg,
  extraEvents
) => {
  const data = contract.interface.encodeFunctionData(
    "replaceOwner(address,address)",
    [ownerToRemove, ownerToAdd]
  );

  await execTransaction(
    contract,
    submitter,
    owners,
    contract.address,
    ZERO,
    data,
    gas,
    errorMsg,
    extraEvents
  );

  if (!errorMsg) {
    expect(await contract.isOwner(ownerToAdd)).to.be.true;
    expect(await contract.isOwner(ownerToRemove)).to.be.false;
  }
};

These four functions make use of the execTransaction function to test the execution of the addOwner, removeOwner, changeThreshold, and replaceOwner functions. They will also check if the owner was added, removed, or replaced correctly or if the threshold was changed correctly.

As our main execTransaction function takes data as an argument, we need to encode our function call before sending it. We can do that using the encodeFunctionData function from the contract interface. This function will encode the function name and the arguments into a data string that can be used to call the function.

Please ensure the text looks like this:

Finally, we need to export all the helper functions. Please add the following code at the end of the file:

module.exports = {
  checkRawTxnResult,
  prepareSignatures,
  execTransaction,
  addOwner,
  removeOwner,
  changeThreshold,
  replaceOwner,
};

This way, we can import all the helper functions in our test file.

Please make sure the text looks like this:

image

Exporting all the helpers

In the index.js file, we will add the following code:

const errors = require("./errors");
const test = require("./test");
const signature = require("./signatures");
const utils = require("./utils");

module.exports = {
  errors,
  test,
  signature,
  ...utils,
};

This way, we can import all the helper functions in our test file by importing the index.js file.

Please ensure the text looks like this:

image

Writing our first test

Now that we have all the helper functions ready, we can start writing our first test. In the test folder, rename the Lock.js file that Hardhat had added for us to CeloMultiSig.test.js. Then, replace all the content of the file with the following code:

image
image

const { expect } = require("chai");
const { ethers } = require("hardhat");

const Helper = require("./helper");

let provider;
let owner01;
let owner02;
let owner03;
let ownerCount;
let user01;
let user02;
let user03;
let contract;

describe("CeloMultiSig", function () {
  before(async function () {
    [provider, owner01, owner02, owner03, user01, user02, user03] =
      await Helper.setupProviderAndWallets();
  });

  beforeEach(async function () {
    const owners = [owner01.address, owner02.address, owner03.address];
    ownerCount = owners.length;
    contract = await Helper.deployContract(
      [owner01.address, owner02.address, owner03.address],
      2
    );
  });

  it("Contract return correct contract name", async function () {
    expect(await contract.name()).to.be.equal("CeloMultiSig");
  });

  it("Contract return correct contract version", async function () {
    expect(await contract.version()).to.be.equal("1.0");
  });
});

Please make sure the text looks like this:

Now that we have some tests, we can run them using the following command:

npx hardhat test

Doing so, we should have our first error! It should look something like this:

The important part of the error is the following:

Error: VM Exception while processing transaction: reverted with reason string 'CeloMultiSig: only this contract can call this function'

This error is telling us that somewhere in our constructor function logic, we are calling a function that is not allowed to be called by anyone else than the contract itself. Let’s fix that!

Fix the constructor function

This is our constructor function:

constructor(address[] memory owners_, uint16 threshold_) EIP712(name(), version()) {
  require(owners_.length <= 2 ** 16 - 1, 'CeloMultiSig: cannot add owner above 2^16 - 1');
  uint256 length = owners_.length;
  for (uint256 i = 0; i < length; ) {
    _owners[owners_[i]] = true;
    unchecked {
      ++i;
    }
  }
  _ownerCount = uint16(owners_.length);
  changeThreshold(threshold_);
}

The last line of this logic is calling the changeThreshold function. Let’s take a look at this function:

function changeThreshold(uint16 newThreshold) public onlyThis {
  require(newThreshold > 0, 'CeloMultiSig: threshold must be greater than 0');
  require(newThreshold <= _ownerCount, 'CeloMultiSig: threshold must be less than or equal to owner count');
  _threshold = newThreshold;
}

This function is using the onlyThis modifier. Let’s take a look at this modifier:

modifier onlyThis() {
  require(msg.sender == address(this), 'CeloMultiSig: only this contract can call this function');
  _;
}

This modifier checks that msg.sender is the contract itself. This is not what we want in our constructor function, as we want to allow anyone to call this function. There are different ways to solve this issue. We could either paste the logic of the changeThreshold function in the constructor function or create a private _changeThreshold function that does not use the onlyThis modifier. Let’s go with the second option.

However, we must not simply remove the onlyThis modifier to fix the issue. Doing so would allow anyone to call the changeThreshold function, which is not what we want. Instead, we want to allow anyone to call the constructor function but not the changeThreshold function.

To achieve this, we can replace the changeThreshold function with the following code:

function _changeThreshold(uint16 newThreshold) private {
  require(newThreshold > 0, 'CeloMultiSig: threshold must be greater than 0');
  require(newThreshold <= _ownerCount, 'CeloMultiSig: threshold must be less than or equal to owner count');
  _threshold = newThreshold;
}

/// @notice Changes the threshold
/// @param newThreshold The new threshold.
/// @dev This function can only be called inside a multisig transaction.
function changeThreshold(uint16 newThreshold) public onlyThis {
    _changeThreshold(newThreshold);
}

The result should look like this:

Now, in the constructor function, we can call the _changeThreshold function instead of the changeThreshold function. Therefore, we should replace the changeThreshold function by changing the last line of the constructor function with the following code:

_changeThreshold(threshold_);

The constructor function should now look like this:

Run the tests again

Now that we have fixed the issue, we can run the tests again using the following command:

npx hardhat test

Doing so, we should have the following result:

image

Add more read functions tests

Now let’s add more tests to our test file. We’ll go back to the test/CeloMultiSig.test.js file and add the following code:

it("Contract return correct threshold", async function () {
  expect(await contract.threshold()).to.be.equal(2);
});

it("Contract return correct ownerCount", async function () {
  expect(await contract.ownerCount()).to.be.equal(ownerCount);
});

it("Contract return correct nonce", async function () {
  expect(await contract.nonce()).to.be.equal(0);
});

it("Contract return true when calling isOwner for the original owners addresses", async function () {
  expect(await contract.isOwner(owner01.address)).to.be.true;
  expect(await contract.isOwner(owner02.address)).to.be.true;
  expect(await contract.isOwner(owner03.address)).to.be.true;
});

it("Contract return false when calling isOwner for non owners addresses", async function () {
  expect(await contract.isOwner(user01.address)).to.be.false;
  expect(await contract.isOwner(user02.address)).to.be.false;
  expect(await contract.isOwner(user03.address)).to.be.false;
});

The result should look like this:

These tests are simple but important. They are testing the read functions of our contract. We are testing that the contract returns the correct threshold, the correct ownerCount, the correct nonce, and that the contract returns the correct value when calling the isOwner function for the original owners’ addresses and for non-owners’ addresses.

We can now run the tests again using the following command:

npx hardhat test

Doing so, we should have the following result:

image

Add the first write function test

Now let’s add the first test for a write function. We’ll go back to the test/CeloMultiSig.test.js file and add the following code:

it("Can add a new owner", async function () {
  await Helper.test.addOwner(
    contract,
    owner01,
    [owner01, owner02, owner03],
    user01.address,
    undefined,
    undefined,
    ["OwnerAdded"]
  );
});

it("Can add a new owner and then use it to sign a new transaction replaceOwner", async function () {
  await Helper.test.addOwner(
    contract,
    owner01,
    [owner01, owner02, owner03],
    user01.address,
    undefined,
    undefined,
    ["OwnerAdded"]
  );
  await Helper.test.replaceOwner(
    contract,
    owner01,
    [user01, owner02, owner03],
    user02.address,
    owner01.address,
    undefined,
    undefined,
    ["OwnerRemoved", "OwnerAdded"]
  );
});

it("Can add a new owner and then use it to sign a new transaction changeThreshold", async function () {
  await Helper.test.addOwner(
    contract,
    owner01,
    [owner01, owner02, owner03],
    user01.address,
    undefined,
    undefined,
    ["OwnerAdded"]
  );
  await Helper.test.changeThreshold(
    contract,
    owner01,
    [user01, owner02, owner03],
    3,
    undefined,
    undefined,
    ["ThresholdChanged"]
  );
});

it("Can add a new owner and then use it to sign a new transaction removeOwner", async function () {
  await Helper.test.addOwner(
    contract,
    owner01,
    [owner01, owner02, owner03],
    user01.address,
    undefined,
    undefined,
    ["OwnerAdded"]
  );
  await Helper.test.removeOwner(
    contract,
    owner01,
    [user01, owner02, owner03],
    owner01.address,
    undefined,
    undefined,
    ["OwnerRemoved"]
  );
});

The result should look like this:

npx hardhat test

Doing so, we should have the following result:

image

Add tests expecting failures

Time to add some tests expecting the transaction to fail. We’ll go back to the test/CeloMultiSig.test.js file and add the following code:

it("Cannot add a new owner with just 10k gas", async function () {
  await Helper.test.addOwner(
    contract,
    owner01,
    [owner01, owner02, owner03],
    user01.address,
    10000,
    Helper.errors.NOT_ENOUGH_GAS
  );
});

it("Cannot add a new owner with 3x the signature of owner01", async function () {
  await Helper.test.addOwner(
    contract,
    owner01,
    [owner01, owner01, owner01],
    user01.address,
    30000,
    Helper.errors.OWNER_ALREADY_SIGNED
  );
});

it("Cannot remove all owners", async function () {
  await Helper.test.removeOwner(
    contract,
    owner01,
    [owner02, owner03],
    owner01.address,
    undefined,
    undefined,
    ["OwnerRemoved"]
  );
  await Helper.test.removeOwner(
    contract,
    owner02,
    [owner02, owner03],
    owner03.address,
    undefined,
    Helper.errors.OWNER_COUNT_BELOW_THRESHOLD,
    ["TransactionFailed"]
  );
  await Helper.test.removeOwner(
    contract,
    owner03,
    [owner02, owner03],
    owner02.address,
    undefined,
    Helper.errors.OWNER_COUNT_BELOW_THRESHOLD,
    ["TransactionFailed"]
  );
});

it("Cannot reuse a signature", async function () {
  const data = contract.interface.encodeFunctionData("addOwner(address)", [
    user02.address,
  ]);
  const signatures = await Helper.test.prepareSignatures(
    contract,
    [owner01, owner02],
    contract.address,
    0,
    data
  );
  await Helper.test.execTransaction(
    contract,
    owner01,
    [owner01, owner02],
    contract.address,
    0,
    data,
    30000,
    undefined,
    ["OwnerAdded"],
    signatures
  );
  await Helper.test.execTransaction(
    contract,
    owner01,
    [owner01, owner02],
    contract.address,
    0,
    data,
    30000,
    Helper.errors.INVALID_OWNER,
    undefined,
    signatures
  );
});

The result should look like this:

You can see that in these tests, we use the errors object from the Helper object to pass to our test helper function the error message we expect to be thrown by the contract.

We can now run the tests again using the following command:

npx hardhat test

Doing so, we should have the following result:

image

Add tests of regular ethereum transactions

Let’s add a few tests of regular Ethereum transactions. We’ll go back to the test/CeloMultiSig.test.js file and add the following code:

it("Execute transaction without data but 1 ETH in value", async function () {
  await owner01.sendTransaction({
    to: contract.address,
    value: ethers.utils.parseEther("1"),
    data: "",
    gasLimit: 30000,
  });
  await Helper.test.execTransaction(
    contract,
    owner01,
    [owner01, owner02, owner03],
    owner01.address,
    ethers.utils.parseEther("1"),
    "0x",
    30000
  );
});

it("Execute transaction without data but 2x 1 ETH in value", async function () {
  await owner01.sendTransaction({
    to: contract.address,
    value: ethers.utils.parseEther("2"),
    data: "",
    gasLimit: 30000,
  });
  await Helper.test.execTransaction(
    contract,
    owner01,
    [owner01, owner02, owner03],
    owner01.address,
    ethers.utils.parseEther("1"),
    "0x",
    30000
  );
  await Helper.test.execTransaction(
    contract,
    owner02,
    [owner01, owner02, owner03],
    owner01.address,
    ethers.utils.parseEther("1"),
    "0x",
    30000
  );
});

The result should look like this:

We can now run the tests again using the following command:

npx hardhat test

Doing so, we should have the following result:

As you can see, we have our second test failing. We were able to execute transactions that called our contract functions, but we can’t send Ethereum to our contract successfully. This is because our multisignature contract does not have a receive function.

Add a receive Function

Let’s fix this issue by adding a receive function. We’ll go back to the contracts/CeloMultiSig.sol file and add the following code at the end of the contract:

  /// @notice Receives Ether
  receive() external payable {}

The result should look like this:

image

We can now run the tests again using the following command:

npx hardhat test

Doing so, we should have the following result:

image

As you can see, all our tests are now passing. So far, we have tested all our contract functions and tested that we can use this multisignature contract to send Ethereum to another address. However, one thing we have not tested yet is that we can use this contract to interact with other contracts.

Add tests of contract interactions

To test that we can use this contract to interact with other contracts, we will use the Hardhat-Awesome-CLI to add a couple of mock contracts to our project. So, run the following command:

npx hardhat cli

Doing so, you should see the following menu:

image

Press 7 time on the down arrow key to select the Create Mock contracts option:

image

Then press enter. You should see the following menu:

image

Now select the MockERC20 option by pressing enter. You should see the following menu:

image

Now select no by pressing the down arrow and enter. You should see the following menu:

image

Now select yes by pressing the down arrow and enter. After a few seconds, you should see one new file in the contracts folder and one new file in the test folder:

Let’s organize these new files better, let’s add the MockERC20.sol contract in the contracts/mocks folder and the MockERC20.test.js test file in the test/mocks folder. So go back to the contracts folder and create a new folder named mocks and move the MockERC20.sol contract in it. Then go back to the test folder and create a new folder named mocks and move the MockERC20.test.js test file in it. The result should look like this:

Now if we run the tests again using the following command:

npx hardhat test

Doing so, we should have the following result:

image

We see that there is more tests for our mock ERC20 contract (a basic token contract), our next step is to add test in our CeloMultiSig.test.js file to test that we can deploy the Mock ERC20 contract, then use our multi-signatures contract to interact with it.
Let’s open first the test/mocks/MockERC20.test.js file to copy the deployment code of the Mock ERC20 contract:

const MockERC20 = await ethers.getContractFactory("MockERC20");
mockERC20 = await MockERC20.deploy();
await mockERC20.deployed();

This is the code that deploys the mock ERC20 contract, so let’s go back to the test/CeloMultiSig.test.js file and add the following code at the end of the file:

it("Deploy MockERC20 contract and mint token using the multi-signatures", async function () {
  const MockERC20 = await ethers.getContractFactory("MockERC20");
  mockERC20 = await MockERC20.deploy();
  await mockERC20.deployed();

  const data = mockERC20.interface.encodeFunctionData("mint(address,uint256)", [
    user02.address,
    1000000,
  ]);
  const signatures = await Helper.test.prepareSignatures(
    contract,
    [owner01, owner02, owner03],
    mockERC20.address,
    0,
    data,
    50000
  );
  await Helper.test.execTransaction(
    contract,
    owner01,
    [owner01, owner02, owner03],
    mockERC20.address,
    0,
    data,
    50000,
    undefined,
    ["Transfer"],
    signatures
  );
  expect(await mockERC20.balanceOf(user02.address)).to.equal(1000000);
});

it("Deploy MockERC20 contract and mint token using the multi-signatures then burn them using the multi-signatures", async function () {
  const MockERC20 = await ethers.getContractFactory("MockERC20");
  mockERC20 = await MockERC20.deploy();
  await mockERC20.deployed();

  const data1 = mockERC20.interface.encodeFunctionData(
    "mint(address,uint256)",
    [contract.address, 1000000]
  );
  const signatures1 = await Helper.test.prepareSignatures(
    contract,
    [owner01, owner02, owner03],
    mockERC20.address,
    0,
    data1,
    50000
  );
  await Helper.test.execTransaction(
    contract,
    owner01,
    [owner01, owner02, owner03],
    mockERC20.address,
    0,
    data1,
    50000,
    undefined,
    ["Transfer"],
    signatures1
  );
  expect(await mockERC20.balanceOf(contract.address)).to.equal(1000000);

  const data2 = mockERC20.interface.encodeFunctionData("burn(uint256)", [
    1000000,
  ]);
  const signatures2 = await Helper.test.prepareSignatures(
    contract,
    [owner01, owner02, owner03],
    mockERC20.address,
    0,
    data2,
    50000
  );
  await Helper.test.execTransaction(
    contract,
    owner01,
    [owner01, owner02, owner03],
    mockERC20.address,
    0,
    data2,
    50000,
    undefined,
    ["Transfer"],
    signatures2
  );
  expect(await mockERC20.balanceOf(contract.address)).to.equal(0);
});

It should look like this:

Now if we run the tests again using the following command:

npx hardhat test

Doing so, we should have the following result:

image

Perfect! We did it! We now have a multi-signature contract that can interact with other contracts and be used for regular transactions. Now let’s deploy our multi-signature contract on the Celo Alfajores Testnet.

Deploy the Multi-Signature Contract on Celo Alfajores Testnet

It’s time to deploy our multi-signature contract on the Celo Alfajores Testnet. Let’s go back to the scripts/deploy.js file and change the code to the following:

const Helper = require("../test/helper");

async function main() {
  const [, owner01, owner02, owner03] = await Helper.setupProviderAndWallets();

  const owners = [owner01.address, owner02.address, owner03.address];
  const contract = await Helper.deployContract(owners, 2);

  console.log(`Contract CeloMultiSig deployed to ${contract.address}`);
  console.log(`Owners: ${owners}`);
  console.log(`Required confirmations (threshold): 2`);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

It should look like this:

Deploy locally

Now let’s run the following command to deploy our contract:

npx hardhat run scripts/deploy.js

Doing so, we should have the following result:

Deploy on Celo Alfajores Testnet

We have now deployed our contract locally, but we want to deploy it on the Celo Alfajores Testnet. If you have set up your mnemonic phrase in the .env file, you can run the following command to deploy your contract on the Celo Alfajores Testnet:

npx hardhat run scripts/deploy.js --network celoAlfajores

By doing so, we should get the following result:

We have successfully deployed our contract on the Celo Alfajores Testnet.

Conclusion

Congratulations! You have now learned how to write tests for a multi-signature smart contract in JavaScript using Hardhat. By following this tutorial, you have gained a solid understanding of how multi-signature contracts work and how to use them with ethers.js. You can now implement this secure form of contract ownership protection in your own projects.

Don’t forget that you can view, clone, or fork the full code of this tutorial on GitHub at Celo Multi-Signatures Hardhat. You can use this code as a reference for your own multi-signature contracts or modify it to suit your specific needs.

I hope you found this tutorial helpful in your journey towards becoming a proficient blockchain developer. If you have any questions or feedback, please do not hesitate to reach out. Thank you for reading, and happy coding!

Next steps

From here, there are multiple roads you can choose, depending if you are planning on using this multi-signature contract to secure the ownership or some access roles of your smart contract, or to secure your personal funds. As you can see from this tutorials, using a multi-signature to execute calls to other smart contracts adds a level of complexity, as you need to have the ABI of the contract you are calling, encode the call and assemble the different signature. So to make multi-signatures very flexible and user-friendly, it can be a daunting task. However, it can be pretty simple to integrate at a smaller scale in one specific project workflow, as a way to secure the ownership of a contract, and will often be a suggestion from the auditors.

In a future set of tutorials we will look at how we can do a flexible DApp to manage the multi-signatures, and make it user friendly for the owners to sign transactions.

In the meantime, I invite you to start planning yourself for this next tutorial, by starting designing the DApp we will be building. You can start by looking at the Designing a User-Friendly Celo DApp- A Beginners Guide to UI/UX.

And if you want to go one step further, you can start setting up a react project, by following the React based DApp tutorial.

References

About the author

Hi there! My name is Marc-Aurele, but please feel free to call me Mark. Thank you for taking the time to read my tutorial. I am a Web3 full-stack and Solidity engineer with a passion for blockchain technology, smart contract development, and building open-source tools.

My mission is to make it easier for developers to build decentralized applications and integrate smart contracts into their projects by creating innovative solutions and tools. I firmly believe that blockchain technology has the potential to revolutionize the way we interact online, and I am committed to contributing to its development.

With extensive expertise in frameworks such as Hardhat and Foundry for Smart Contracts development, I have built multiple open-source tools that facilitate smart contract development and make it more accessible for developers. I am always exploring new technologies and frameworks to stay ahead of the curve and bring fresh ideas to the table.

Sharing my knowledge and helping other developers is also one of my passions. I am always willing to lend a hand to those who are stuck on a blocker, and I am open to collaborating and contributing to the ecosystem. Teaching and mentoring are essential to me, and I believe that by helping others, we can all grow and advance together.

Lastly, if you want to stay updated on my latest projects, tools, and insights on blockchain technology and smart contract development, please consider following me on Twitter at @marcaureleb or connecting with me on LinkedIn at Marc-Aurele Besne. I’m always excited to connect with like-minded individuals and share knowledge and ideas. Let’s stay in touch!

3 Likes

Is the Author of this piece a Celo sage @Celo_Academy cos I’m able to tag him? This piece is wow and outstanding. I’m bookmarking this page for later. Quite helpful and inspiring.

2 Likes