The Correct Way To Write Tests For Your Smart Contracts, Using HardHat And Ethers-JS: Reentrancy Attacks

The Correct Way To Write Tests For Your Smart Contracts, Using HardHat And Ethers-JS: Reentrancy Attacks

I was reading up on a few interesting topics the other day, and I found something that fascinated me.

Reentrancy Attacks...

...one of the simplest, yet deadliest attacks anyone has ever carried out, or will ever carry out on a decentralized application.

It might be easier to understand how a reentrancy attack works if I first explain what smart contracts are (for the sake of readers who are either new to solidity, or are in the learning process, but haven't reached that point just yet).

Smart contracts are basically pieces of code (programmatic instructions) that are stored on a blockchain, and will only run when previously specified conditions are met.

It might be easier to picture a person: Me, for example, on my way to the store to buy a can of peas.

Only, instead of assistants and cashiers to help me find my beans on aisle four, to check out my purchase and pack them into shopping bags... there are robots.

Really stubborn robots.

  • The robot supposed to help me find my beans is programmed to only help me if I'm wearing an orange shirt.
  • The robot supposed to check out my purchase is programmed to only help me, if I truly am wearing an orange shirt, have picked something off the shelves, and show evidence of being able to pay for it
contract{ 
  if( person.shirt(orange) && person.purchase(true) && money.true ){ 
    help(person) 
  } 
}

You can think of the programs running these robots as smart contracts.

Oh, yeah, and... The "contracts" above, in case you hadn't noticed from the semi-made-up syntax, are dummy contracts.

Okay, but any Other Computer Program Can Do These Things...

Yes, this is true.

It is easy to wonder what makes smart contracts so different from other kinds of programs.

One of the main differences is that the conditions of these 'contracts' are readily available on the blockchain, for both parties of any transaction to see.

With these 'smart contracts' running the robots, I do not need to trust that the store owners will keep their word, and allow me to leave with my peas when I pay for them. Because I have read the 'contract', agreed to wear an orange shirt and agreed to purchase something, long before leaving my apartment.

The owners of the store can rest easy as well, knowing that their robots will never let me leave with my purchase if I refuse to wear an orange shirt or to pay for my purchase.

This is what people mean when they say smart contracts are used to run a 'trustless system'.

Smart Contracts are generally perceived to be uncrackable and unbendable to a fault. As with other kinds of computer systems and applications, however, there will always be loopholes.

This is where reentrancy attacks occur.

The logic behind this kind of attack is quite simple to grasp. An attacker trying to carry out a reentrancy attack on a system takes advantage of the system's latency in updating its state.

In other words, I as an attacker, could attack an ATM running a smart contract with bad state management, and continue to request withdrawals. Over and over again, before the system has a chance to deduct my withdrawal amount(s) from my account balance.

Although, reentrancy attacks are now considered old, they were quite prevalent until only a few years ago.

Some of the biggest attacks on decentralized applications were carried out using reentrancy attacks. some good examples include

  • The Uniswap/Lendf.me hack

  • The King of the Ether Throne Attack (2016)

  • The BatchOverflow Attack (2018)

  • The ProxyOverflow Attack (2021)

And several others.

The point is, reentrancy attacks are clever exploits on smart contracts and are an interesting topic everyone should get a read on.

Writing Smart Contract Tests

Now that we have all definitions out of the way, we should probably try to understand the tools we'll be working with.

HardHat is a development environment that provides a set of tools for developing, testing, and deploying smart contracts. Ethers-JS is a library that provides a simple and consistent interface for interacting with Ethereum networks.

To begin, we need to install and set up HardHat and Ethers-JS.

You can follow the installation instructions for ethers.js here, and for Hardhat.js here.

Once you have set up your project, you can create a test file using the Mocha testing framework, which is included in HardHat by default.

In this article, we will walk through the correct way to write tests for your smart contracts using HardHat and Ethers-JS. We will use an example smart contract for a business that accepts 0.1 ether payment from users and then sends this money to the owner of the business as soon as it collects 1 ether.

First, let's start with the smart contract code. Here is an example of a smart contract that accepts payment from users and sends it to the owner of the contract when the amount reaches 1 ether:

pragma solidity ^0.8.0;

contract PaymentCollection {
    address payable owner;
    uint256 public amountCollected;

    constructor() {
        owner = payable(msg.sender);
    }

    function makePayment() public payable {
        require(msg.value == 0.1 ether, "Payment must be 0.1 ether");
        amountCollected += msg.value;
        if (amountCollected == 1 ether) {
            sendFundsToOwner();
        }
    }

    function sendFundsToOwner() private {
        owner.transfer(amountCollected);
        amountCollected = 0;
    }
}

As you can see, the contract accepts payment from users and stores it in the amountCollected variable. When the amountCollected reaches 1 ether, the contract calls the sendFundsToOwner function, which sends the collected amount to the owner address and resets the amountCollected variable to 0.

Reentrancy Attack on our Smart Contract

Now let's discuss how a reentrancy attack can be applied to this contract.

At first glance, it may seem that this contract is secure and free from vulnerabilities, but let's take a closer look.

A reentrancy attack occurs when a malicious contract repeatedly calls back into the vulnerable contract before the first call has been completed, resulting in unexpected behavior and potentially causing the contract to behave in unintended ways.

In the PaymentCollection contract, the function sendFundsToOwner sends the collected funds to the owner's address. However, it does not update the amountCollected variable until after the transfer is complete. This creates a vulnerability that can be exploited by an attacker to execute a reentrancy attack.

To understand how this can be done, consider the following malicious contract:

pragma solidity ^0.8.0;

contract MaliciousContract {
    PaymentCollection paymentCollection;

    constructor(address payable paymentCollectionAddress) {
        paymentCollection = PaymentCollection(paymentCollectionAddress);
    }

    function attack() public payable {
        // Call the vulnerable function
        paymentCollection.makePayment{value: 0.1 ether}();

        // Re-enter the vulnerable function
        paymentCollection.makePayment{value: 0.1 ether}();
    }

    receive() external payable {
        // Do nothing
    }
}

The malicious contract MaliciousContract accepts an address of the vulnerable contract as a parameter and calls its makePayment function twice with a payment of 0.1 ether each time. Because the sendFundsToOwner function does not update the amountCollected variable until after the transfer is complete, the attacker can repeatedly call the makePayment function before the first transfer is completed. This results in the amountCollected variable being set to 0 before the funds are sent to the owner's address.

As a result, the attacker can drain the entire balance of the PaymentCollection contract by repeatedly calling the makePayment function before the first transfer is completed. This is a classic example of a reentrancy attack.

To protect against reentrancy attacks, it is essential to follow the "checks-effects-interactions" pattern. This pattern involves performing all checks and updates to the contract's state before interacting with external contracts. In the PaymentCollection contract, the amountCollected variable should be updated before transferring the funds to the owner's address, as shown below:

function sendFundsToOwner() private {
    uint256 amount = amountCollected;
    amountCollected = 0;
    owner.transfer(amount);
}

By updating the amountCollected variable before transferring the funds to the owner's address, the PaymentCollection contract is now protected against reentrancy attacks, as the contract state is updated before interacting with external contracts.

It is also worth noting that using the transfer function is generally considered safer than using send or call, as it has a built-in limit of 2,300 gas and automatically reverts in case of a failure. However, it is still important to follow best practices to ensure the contract is secure.

Where Hardhat And Ethers Come In

Testing smart contracts is an essential part of the development process, and Hardhat and Ethers are two popular tools that can be used to test smart contracts efficiently. In this section, we will write a test for the PaymentCollection contract using Hardhat and Ethers.

By now, you should have Hardhat and Ethers installed.

Next, we need to initialize a new Hardhat project by running the following command in the terminal:

npx hardhat init

This will create a new Hardhat project with a sample contract and test file. We can delete the sample contract and test file and create a new test file for our PaymentCollection contract.

In the new test file, we can write a test that simulates a reentrancy attack. The test will deploy an instance of the PaymentCollection contract, create a new instance of the malicious contract, and then call the attack function in the malicious contract to drain the balance of the PaymentCollection contract.

Once you've installed the necessary packages and have a basic hardhat project set up for you, you can create a new test file in the test/ directory.

Let's call it paymentCollection.test.js. In this file, you can use the following code to test the PaymentCollection contract:

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

describe("PaymentCollection", function () {
  let paymentCollection;
  let owner;

  beforeEach(async function () {
    // Deploy the PaymentCollection contract
    const PaymentCollection = await ethers.getContractFactory("PaymentCollection");
    paymentCollection = await PaymentCollection.deploy();
    await paymentCollection.deployed();

    // Get the owner's address
    [owner] = await ethers.getSigners();
  });

  it("should send funds to owner when amount collected is 1 ether", async function () {
    // Make payments until amount collected is 1 ether
    await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
    await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
    await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
    await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
    await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
    await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
    await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
    await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });

    // Check that the amount collected is 1 ether
    expect(await paymentCollection.amountCollected()).to.equal(ethers.utils.parseEther("1"));

    // Send funds to owner
    await paymentCollection.sendFundsToOwner();

    // Check that the owner received the funds
    const balance = await ethers.provider.getBalance(owner.address);
    expect(balance).to.equal(ethers.utils.parseEther("1"));

    // Check that the amount collected is 0
    expect(await paymentCollection.amountCollected()).to.equal(0);
  });
});

Let's break all of this down step by step, shall we?

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

These lines import the necessary dependencies: expect from the Chai assertion library and ethers from the Hardhat Ethers plugin.

describe("PaymentCollection", function () {
  let paymentCollection;
  let owner;

  beforeEach(async function () {
    // Deploy the PaymentCollection contract
    const PaymentCollection = await ethers.getContractFactory("PaymentCollection");
    paymentCollection = await PaymentCollection.deploy();
    await paymentCollection.deployed();

    // Get the owner's address
    [owner] = await ethers.getSigners();
  });

This creates a test suite using describe to group the tests for the PaymentCollection contract. The beforeEach function runs before each test and performs two actions: it deploys a new instance of the PaymentCollection contract and it retrieves the address of the contract owner.

it("should send funds to owner when amount collected is 1 ether", async function () {
    // Make payments until amount collected is 1 ether
    await paymentCollection.makePayment({ value: ethers.utils.parseEther("0.1") });
    // ...

This is the first test, which checks that funds are sent to the owner when the amount collected is 1 ether. It uses the makePayment function to make eight payments of 0.1 ether each, which adds up to 0.8 ether. Then, it checks that the amountCollected variable is equal to 1 ether by calling await paymentCollection.amountCollected(). If the condition is true, it sends the collected funds to the owner by calling the sendFundsToOwner function.

// Check that the owner received the funds
    const balance = await ethers.provider.getBalance(owner.address);
    expect(balance).to.equal(ethers.utils.parseEther("1"));

    // Check that the amount collected is 0
    expect(await paymentCollection.amountCollected()).to.equal(0);
  });

This part of the test checks that the owner has received the funds by retrieving the owner's balance using await ethers.provider.getBalance(owner.address) and comparing it to 1 ether. Then, it checks that the amountCollected variable has been reset to zero.

Do you see how useful Hardhat and ethers are for testing smart contracts?

In essence, tests are important when it comes to smart contract development, in that they:

  1. Ensure functionality

  2. Prevent attacks

  3. Enhance code quality

  4. and Encourage collaboration

Overall, writing tests is an essential part of smart contract development that helps ensure the smart contract functions as intended, is secure and is of high quality.

In all, I hope I have been able to show you a few tips and tricks about how to use hardhat and ethers, how to write effective tests for your smart contracts, and how important smart contract testing when it comes to ensuring security and scalability.