A Solidity Symphony: Testing Events with Foundry

post image

To Begin…

In the world of blockchain, transaction execution and onchain persistent storage modifications incur a fundamentally important cost. One way to circumvent the costs of onchain interactions for data retrieval is through the observance of emitted events. Solidity events allow for a generally free and persistent way of capturing onchain activities. Given the potential dependency of events by offchain parties, careful orchestration and diligent testing of event emissions becomes a necessity to ensure the robustness of our smart contracts.

In this example, we’ll conduct both a trivial and non-trivial approaches (Part 1 and Part 2 respectively) to testing emitted events with Foundry. The trivial approach will be with a direct function call and Foundry’s vm.expecteEmit() cheatcode. The non-trivial approach will be with Solidity’s low-level call method and Foundry’s vm.recordLogs() and vm.getRecordedLogs() cheatcodes. We’ll be using our simple (and unsecure) PiggyBank contract for this test.

There are also bonus subsections that cover analyzing Foundry’s stack trace printouts provided by verbose test runs.

Please note: This article will assume you have basic knowledge of the Solidity language, events, and Foundry testing. If you need a refresher on events please read through the official Solidity documentation.

Okay. Let’s begin!


The Events

We’ll start by taking a first look at the events.

contract PiggyBankEvents {
    event Deposited(address indexed from, address indexed to, uint256 amount);
    event Withdrawn(address indexed to, uint256 amount);
}

For the Deposited event we have two variables that make up our topics:

  • from: the sender account

  • to: the receiver account

And one variable that makes up our data:

  • amount: the amount to deposit

For the Withdrawn event we have one variable that makes up our topics:

  • to: the receiver account

And one variable that makes up our data:

  • amount: the amount to withdraw

The Contract

pragma solidity 0.8.20;

contract PiggyBank is PiggyBankEvents {
    uint256 public totalBalance;
    mapping(address account => uint256) public balances;

    function deposit(address _account) external payable {
        require(msg.value != 0, "invalid deposit");

        // Increment record
        totalBalance += msg.value;
        balances[_account] += msg.value;

        // Emit event
        emit Deposited(msg.sender, _account, msg.value);
    }

    function withdraw(uint256 _amount) external {
        require(balances[msg.sender] >= _amount, "balance too low");

        // Decrement record
        totalBalance -= _amount;
        balances[msg.sender] -= _amount;

        payable(msg.sender).transfer(_amount);

        // Emit event
        emit Withdrawn(msg.sender, _amount);
    }
}

Here is our PiggyBank contract. In summary, it allows for deposits and withdraws of ether. Please note that this is not a secure contract and is purely for demo purposes.

For this contract, the important takeaways are:

  1. The inheritance of the PiggyBankEvents contract.

  2. The Deposited event emission within the deposit() function.

  3. The Withdrawn event emission within the withdraw() function.


The Test: Part 1

We’re going to start off somewhat backwards. We’re going to begin by testing the Withdrawn event. You’ll see why when we get to the Deposited event test section (Part 2).

Here we go 🌪️…

Below is the portion of our Foundry test for validating withdrawals. Please note 📝 this test contract also inherits the PiggyBankEvents contract which gives it access to the same events as our PiggyBank contract.

pragma solidity 0.8.20;

import {Test, Vm} from "forge-std/Test.sol";

contract PiggyBankTest is Test, PiggyBankEvents {
    function testPiggyBank_Withdraw() public {
        // Create PiggyBank contract
        PiggyBank piggyBank = new PiggyBank();
        uint256 _amount = 1000;

        // Deposit
        vm.deal(msg.sender, _amount);
        vm.startPrank(msg.sender);
        (bool _success, ) = address(piggyBank).call{value: _amount}(
            abi.encodeWithSignature("deposit(address)", msg.sender)
        );
        assertTrue(_success, "deposited payment.");
        vm.stopPrank();

        // Set withdraw event expectations
        vm.expectEmit(true, false, false, true, address(piggyBank));
        emit Withdrawn(msg.sender, 1000);

        // Withdraw
        vm.startPrank(msg.sender);
        piggyBank.withdraw(_amount);
        vm.stopPrank();
    }

    ...
}

Starting from the top we can see we’re creating a new instance of our PiggyBank contract and setting the _amount value, which will be the amount we are depositing and withdrawing.

// Create PiggyBank 
PiggyBank piggyBank = new PiggyBank();
uint256 _amount = 1000;

Next, we are conducting a deposit with an ether transfer.

// Deposit
vm.deal(msg.sender, _amount);
vm.startPrank(msg.sender);
(bool _success, ) = address(piggyBank).call{value: _amount}(
    abi.encodeWithSignature("deposit(address)", msg.sender)
);
assertTrue(_success, "deposited payment.");
vm.stopPrank();

Notice here that we are only verifying the success of the transaction with assertTrue(_success, ...). There is no check for the Deposited event emission. Just hold onto that 🤔.

Continuing on, we’ll conduct our withdrawal of our funds using our contact’s withdraw() function.

// Set withdraw event expectations
vm.expectEmit(true, false, false, true, address(piggyBank));
emit Withdrawn(msg.sender, _amount);

// Withdraw
vm.startPrank(msg.sender);
piggyBank.withdraw(_amount);
vm.stopPrank();

For this the effort, we will start by explaining the use of Foundry’s startPrank() and stopPrank() cheatcodes. As noted in the official Foundry documentation, startPrank “Sets msg.sender for all subsequent calls until stopPrank is called.”

Now for the important cheatcode 🤗

Notice the vm.expectEmit() cheatcode. We’ll break this down a bit, but also feel free to following along with Foundry’s documentation on this.

For vm.expectEmit(), the first three arguments are for the three possible topics of the event. According to the Solidity documentation, events allow us to “add the attribute indexed to up to three parameters which adds them to a special data structure known as topics instead of the data part of the log". Since our Withdrawn event is only making use of the first topic, we only want to set the first argument to true. Setting this argument to false would tell Foundry that you do not care if the actual emitted results match up.

For the fourth argument, this will represent the data portion of our emitted event. For this test we want this to match, so we will set this to true.

For the fifth argument, this will check the emitter address (i.e. the contract’s address that the event was emitted from). We will specify this as the PiggyBank contract’s address since that is where we expect the event to be emitted from.

Following the expectEmit cheatcode, we must follow it with the emission of the actual expected event from within our test:

vm.expectEmit(true, false, false, true, address(piggyBank));
emit Withdrawn(msg.sender, _amount);

Finally, we can call our withdraw() function.

// Withdraw
...
piggyBank.withdraw(_amount);
...

Using Foundry’s builtin command-line interface forge, we will run the test:

$ forge test --match-test testPiggyBank_Withdraw -vvvvv
[⠔] Compiling...
No files changed, compilation skipped

Running 1 test for test/Scratch__test.sol:PiggyBankTest
[PASS] testPiggyBank_Withdraw() (gas: 218500)
Traces:
  [218500] PiggyBankTest::testPiggyBank_Withdraw() 
    ├─ [155802]new PiggyBank@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
    │   └─ ← 778 bytes of code
    ├─ [0] VM::deal(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, 1000) 
    │   └─ ← ()
    ├─ [0] VM::startPrank(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38) 
    │   └─ ← ()
    ├─ [46612] PiggyBank::deposit{value: 1000}(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38) 
    │   ├─ emit Deposited(from: 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, to: 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, amount: 1000)
    │   └─ ← ()
    ├─ [0] VM::stopPrank() 
    │   └─ ← ()
    ├─ [0] VM::startPrank(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38) 
    │   └─ ← ()
    ├─ [0] VM::expectEmit(true, false, false, true, PiggyBank: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) 
    │   └─ ← ()
    ├─ emit Withdrawn(to: 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, amount: 1000)
    ├─ [7533] PiggyBank::withdraw(1000) 
    │   ├─ [0] 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38::fallback{value: 1000}() 
    │   │   └─ ← ()
    │   ├─ emit Withdrawn(to: 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, amount: 1000)
    │   └─ ← ()
    ├─ [0] VM::stopPrank() 
    │   └─ ← ()
    └─ ← ()

Okay. That’s a lot to tell us we had a successful test. Let’s break this down some more!

Starting at the top:

$ forge test --match-test testPiggyBank_Withdraw -vvvvv

This is our command which specifies we’re running a test named testPiggyBank_Withdraw with level 5 verbosity (stack traces and setup traces are always displayed). Note that testPiggyBank_Withdraw is the name of our function with the test. That is not a coincidence.

Next we have our test completion status:

[PASS] testPiggyBank_Withdraw() (gas: 218500)

For this we just care that our testPiggyBank_Withdraw() test status is [PASS]. Congratulations! We passed our first test 🥳.

Call stack analysis:

Going forward, you can skip to Part 2, or dive deeper into the stack trace 🧠.

To stay on track and remain focused on our withdraw function, we’ll focus on only the necessary lines within Foundry’s stack trace.

[218500] PiggyBankTest::testPiggyBank_Withdraw()
├─ [155802] → new PiggyBank@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
.
.
.
├─ [0] VM::expectEmit(true, false, false, true, PiggyBank: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) 
    │   └─ ← ()
    ├─ emit Withdrawn(to: 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, amount: 1000)

Starting off, we can see that the address for the PiggyBank contract is 0x5615...b72f. Below, we notice our test is specifying the event to watch for within the expectEmit cheatcode. We see the expected values for to as 0x1804...1f38 and 1000 for amount, and we also see the PiggyBank address matches the address above.

Next is the call to our withdraw() function:

├─ [7533] PiggyBank::withdraw(1000) 
    │   ├─ [0] 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38::fallback{value: 1000}() 
    │   │   └─ ← ()
    │   ├─ emit Withdrawn(to: 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, amount: 1000)
    │   └─ ← ()

On the second line we see the actual transfer to account 0x1804...1f38. This is followed by the emission of the Withdrawn event with the matching values for to as 0x1804...1f38 and 1000 for amount. And that’s it. We’ve verified through the stack trace what Foundry’s forge has already told us 🤓.

Now we continue on to check the Deposited event’s emission.


The Test: Part 2

Alright, we’ve made it this far. No turning back now!

Now we have our deposit() function that emits the Deposited event. We also see it is a payable function that is intended to be called with a msg.value that is not 0. Therefore, our deposit() test can be written as follows:


pragma solidity 0.8.20;

import {Test, Vm} from "forge-std/Test.sol";

contract PiggyBankTest is Test, PiggyBankEvents {
    ...

    address internal constant RECEIVER =
        address(uint160(uint256(keccak256("piggy bank test receiver"))));

    function testPiggyBank_Deposit() public {
        PiggyBank piggyBank = new PiggyBank();
        uint256 _amount = 1000;

        // Start recording all emitted events
        vm.recordLogs();

        // Deposit
        vm.deal(msg.sender, _amount);
        vm.startPrank(msg.sender);
        (bool _success, ) = address(piggyBank).call{value: _amount}(
            abi.encodeWithSignature("deposit(address)", RECEIVER)
        );
        vm.stopPrank();

        assertTrue(_success, "deposited payment failure.");

        // Consume the recorded logs
        Vm.Log[] memory entries = vm.getRecordedLogs();

        // Check logs
        bytes32 deposited_event_signature = keccak256(
            "Deposited(address,address,uint256)"
        );

        for (uint256 i; i < entries.length; i++) {
            if (entries[i].topics[0] == deposited_event_signature) {
                assertEq(
                    address(uint160(uint256((entries[i].topics[1])))),
                    msg.sender,
                    "emitted sender mismatch."
                );
                assertEq(
                    address(uint160(uint256((entries[i].topics[2])))),
                    RECEIVER,
                    "emitted receiver mismatch."
                );
                assertEq(
                    abi.decode(entries[i].data, (uint256)),
                    _amount,
                    "emitted amount mismatch."
                );
                assertEq(
                    entries[i].emitter,
                    address(piggyBank),
                    "emitter contract mismatch."
                );

                break;
            }

            if (i == entries.length - 1)
                fail("emitted deposited event not found.");
        }
    }
}

Like before, we’ll start at the top. The first noticeable line is the creation of a generic receiver:

address internal constant RECEIVER =
        address(uint160(uint256(keccak256("piggy bank test receiver"))));

Overall, there’s not much to take away from this line 🐒. It’s simply creating our receiver address.

Next, you’ll notice familiar lines for contract creation and the variable storing the amount for transfer:

PiggyBank piggyBank = new PiggyBank();
int256 _amount = 1000;

For the following lines, take a look at the differences and similarities from our previous test with the Withdrawn event. I want to point out two specific important differences.

  1. We are not using the vm.expectEmit() cheatcode nor are we emitting the expected event from within our test contract.

  2. Instead of invoking the PiggyBank contract’s deposit() function directly, we are using the low-level call function to send ether to the payable function.

Now let’s proceed down the test function with one of the most important lines within the test:

// Start recording all emitted events
vm.recordLogs();

This line uses Foundry’s vm.recordLogs() cheatcode to initiate recording of all emitted events. This will help us later to assess the Withdrawn event emitted by the low-level call of the deposit() function.

Now we can perform the actual deposit:

// Deposit
vm.deal(msg.sender, _amount);
vm.startPrank(msg.sender);
(bool _success, ) = address(piggyBank).call{value: _amount}(
    abi.encodeWithSignature("deposit(address)", RECEIVER)
);
vm.stopPrank();

assertTrue(_success, "deposited payment.");

Here, we introduce the vm.deal() cheatcode. This cheatcode allows us to fund the msg.sender account with ether 💸.

We won’t go into vm.startPrank() and vm.stopPrank() given we covered them in Part 1.

For the low-level call of the deposit function, we can see that we are sending _amount ether to RECEIVER.

And finally, we have a quick check to make sure the call was successful.

Now! Onto the good stuff 🤩!

// Consume the recorded logs
Vm.Log[] memory entries = vm.getRecordedLogs();

Let’s break this down a bit. If we navigate into Foundry’s contracts, we’ll find the VmSafe interface within Vm.sol. Here we can see the makeup of the Log struct:

interface VmSafe {
    struct Log {
        bytes32[] topics;
        bytes data;
        address emitter;
    }
...
}

We’ll these look familiar! So it looks like we have fields for the event topics, data, and emitter contract. As we continue on the same line, we can see that the vm.getRecordedLogs() is called to consume the recorded logs. Thank you vm.recordLogs() 🤜🏽🤛🏾! Later we will take a deep dive into what this looks like with Foundry’s call stack analysis.

Now onto the actual verification:

// Check logs
bytes32 deposited_event_signature = keccak256(
    "Deposited(address,address,uint256)"
);

for (uint256 i; i < entries.length; i++) {
    if (entries[i].topics[0] == deposited_event_signature) {
        assertEq(
            address(uint160(uint256((entries[i].topics[1])))),
            msg.sender,
            "emitted sender mismatch."
        );
        assertEq(
            address(uint160(uint256((entries[i].topics[2])))),
            RECEIVER,
            "emitted receiver mismatch."
        );
        assertEq(
            abi.decode(entries[i].data, (uint256)),
            _amount,
            "emitted amount mismatch."
        );
        assertEq(
            entries[i].emitter,
            address(piggyBank),
            "emitter contract mismatch."
        );

        break;
    }

    if (i == entries.length - 1)
        fail("emitted deposited event not found.");
    }
}

There’s a bit more to this section, but I promise it’s not bad.

The first line creates our Deposited event signature, which will be used for identifying our event from within the entries struct array.

Next we have a loop. In all honestly, this is not needed, but I wanted to include it to show you how you can use this with more complex implementations where multiple events are triggered within a single call. If the loop bothers you, pretend there is no loop and every i is 0.

The following conditional checks to see if our entries[i].topics[0] matches our event’s signature. I know what you’re thinking 💡. Exactly! “That’s what Foundry’s documentation of the vm.expectEmit() cheatcode means by ‘Topic 0 is always checked’” 🤯. It only makes sense that Foundry’s vm.expectEmit() would always check that the event emitted by the contract is the same as the event emitted within the test contract.

So assuming our topic 0 matches our Deposited signature, we’ll continue down to comparing topics 1 and 2. Remember that topics 1 and 2 are addresses. We also can see from the Log struct that entries[i].topics is a bytes32 array. Therefore, we need to convert the bytes32 values of topics 1 and 2 to addresses, as shown in the example.

If we had an additional topic (i.e. topic 3), we would simply access it with entries[i].topics[3].

Now for the data field. As we saw above, the entries[i].data is a byte array. Given we know the data type expected is a uint256, as seen within the declaration of the Deposit event, we can simply use solidity’s builtin decode method to parse the amount value from the data field.

abi.decode(entries[i].data, (uint256))

If there were more data fields, we would have to handle this line a bit differently.

The next line simply breaks the loop and the last line is a sanity check that should never execute. The last line fails the test should no Deposited event have been emitted.

Alright! Time to actually test this!

$ forge test --match-test testPiggyBank_Deposit -vvvvv
[⠃] Compiling...
No files changed, compilation skipped

Running 1 test for test/Scratch__test.sol:PiggyBankTest
[PASS] testPiggyBank_Deposit() (gas: 263253)
Traces:
  [263253] PiggyBankTest::testPiggyBank_Deposit() 
    ├─ [169214]new PiggyBank@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
    │   └─ ← 845 bytes of code
    ├─ [0] VM::recordLogs() 
    │   └─ ← ()
    ├─ [0] VM::deal(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, 1000) 
    │   └─ ← ()
    ├─ [0] VM::startPrank(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38) 
    │   └─ ← ()
    ├─ [46634] PiggyBank::deposit{value: 1000}(0xfb64bE75D69E2850c43758e8a2684031f753204c) 
    │   ├─ emit Deposited(from: 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, to: 0xfb64bE75D69E2850c43758e8a2684031f753204c, amount: 1000)
    │   └─ ← ()
    ├─ [0] VM::stopPrank() 
    │   └─ ← ()
    ├─ [0] VM::getRecordedLogs() 
    │   └─ ← [([0x8752a472e571a816aea92eec8dae9baf628e840f4929fbcc2d155e6233ff68a7, 0x0000000000000000000000001804c8ab1f12e6bbf3894d4083f33e07309d1f38, 0x000000000000000000000000fb64be75d69e2850c43758e8a2684031f753204c], 0x00000000000000000000000000000000000000000000000000000000000003e8)]
    └─ ← ()

I hope this looks familiar. We have our command for specifying our test named testPiggyBank_Deposit with level 5 verbosity (stack traces and setup traces are always displayed).

$ forge test --match-test testPiggyBank_Deposit -vvvvv

Next we have our test completion status:

[PASS] testPiggyBank_Deposit() (gas: 263253)

This tells us our test status was [PASS]🥳.

Call stack analysis:

Now onto the good stuff 👩🏿‍💻!

[263253] PiggyBankTest::testPiggyBank_Deposit() 
    ├─ [169214] → new PiggyBank@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
.
.
.
├─ [0] VM::recordLogs()
├─ [0] VM::startPrank(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38) 
    │   └─ ← ()

Here we can see that the address for the PiggyBank contract is 0x5615...b72f. Below, we notice our test is NOT specifying the event to watch for within the expectEmit cheatcode. This should make sense.

Then we have our call to start event log recording and our setting of 0x1804...1f38 as our active msg.sender.

Next is the low-level call to our deposit() function:

├─ [46634] PiggyBank::deposit{value: 1000}(0xfb64bE75D69E2850c43758e8a2684031f753204c) 
    │   ├─ emit Deposited(from: 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, to: 0xfb64bE75D69E2850c43758e8a2684031f753204c, amount: 1000)

At the low-level call, we can see that our msg.value is 1000 and our to address is 0xfb64...204c.

Then we can see our event being emitted 🧃. From here, it’s clear that our from matches the expected sender, to matches the expected receiver, and amount matches the expected amount.

We’ll that’s a warm and fuzzy!

Now onto the nitty-gritty, our collected log.

├─ [0] VM::getRecordedLogs() 
    │   └─ ← [([0x8752a472e571a816aea92eec8dae9baf628e840f4929fbcc2d155e6233ff68a7, 0x0000000000000000000000001804c8ab1f12e6bbf3894d4083f33e07309d1f38, 0x000000000000000000000000fb64be75d69e2850c43758e8a2684031f753204c], 0x00000000000000000000000000000000000000000000000000000000000003e8)]
    └─ ← ()

I hope this line makes it clear on how multiple events would be packaged within this array.

Moving inward, we’ll take a look at the first topic within the first and only Log struct of the array.

Let’s verify this on the fly with another great Foundry tool, cast:

$ cast keccak "Deposited(address,address,uint256)"
0x8752a472e571a816aea92eec8dae9baf628e840f4929fbcc2d155e6233ff68a7

Well, that looks like a match!

Next we’ll take a look at the next two topics:

Those two look like exact matches to our expected from and to addresses respectively. Of course they are padded out to bytes32, hence the address(uint160(uint256(...) conversions.

The next and final verification is done on the data portion of the recorded logs. Given we only have one value within the data field, this is relatively trivial:

And the decimal conversion of 0x03e8 is the expected 1000!


Finale!

Wow! Congrats to you and me if you’re still here. That was a quick run through of event testing with Foundry. If you have any suggestions, comments, or requests for clarification, please do reach out.

If this made you want to throw money into a mostly empty pocket, please feel free to toss it here: 0x0b1928F5EbCFF7d9d2c8d72c608479d27117b14D.

If you’re a LinkedIn connection master, please reach out to me on LinkedIn with a note from this article if you’d like to connect.

Thank you for taking time out of your day to read my work and I hope it was fun.