Ethereum development made easy with Foundry
As part of our trip to Devcon Amsterdam back in April, we attended the War Room Games Amsterdam competition, an Ethereum CTF where you "hacked" smart contracts to win points. The event was loads of fun, but we realized while playing that our main obstacle was not Ethereum/Solidity knowledge, but rather tooling. We knew how to hack most contracts, but struggled to do so because we lacked the right tools, relying a lot on manual Metamask or Remix interaction.
This prompted us to write some basic REPL-style tool to develop, deploy and interact with smart contracts on chain written in Elixir, a language we are very comfortable with. After writing its basic functionality in a weekend, we started looking for other existing tools not written in Javascript (the most well-known ones, Truffle and Hardhat, expect you to do everything in JS).
Enter Foundry, an Ethereum toolkit written in Rust. Inspired by Dapp Tools, it lets you write, run, test and deploy smart contracts, all in Solidity.
Ethereum
Before diving into Foundry, a quick recap on Ethereum. As the leading example of blockchain's second generation, Ethereum distinguishes itself most prominently from Bitcoin by running a full Virtual Machine capable of (at least in theory) running any computation. This means that it is not just a public ledger for a virtual currency where users can pay each other, but also a global public computer, capable of trustlessly executing any code.
Thus Ethereum transactions are not limited to eth
exchanges, but can be any arbitrary logic, which allowed the creation of stablecoins, NFTs, DeFi or even actual games.
For our purposes, you will need to setup an ethereum account, which you can do by downloading an Ethereum wallet like Metamask (the word "wallet" here is a bit of a misnomer, as it allows you to do more than just manage your money). Take note of your account's private key (in Metamask, "Account details" -> "Export Private Key"), as it is will be needed to send transactions to the network.
NOTE: Treat this account you just created as a throwaway to play around. In a real scenario, you should never be copy pasting your private key around, as it is what makes your wallet yours.
Foundry
Let's now dive into Foundry by going through an example. First, install it with
curl -L https://foundry.paradigm.xyz | bash
foundryup
Forge and Cast
Foundry's first and most important tool is Forge
, a complete testing framework. Let's write a very simple smart contract (taken from here) to see it in action.
Creating a project
Create a new project with
forge init storage
This will create a storage
directory with a bunch of files, the only one we care about for now is in src/Contract.sol
. Rename that file Storage.sol
and add the following code to it
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Storage {
uint256 number;
function store(uint256 num) public {
number = num;
}
function retrieve() public view returns (uint256){
return number;
}
}
The contract is self-explanatory: it stores a certain number with the store(num)
method and returns it with retrieve()
. Running
forge build
should tell you compilation was successful. We now have our contract compiled, but how do we run it? This is code that's meant to be deployed on the ethereum blockchain, to be interacted with by users who send transactions. Ideally, the tests we perform should be as close as possible to this environment. One thing we can do is deploy it to a Testnet and call it from there.
Deploying
To deploy a contract to an ethereum network, we can use the forge create
command. In our case, the easiest way to to interact with a testnet is (unfortunately) to use a provider like Infura or Alchemy. Just register with a free account and create a Goerli
testnet application, which should give you an RPC URL to interact with said testnet that looks something like this
https://eth-goerli.g.alchemy.com/v2/<API_KEY>
Set the ETH_RPC_URL
environment variable to this value to use it for all our interactions.
The last thing we need is to fund our account to pay for the transactions we send. For this, look for a faucet like this one and request funds by pasting your address (faucets are a bit annoying in that they're usually either very sketchy or require authentication). Having done all that, let's deploy our Storage
contract:
forge create Storage --private-key <your_private_key>
If everything goes well, you should see something like
Deployer: <your_address>
Deployed to: <contract_address>
Transaction hash: <transaction_hash>
Calling our contract
To interact with our deployed contract, Foundry has a tool called Cast
; it is a more mature CLI version of the elixir code we wrote mentioned at the beginning.
We can call the retrieve()
method by doing
cast call <contract_address> "retrieve()"
which should return
0x0000000000000000000000000000000000000000000000000000000000000000
Notice the result is in an awkward binary format; that's because it's ABI encoded. If we also provide the return type of the method, cast
will decode it for us:
cast call <contract_address> "retrieve()(uint256)"
0
To call the store
method, we need to use cast send
instead of call
. This is because retrieve
is a method that does not modify any blockchain state, it just reads it. On the other hand, store
does modify state, which requires sending an actual transaction
to our contract so that, when it gets included in a block, the store
method is run and the state of our variable is updated and stored in the network.
All that said, to run store
we do
cast send <contract-address> --private-key <your_private_key> "store(uint256)" 5
which, after a while, should return a transacion receipt with all the info about the transaction, and the number
variable should now be updated to be 5
. We can verify that by running again
cast call <contract_address> "retrieve()(uint256)"
5
Writing tests
We just verified that our contract worked as expected, though it was a bit cumbersome; the problem with trying out smart contracts, as opposed to more traditional development environments, is that most of the code that matters has to go through a transaction on the blockchain. This is a very slow process, so while the above works, it quickly becomes annoying as the code becomes more complex code and starts interacting with other contracts.
Forge allow us to write tests running in a simulated blockchain environment, with the ability to manipulate it to recreate any situation we want.
To keep things simple, we will add a test to the same file we were using before, though typically tests go on separate files. At the bottom of Storage.sol
, add:
import "forge-std/Test.sol";
contract StorageTest is Test {
Storage storageContract;
function setUp() public {
storageContract = new Storage();
}
function testSetWorks() public {
assertEq(storageContract.retrieve(), 0);
storageContract.store(5);
assertEq(storageContract.retrieve(), 5);
}
}
Notice this is just another contract written in solidity, only we imported the forge-std/Test.sol
, which contains all the test code and utilities, like assertions and logging.
The setUp
function runs before every test, and in this case just deploys a Storage
contract so that we can call it. The test itself is in the testSetWorks()
(test methods must start with the word test
), and it does the same thing we did above, only in the blockchain environment provided by Forge.
Running forge test
should print the following
Running 1 test for src/Storage.sol:StorageTest
[PASS] testSetWorks() (gas: 32478)
Test result: ok. 1 passed; 0 failed; finished in 323.17µs
Printing and events
A very common problem developers new to Ethereum run into is printing variables for debugging. Again, because our code is meant to be run on the Ethereum virtual machine on-chain, printing to standard output isn't something baked into the language. Some people get around it by manually emitting Events, but this is very cumbersome.
The Forge Test contract gives us console.log
methods to print out values when running tests. If we add a log statement to our test, like so
function testSetWorks() public {
assertEq(storageContract.retrieve(), 0);
storageContract.store(5);
uint256 result = storageContract.retrieve();
assertEq(result, 5);
console.log(result);
}
and run the tests again with a verbosity of two (forge test -vv
) we should see
Running 1 test for src/Storage.sol:StorageTest
[PASS] testSetWorks() (gas: 31774)
Logs:
5
and our variable gets printed. Under the hood, what's happening here is console.log
emits actual ethereum events (the ones mentioned above) in Forge's execution environment, which Forge are then captured and printed out.
Note that we could have added calls to console.log
to our regular non-test code, and we would have seen those logs when running tests as well.
Traces and gas estimation
In the last section we used the -vv
flag when running tests to show logs, but the verbosity level can go up to five. Running forge test -vvvvv
should return something like this:
Traces:
[88926] StorageTest::setUp()
├─ [34487] → new Storage@"0xce71…c246"
│ └─ ← 172 bytes of code
└─ ← ()
[31774] StorageTest::testSetWorks()
├─ [2246] Storage::retrieve() [staticcall]
│ └─ ← 0
├─ [20212] Storage::store(5)
│ └─ ← ()
├─ [246] Storage::retrieve() [staticcall]
│ └─ ← 5
├─ [0] console::f5b1bba9(0000000000000000000000000000000000000000000000000000000000000005) [staticcall]
│ └─ ← ()
└─ ← ()
This shows the stack trace of every test, each function call also showing its associated gas
cost. Recall gas
in ethereum is a measure of the cost of execution of a certain operation; the higher it is the more computationally expensive a bunch of code is. Because gas
is ultimately paid in real money, optimizing it becomes extremely important.
In this case we can see that a call to store
is an order of magnitude more expensive than retrieve
, i.e., storing data is much more expensive than just reading it. Additionally, the second call to retrieve
was 10x cheaper than the first one. This is no bug, the EVM reduces the cost of a storage read if the variable in question has already been read from (i.e. if the variable is hot
).
Closing thoughts
Foundry has a lot more features including fuzz testing, forking from live networks, an array of cheatcodes, and the list goes on. For a deeper dive we highly recommend going directly to the Foundry book, it is very easy to follow and has some thorough tutorials.