In the blockchain world, transaction execution and on-chain persistent storage modifications incur a fundamentally important cost. Observing emitted events is one way to circumvent the costs of on-chain interactions for data retrieval. Solidity events allow for a generally free and persistent way of capturing on-chain activities. Given the potential dependency of events by off-chain parties, careful orchestration and diligent testing of event emissions becomes necessary to ensure our smart contracts’ robustness.
In this example, we’ll conduct trivial and non-trivial approaches (Part 1 and Part 2, respectively) to testing emitted events with Foundry. The trivial approach will involve a direct function call and Foundry’s vm.expecteEmit() cheat code. The non-trivial approach will be with Solidity’s low-level call method and Foundry’s vm.recordLogs() and vm.getRecordedLogs() cheat codes. We’ll be using our simple (and unsecure) PiggyBank contract for this test.
Bonus subsections also 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 these topics, we recommend reviewing the official Solidity documentation and the Foundry book’s Test section.
Okay. Let’s begin!
Project Config and Setup
- Code: https://github.com/jasonjgarcia24/foundry-events-testing
- Solidity version: 0.8.20
- Forge version: forge 0.2.0 (58a2729 2023–05–16T00:03:39.980639280Z)
- IDE: VS Code
To begin, we’ll create our Foundry environment by running the following command:
$ forge init
Your project’s structure and the Foundry configuration should now be similar to the below (btw, please feel free to remove that last line). The only notable difference you’ll likely see is the solc version within the foundry.toml configuration file. Please set your solc to match what I have below:
Next, remove all the Counter*.sol contracts. You can also completely remove the script directory if you like. We will not be using it here.
As previously mentioned, we will be using the PiggyBank contract within the PiggyBank.sol file. So, we’ll need to create this file within the ./src/ folder and copy the below events interface and contract into the file.
$ touch ./src/PiggyBank.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;
contract PiggyBankEvents {
event Deposited(address indexed from, address indexed to, uint256 amount);
event Withdrawn(address indexed to, uint256 amount);
}
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);
}
}
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
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:
- The inheritance of the PiggyBankEvents contract.
- The Deposited event emission within the deposit() function.
- The Withdrawn event emission within the withdraw() function.
Part 1 — Testing Withdrawn
We’re going to start somewhat backward. We’re going to begin by testing the Withdrawn event. You’ll see why when we reach 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.
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;
import {Test, Vm} from "forge-std/Test.sol";
import {PiggyBank, PiggyBankEvents} from "src/PiggyBank.sol";
contract PiggyBankTest is Test, PiggyBankEvents {
function setUp() public {
vm.label(msg.sender, "MSG_SENDER");
}
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();
}
...
}
Beginning with the setUp() function, this function is a specific optional function known to forge that is invoked before each test case’s run. In this function, you’ll notice the vm.label() Foundry cheat code️. In this use case, this helper cheat code instructs forge to use the alias MSG_SENDER in place of the address of msg.sender in test traces. This will help us later in our assessments of the stack trace printouts.
Now onto our testPiggyBank_Withdraw() test function.
// Create PiggyBank
PiggyBank piggyBank = new PiggyBank();
uint256 _amount = 1000;
Here, 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.
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 the 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 effort, we will start by explaining the use of Foundry’s vm.startPrank() and vm.stopPrank() cheat codes. As noted in the official Foundry documentation, startPrank “Sets msg.sender for all subsequent calls until stopPrank is called.”
Now for the important cheat code!
Notice the vm.expectEmit() cheat code. We’ll break this down a bit, but also feel free to follow 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.
The fourth argument will represent the data portion of our emitted event. For this test, we want this to match, so that we will set this to true.
For the fifth argument, this will check the emitter address (i.e., the contract’s address from which the event was emitted). 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 cheat code, 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 built-in command-line interface forge, we will run the test:
$ forge test --match-test testPiggyBank_Withdraw -vvvvv
[⠰] Compiling...
[⠰] Compiling 1 files with 0.8.20
[⠒] Solc 0.8.20 finished in 963.75ms
Compiler run successful!
Running 1 test for test/PiggyBank__test.sol:PiggyBankTest
[PASS] testPiggyBank_Withdraw() (gas: 231925)
Traces:
[3064] PiggyBankTest::setUp()
├─ [0] VM::label(MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], MSG_SENDER)
│ └─ ← ()
└─ ← ()
[231925] PiggyBankTest::testPiggyBank_Withdraw()
├─ [169214] → new PiggyBank@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
│ └─ ← 845 bytes of code
├─ [0] VM::deal(MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], 1000)
│ └─ ← ()
├─ [0] VM::startPrank(MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38])
│ └─ ← ()
├─ [46634] PiggyBank::deposit{value: 1000}(MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38])
│ ├─ emit Deposited(from: MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], to: MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], amount: 1000)
│ └─ ← ()
├─ [0] VM::stopPrank()
│ └─ ← ()
├─ [0] VM::expectEmit(true, false, false, true, PiggyBank: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f])
│ └─ ← ()
├─ emit Withdrawn(to: MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], amount: 1000)
├─ [0] VM::startPrank(MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38])
│ └─ ← ()
├─ [7533] PiggyBank::withdraw(1000)
│ ├─ [0] MSG_SENDER::fallback{value: 1000}()
│ │ └─ ← ()
│ ├─ emit Withdrawn(to: MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], amount: 1000)
│ └─ ← ()
├─ [0] VM::stopPrank()
│ └─ ← ()
└─ ← ()
Test result: ok. 1 passed; 0 failed; finished in 521.63µs
OK. 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: 231925)
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.
We’ll focus on only the necessary lines within Foundry’s stack trace to stay on track and remain focused on our withdraw function.
[231925] PiggyBankTest::testPiggyBank_Withdraw()
├─ [169214] → new PiggyBank@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
│ └─ ← 845 bytes of code
.
.
.
├─ [0] VM::expectEmit(true, false, false, true, PiggyBank: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f])
│ └─ ← ()
├─ emit Withdrawn(to: MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], amount: 1000)
.
.
.
Starting off, we can see that the address for the PiggyBank contract is 0x5615…b72f. Below, we notice our test specifies the event to watch for within the vm.expectEmit() cheat code. We see the expected value for to is our MSG_SENDER (0x1804…1f38) and 1000 for the expectedamount. We also can verify the PiggyBank address matches the address above.
Next is the call to our withdraw() function:
├─ [7533] PiggyBank::withdraw(1000)
│ ├─ [0] MSG_SENDER::fallback{value: 1000}()
│ │ └─ ← ()
│ ├─ emit Withdrawn(to: MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], amount: 1000)
│ └─ ← ()
On the second line, we see the actual transfer to our MSG_SENDER. This is then followed by the emission of the Withdrawn event with the matching values for to as MSG_SENDER 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 to check the Deposited event’s emission.
Part 2 — Testing Deposit
All right, 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:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;
import {Test, Vm} from "forge-std/Test.sol";
import {PiggyBank, PiggyBankEvents} from "src/PiggyBank.sol";
contract PiggyBankTest is Test, PiggyBankEvents {
address internal constant RECEIVER =
address(uint160(uint256(keccak256("piggy bank test receiver"))));
function setUp() public {
vm.label(msg.sender, "MSG_SENDER");
vm.label(RECEIVER, "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 may have noticed the introduction of a new vm.label() within our setUp() function. This label will be used to alias our account, which will be the recipient address for our withdraw() function.
function setUp() public {
vm.label(msg.sender, "MSG_SENDER");
vm.label(RECEIVER, "RECEIVER"); // NEW
}
Within our testPiggyBank_Deposit() test function, 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.
- We are not using the vm.expectEmit() cheat code nor emitting the expected event within our test contract.
- 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() cheat code to initiate the 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() cheat code. This cheat code 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 ensure 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 forge-std/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 deeply 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. Honestly, this is unnecessary, but I wanted to include it to show you how to use it 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() cheat code 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 that emitted within the test contract.
So, assuming our topic 0 matches our Deposited signature, we’ll continue 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, as shown in the example, we need to convert the bytes32 values of topics 1 and 2 to addresses.
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 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.
All right! Time to actually test this!
$ forge test --match-test testPiggyBank_Deposit -vvvvv
[⠰] Compiling...
[⠔] Compiling 1 files with 0.8.20
[⠒] Solc 0.8.20 finished in 1.01s
Compiler run successful!
Running 1 test for test/PiggyBank__test.sol:PiggyBankTest
[PASS] testPiggyBank_Deposit() (gas: 263181)
Traces:
[3466] PiggyBankTest::setUp()
├─ [0] VM::label(MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], MSG_SENDER)
│ └─ ← ()
├─ [0] VM::label(RECEIVER: [0xfb64bE75D69E2850c43758e8a2684031f753204c], RECEIVER)
│ └─ ← ()
└─ ← ()
[263181] PiggyBankTest::testPiggyBank_Deposit()
├─ [169214] → new PiggyBank@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
│ └─ ← 845 bytes of code
├─ [0] VM::recordLogs()
│ └─ ← ()
├─ [0] VM::deal(MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], 1000)
│ └─ ← ()
├─ [0] VM::startPrank(MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38])
│ └─ ← ()
├─ [46634] PiggyBank::deposit{value: 1000}(RECEIVER: [0xfb64bE75D69E2850c43758e8a2684031f753204c])
│ ├─ emit Deposited(from: MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], to: RECEIVER: [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: 263181)
This tells us our test status was [PASS].
Call stack analysis
Now onto the good stuff!
[263181] PiggyBankTest::testPiggyBank_Deposit()
├─ [169214] → new PiggyBank@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
├─ [0] VM::recordLogs()
│ └─ ← ()
├─ [0] VM::deal(MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], 1000)
│ └─ ← ()
├─ [0] VM::startPrank(MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38])
│ └─ ← ()
.
.
.
First, we notice the address for the PiggyBank contract is 0x5615…b72f. Next, we can see our initiation of event log recording with vm.recordLogs(). This is then followed by dealing 1000 ETH to MSG_SENDER and setting it as our active msg.sender.
Continuing on, we can confirm our test is NOT specifying the event to watch for with the vm.expectEmit() cheat code. This should make sense.
[263181] PiggyBankTest::testPiggyBank_Deposit()
.
.
.
├─ [46634] PiggyBank::deposit{value: 1000}(RECEIVER: [0xfb64bE75D69E2850c43758e8a2684031f753204c])
│ ├─ emit Deposited(from: MSG_SENDER: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], to: RECEIVER: [0xfb64bE75D69E2850c43758e8a2684031f753204c], amount: 1000)
│ └─ ← ()
├─ [0] VM::stopPrank()
│ └─ ← ()
├─ [0] VM::getRecordedLogs()
│ └─ ← [([0x8752a472e571a816aea92eec8dae9baf628e840f4929fbcc2d155e6233ff68a7, 0x0000000000000000000000001804c8ab1f12e6bbf3894d4083f33e07309d1f38, 0x000000000000000000000000fb64be75d69e2850c43758e8a2684031f753204c], 0x00000000000000000000000000000000000000000000000000000000000003e8)]
└─ ← ()
Here, we have the low-level call to our deposit() function where we can confirm our msg.value is 1000 and our to address is our RECEIVER (0xfb64…204c). As expected, we then notice our event being emitted 🧃. From here, it’s clear that our from matches the expected MSG_SENDER, to matches the expected RECEIVER, and amount matches the expected amount.
Well, that’s 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 how multiple events would be packaged within this array.
Moving inward, we’ll look at the first topic within the first and only Log struct of the array.
0x8752a472e571a816aea92eec8dae9baf628e840f4929fbcc2d155e6233ff68a7
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 following two topics:
0x0000000000000000000000001804c8ab1f12e6bbf3894d4083f33e07309d1f38
0x000000000000000000000000fb64be75d69e2850c43758e8a2684031f753204c
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:
0x00000000000000000000000000000000000000000000000000000000000003e8
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.
Thank you for reading, and I hope it was fun.
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.
Final Test Code
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;
import {Test, Vm} from "forge-std/Test.sol";
import {PiggyBank, PiggyBankEvents} from "src/PiggyBank.sol";
contract PiggyBankTest is Test, PiggyBankEvents {
address internal constant RECEIVER =
address(uint160(uint256(keccak256("piggy bank test receiver"))));
function setUp() public {
vm.label(msg.sender, "MSG_SENDER");
vm.label(RECEIVER, "RECEIVER");
}
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();
}
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.");
}
}
}
Sources
A Solidity Symphony: Testing Events With Foundry was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.