Testing smart contracts in Ethereum requires testing for more than just the expected return values. It is important to verify that the events that should be emitted when the contract is invoked are also occurring as expected. In this article, we’ll explore how to listen and record local Ethereum events while testing with Hardhat to ensure that our smart contracts perform as expected.

Here is a repo of the finished project.

Project Setup

Open the terminal and create a new hardhat project with npx hardhat init and follow the setup, choosing to create a ‘js project’:

Setup contract

Rename the contacts/Lock.sol file into Bank.sol, and replace the contents with a simple Solidity contract that records a users balance, emitting events when they add and withdraw money:

Solidity
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;

contract Bank {
    event moneyAdded(uint256 amount);
    event moneyWithdrawn(uint256 amount);

    mapping(address => uint) userBalance;

    function addMoney(uint256 amount) public {
        userBalance[msg.sender] += amount;
        emit moneyAdded(amount);
    }

    function withdrawMoney(uint256 amount) public {
        require(userBalance[msg.sender] >= amount, "Not enough money");
        userBalance[msg.sender] -= amount;
        emit moneyWithdrawn(amount);
    }

    function getBalance(address user) public view returns (uint) {
        return userBalance[user];
    }
}

Testing

Rename the test/Lock.js file into bank.test.js and add some basic tests to make sure our smart contract works:

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

describe("Bank", function () {

  const eth = (amount) => ethers.utils.parseEther(String(amount));

  before(async function () {
    [deployer, otherAccount] = await ethers.getSigners();
    Bank = await ethers.getContractFactory("Bank");
    bank = await Bank.deploy();
    await bank.deployed();
  });

  it('can increase users balance', async function () {
    await bank.addMoney(eth(100));
    expect(await bank.getBalance(deployer.address)).to.equal(eth(100));
  });

  it('can decrease users balance', async function () {
    await bank.withdrawMoney(eth(100));
    expect(await bank.getBalance(deployer.address)).to.equal(0);
  });

});

Note: We added a helper function called eth to shorten converting numbers to eth.

Now if you run npx hardhat test we should see two passing tests:

Listening to events

To record the events emitted by our contract and store them to read later, we can add a helper function above our ‘describe’ statement called saveEvents. It will take our transaction, wait for it to be mined and record the receipt. Then it will loop through each event in the receipt and capture the event title + the arguments:

JavaScript
const emittedEvents = [];
const saveEvents = async (tx) => {
    const receipt = await tx.wait()
    receipt.events.forEach(ev => {
        if (ev.event) {
            emittedEvents.push({
                name: ev.event,
                args: ev.args
            });
        }
    });
    console.log(`emittedEvents: `, emittedEvents);
}

To save emitted events, we need to store the transaction in a variable tx and pass that to our function.

JavaScript
const tx = await bank.addMoney(eth(100));
saveEvents(tx);

Let’s add another test to create a bunch of transactions, saving the events from each one:

JavaScript
it('can calculate user deposit history from events', async function () {
  saveEvents(await bank.addMoney(eth(20)));
  saveEvents(await bank.addMoney(eth(30)));
  saveEvents(await bank.withdrawMoney(eth(10)));
  saveEvents(await bank.addMoney(eth(50)));
  saveEvents(await bank.withdrawMoney(eth(40)));
});

Next we can loop through our saved events, to print a log of user deposits and total balance:

JavaScript
let transactionHistory = "Transaction history:\n---\n";
    let userBalance = 0;
    emittedEvents.forEach(event => {
        if (event.name === 'moneyAdded') {
            transactionHistory += `Deposited: $${parseFloat(fromEth(event.args.amount))} \n`;
            userBalance += parseFloat(fromEth(event.args.amount));
        } else if (event.name === 'moneyWithdrawn') {
            transactionHistory += `Withdrew: $${parseFloat(fromEth(event.args.amount))} \n`;
            userBalance -= parseFloat(fromEth(event.args.amount));
        }
    });
transactionHistory += `---\nTotal balance: $${userBalance}`;
console.log('\x1b[36m%s\x1b[0m', transactionHistory);

Note: We added a new function fromEth which does the reverse of eth.

Now when we run the test we get the output:

Note: To make the console text blue we used the ANSI escape code ‘\x1b[36m%s\x1b[0m’ in our console.log statement.

Our completed test file now looks like this:

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

const emittedEvents = [];
const saveEvents = async (tx) => {
    const receipt = await tx.wait()
    receipt.events.forEach(ev => {
        if (ev.event) {
            emittedEvents.push({
                name: ev.event,
                args: ev.args
            });
        }
    });
}

describe("Bank", function () {

  const eth = (amount) => ethers.utils.parseEther(String(amount));
  const fromEth = (amount) => ethers.utils.formatEther(String(amount));
  
  before(async function () {
    [deployer, otherAccount] = await ethers.getSigners();
    Bank = await ethers.getContractFactory("Bank");
    bank = await Bank.deploy();
    await bank.deployed();
  });

  it('can increase users balance', async function () {
    const tx = await bank.addMoney(eth(100));
    saveEvents(tx);
    expect(await bank.getBalance(deployer.address)).to.equal(eth(100));
  });

  it('can decrease users balance', async function () {
    const tx = await bank.withdrawMoney(eth(100));
    saveEvents(tx);
    expect(await bank.getBalance(deployer.address)).to.equal(0);
  });

  it('can calculate user deposit history from events', async function () {
    saveEvents(await bank.addMoney(eth(20)));
    saveEvents(await bank.addMoney(eth(30)));
    saveEvents(await bank.withdrawMoney(eth(10)));
    saveEvents(await bank.addMoney(eth(50)));
    saveEvents(await bank.withdrawMoney(eth(40)));

    let transactionHistory = "Transaction history:\n---\n";
    let userBalance = 0;
    emittedEvents.forEach(event => {
        if (event.name === 'moneyAdded') {
            transactionHistory += `Deposited: $${parseFloat(fromEth(event.args.amount))} \n`;
            userBalance += parseFloat(fromEth(event.args.amount));
        } else if (event.name === 'moneyWithdrawn') {
            transactionHistory += `Withdrew: $${parseFloat(fromEth(event.args.amount))} \n`;
            userBalance -= parseFloat(fromEth(event.args.amount));
        }
    });
    transactionHistory += `---\nTotal balance: $${userBalance}`;
    console.log('\x1b[36m%s\x1b[0m', transactionHistory);
  });

});

Conclusion

A major benefit of listening to and reading events is that we don’t have to spend gas reading and writing to storage on the blockchain, so we can use events as a cheap form of storage for large amounts of data.

Services such as Etherscan, Alchemy and Moralis provide an SDK to capture recorded events on the blockchain, but this is a handy way to emulate that in the testing environment.


Further Reading

Comments

Daring Calf Reply

Why did you get that eth() function?

    Mytch Reply

    Hey, I made it from the ethers parseEther function:
    const eth = (amount) => ethers.utils.parseEther(String(amount));

    So I can think in terms of normal currency but provide the wei value in a shortened form: eth(20)

Comment

Note: Comments are moderated, URLs not permitted.