Open Source: Contributing to Foundry
How I contributed to Ethereum's development toolkit
Table of Contents
Contributing to open source projects can be a bit scary sometimes đ»Â But itâs usually not that hard!
I just made my first (really small) contribution to Foundry (a toolkit to help develop smart contracts for Ethereum) today, and I really enjoyed it! đ
While tackling the issue I took some notes, so if youâre thinking about contributing, Iâm going to walk you through what I did and hopefully youâll see that itâs pretty easy. Obviously the bug I fixed was really simple but it helped me get into the code and Iâm ready to take on some more challenging ones đȘ
It all started with this issue: https://github.com/foundry-rs/foundry/issues/4434
There was a bug in my forge script⊠I didnât have time to look into it that day, so I left the issue opened for a few days, and today I finally took the time to do it. It took me about 3 hours from the moment I cloned the repo to the moment I opened the PR.
Setup#
Foundry repo#
First thing I did after cloning the repo was heading to the dev doc for help.
Since my PR was specifically related to forge script I thought I would only install forge locally with cargo build -p ./forge --profile local.
And I ran the tests: cargo test -p ./forge --profile local
I didnât dig into it, but it seems like adding the local profile flag makes the build take longer, so in the end I just went with cargo build and built the entire project.
I now have the forge binary in foundry/target/debug/forge
And then I ran this command so that it would rebuild automatically when i make a change
cargo watch -x "build"
On every change, the build takes about 15-20 seconds on my Macbook Pro M1
Reproduce the error#
I then created another directory where I setup my project for testing with the problematic script and I linked the newly built forge inside this repo with
$ ln -s ../foundry/target/debug/forge ./myforge
The repo looks like this
|-- cache
|-- foundry.toml
|-- lib
|-- myforge -> ../foundry/target/debug/forge
|-- myscript.s.sol
`-- out
Here is myscript.s.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;
// import "forge-std/Script.sol";
import { console, Script } from "forge-std/Script.sol";
contract MyScript is Script {
function run() public {
uint256 pk = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80;
vm.startBroadcast(pk);
console.log("broadcaster ", vm.addr(pk));
console.log("script ", address(this));
console.log("origin ", tx.origin);
ContractA contractA = new ContractA();
ContractB contractB = new ContractB();
ContractC contractC1 = new ContractC();
console.log("contractA ", address(contractA));
console.log("contractB ", address(contractB));
console.log("contractC1 ", address(contractC1));
console.log("");
contractA.test(address(contractB));
contractB.method();
contractC1.method();
console.log("\n origin (end) ", tx.origin);
vm.stopBroadcast();
}
}
contract ContractA {
function test(address _contractB) public {
ContractB contractB = ContractB(_contractB);
ContractC contractC2 = new ContractC();
console.log("A (start) ", tx.origin);
console.log("A (start) sender ", msg.sender);
contractB.method();
console.log("A (after1) ", tx.origin);
console.log("A (after1) sender ", msg.sender);
contractC2.method();
console.log("A (after2) ", tx.origin);
console.log("A (after2) sender ", msg.sender);
}
}
contract ContractB {
function method() public {
console.log("B ", tx.origin);
console.log("B sender ", msg.sender);
}
}
contract ContractC {
function method() public {
console.log("C ", tx.origin);
console.log("C sender ", msg.sender);
}
}
I can then run this command to reproduce my issue
$ ./myforge script ./myscript.s.sol --tc MyScript
The Foundry code#
After looking at the code, trying to understand the architecture of the repo, and reading the dev doc I understood that I need to look into evm/src/executor/inspector/cheatcodes. So I started by adding some logs to understand what was going on.
I added logs in call, call_end, create and create_end
These are some examples of what it looked like
In evm/src/executor/inspector/cheatcodes/mod.rs on line 450
fn call(
&mut self,
data: &mut EVMData<'_, DB>,
call: &mut CallInputs,
is_static: bool,
) -> (Return, Gas, Bytes) {
...
if let Some(broadcast) = &self.broadcast {
println!("call() \n{broadcast:#?}");
println!("contract: {}\ncontext: {:#?}", call.contract, call.context);
println!("env before: {:#?}", data.env.tx);
println!("\n");
...
fn create_end(
&mut self,
data: &mut EVMData<'_, DB>,
call: &CreateInputs,
status: Return,
address: Option<Address>,
remaining_gas: Gas,
retdata: Bytes,
) -> (Return, Option<Address>, Gas, Bytes) {
...
// Clean up broadcasts
if let Some(broadcast) = &self.broadcast {
println!("create_end() \n{broadcast:#?}");
println!("address: {address:?}");
println!("caller {:#?}", call.caller);
println!("env before: {:#?}", data.env.tx);
println!("depth: {}", data.journaled_state.depth());
data.env.tx.caller = broadcast.original_origin;
println!("/create_end()\n");
I quickly noticed that there was an issue with call_end and create_end which are the functions being called after a contract call and after a contract creation: it would reset the data.env.tx.caller which is the tx.origin in the Solidity context.
So the fix was pretty easy. Change
data.env.tx.caller = broadcast.original_origin;
to
if data.journaled_state.depth() == broadcast.depth {
data.env.tx.caller = broadcast.original_origin;
}
This makes sure that tx.origin will be reset only when the last call is popped from the call stack and the execution returns to the run() function of the Script.
And⊠thatâs it! Weâre done đ
Integration tests#
Now letâs add some tests.
After testing on my local setup by running forge script manually, it was time to add a proper integration test.
Tests are located in cli/tests/it/script.rs
To add a test, we use the forgetest_async! macro. I cleaned the Script that I was using and added some require statements and copied it inside my integration test.
I just need 1 line to make sure my script ran correctly:
assert!(cmd.stdout_lossy().contains("Script ran successfully."));
stdout_lossy() uses output() which, as the comment above says: âIf the command failed, then this panics.â
So the script will fail if the Script fails, or if the output doesnât contain âScript ran successfully.â
Run the test with
$ cargo test assert_tx_origin_is_not_overritten -- --show-output
or, if I want it to rebuild automatically everytime I make a change
$ cargo watch -x "test assert_tx_origin_is_not_overritten -- --show-output"
assert_tx_origin_is_not_overritten is the name I chose for the test I wrote. You can see it on the PR.
Letâs merge#
Before pushing, make sure your code is formatted correctly and doesnât have warnings.
$ cargo +nightly fmt -- --check
$ cargo +nightly clippy --all --all-features -- -D warnings
You can see the commands in the Contributing guidelines
We now need to open the PR and wait for someone to review đ
Here it is: https://github.com/foundry-rs/foundry/pull/4469
Next time you have an issue with an open source project, maybe try to contribute đ
If you have any question, or if I made a mistake somewhere: message me on Twitter 0xteddav