Foundry 101
Until some time the only way to poke around with EVM smart contracts development was to use tools written in JavaScript (truffle, hardhat) or Python (brownie) - unfortunately for me, not a fan of those languages, it was a bit harsh. :)
Luckily, over the last year, a new kid in the block has grown, it’s called Foundry and I really liked it.
Building blocks
Foundry is not one tool, it’s a set of tools, a toolkit that nicely follows Unix philosophy - programs doing one thing, doing it well and playing sane with each other.
The tools are:
- forge - helping us with build -> test -> deploy cycle,
- anvil - serving as a local testnet node,
- cast - utility for “talking” to Ethereum node,
- chisel - REPL environment for solidity.
Let’s briefly take a look at their purposes and usage.
Forge
Forge is the foundation of Foundry, it lets you manage, build and test your code.
From now on, we’re going to leverage the source code that is generated by default with a new project and see what happens there.
Project layout
To create a new project it’s either:
$ mkdir counter; cd counter; forge init
or if you’re in the right directory already just:
$ forge init counter
Here’s what we got:
$ tree -L 2
├── foundry.toml
├── lib
│ └── forge-std
├── script
│ └── Counter.s.sol
├── src
│ └── Counter.sol
└── test
└── Counter.t.sol
Config
foundry.toml
is a configuration file, for our use case it’s good to just know it’s there - we won’t use anything other than defaults.
For more details on various toggles, check this doc.
Dependencies
lib/
is where dependencies live. By default, forge creates a new git repository and dependencies are handled as git submodules.
To add a new dependency from GitHub:forge install transmissions11/solmate
.
If you need more, forge install -h
is your friend.
To avoid long import paths in the code, forge offers remappings feature.
Let me just serve you an example:
$ forge remappings
ds-test/=lib/forge-std/lib/ds-test/src/
forge-std/=lib/forge-std/src/
solmate/=lib/solmate/src/
Here are the remappings that forge inferred automatically.
If you want to add more, then all remappings need to be stored in remappings.txt
(there is a way to store them in foundry.toml, alternatively)
From now on, you can use statements likeimport "solmate/src/tokens/ERC20.sol";
and forge will automatically resolve solmate
into lib/solmate/src/
when needed.
Building the project
Forge generated some code for us in src/Counter.sol
, let’s take a look.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;
contract Counter {
uint256 public number;
function setNumber(uint256 newNumber) public {
number = newNumber;
}
function increment() public {
number++;
}
}
To build the code, run forge build
.
Btw. there is also forge fmt
subcommand that comes in handy when you add more code and want it nicely formatted.
As you can see, the code is simple, but it serves well in our baby steps. It creates a contract with one public variable and gives us two functions to modify it - let’s play with them by running tests.
Testing
The great thing about Foundry is that its tools feel very “native”, for example, a test in foundry is just another smart contract that interacts with our code.
Here’s a part of generated test/Counter.t.sol
:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;
import "forge-std/Test.sol";
import "../src/Counter.sol";
contract CounterTest is Test {
Counter public counter;
function setUp() public {
counter = new Counter();
counter.setNumber(0);
}
function testIncrement() public {
counter.increment();
assertEq(counter.number(), 1);
}
// [...]
}
Let’s dig more into some specific lines:
contract CounterTest is Test
- we need to inherit Test contract.
Thanks to that we can make assertions, alter the state of EVM during a test and so on.
Speaking about altering the state of EVM - cheatcodes are used for that, I won’t cover them in this post, though.Counter public counter;
creates an instance of our unit being tested.setUp()
is called before each test case.
It’s supposed to set the desired state of things before the test case runs.testIncrement()
is a test case.
Every function prefixed withtest
is treated by Forge as a test case.assertEq(counter.number(), 1);
checks if the output matches the expected value.
To run a specific test by name run:
$ forge test -vvvv --match-test testIncrement
Running 1 test for test/Counter.t.sol:CounterTest
[PASS] testIncrement() (gas: 28334)
Traces:
[28334] CounterTest::testIncrement()
├─ [22340] Counter::increment()
│ └─ ← ()
├─ [283] Counter::number() [staticcall]
│ └─ ← 1
└─ ← ()
Test result: ok. 1 passed; 0 failed; finished in 258.04µs
The result gives hints about the gas units usage (in square brackets) and the call stack.
Foundry generated one more test case for us - it presents fuzz testing.
function testSetNumber(uint256 x) public {
counter.setNumber(x);
assertEq(counter.number(), x);
}
Notice the value passed as a parameter - it will be mutated.
In our case, the only parameter is uint256 x
but nothing stops you from defining more.
Let’s run it:
forge test -vvvv --match-test testSetNumber
Running 1 test for test/Counter.t.sol:CounterTest
[PASS] testSetNumber(uint256) (runs: 256, μ: 27476, ~: 28409)
Traces:
[28409] CounterTest::testSetNumber(3)
├─ [22290] Counter::setNumber(3)
│ └─ ← ()
├─ [283] Counter::number() [staticcall]
│ └─ ← 3
└─ ← ()
Test result: ok. 1 passed; 0 failed; finished in 9.63ms
Notice the runs: 256
- it means our test case was run with 256 mutations of type uint256.
Tidle ("~") on the other hand informs us on the median gas used across all runs.
Deployment
OK, we have the code, it’s tested, and now we’d like to deploy it to the network. How do we do it?
There are two ways.
For simple contracts, we can just use create
subcommand.
$ forge create \
--rpc-url http://127.0.0.1:8545 \
--private-key 0xac097... \
src/Counter.sol:Counter
The output is pretty compact:
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Transaction hash: 0x8e8c5e09385bf91b3
If there are multiple contracts to be deployed at once or lots of arguments to be passed in constructors - it’s better to use Foundry’s scripts.
Forge already created an example script for Counter contract, but it’s incomplete. We need to mark when the broadcasting starts and stops and initialize our contract in beetwen.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Script.sol";
import "../src/Counter.sol";
contract CounterScript is Script {
function setUp() public {}
function run() public {
vm.startBroadcast();
Counter counter = new Counter();
vm.stopBroadcast();
}
}
The command to run is:
forge script \
--broadcast \
--verify \
--rpc-url http://127.0.0.1:8545 \
--private-key 0xac0974... \
script/Counter.s.sol:CounterScript
The output now is more verbose, it even contains gas cost calcations in ETH:
Estimated gas price: 3.689158116 gwei
Estimated total gas used for script: 138734
Estimated amount required: 0.000511811662065144 ETH
Also, detailed transaction dumps are stored in ./broadcase/<script name>/<chain id>/
, in our case: ./broadcast/Counter.s.sol/31337/run-latest.json
However, to deploy our code one way or another, we need to learn a bit about Anvil first.
Anvil
Anvil is super simple to use but so much helpful. It runs a local Ethereum node and generates a number of test accounts with just one command.
Another great feature is the ability to fork an existing network. That means you can simulate the same state, for example, as the latest block on Ethereum mainnet and interact with contracts that live on the chain - an efficent way to check how your code interacts with other protocols.
The only thing needed is the URL of the archive node (also that could be Infura or Alchemy API) passed as --fork-url
.
It’s called fork because every change made to the state afterwards stays in our local environment.
Here’s an example of an instance that forks Ethereum mainnet at the latest block and creates two test accounts:
anvil -a 2 --fork-url https://eth-mainnet.g.alchemy.com/v2/<key>
_ _
(_) | |
__ _ _ __ __ __ _ | |
/ _` | | '_ \ \ \ / / | | | |
| (_| | | | | | \ V / | | | |
\__,_| |_| |_| \_/ |_| |_|
0.1.0 (5c2db0b 2023-01-31T00:14:55.309941Z)
https://github.com/foundry-rs/foundry
Available Accounts
==================
(0) "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" (10000 ETH)
(1) "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" (10000 ETH)
Private Keys
==================
(0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
(1) 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
[...]
Fork
==================
Endpoint: https://eth-mainnet.g.alchemy.com/v2/<key>
Block number: 16792699
Block hash: 0xa8312cdd77b576095d70d6cb6f5b3cfc80848fc83a256001800f9dd5afcfd060
Chain ID: 1
Listening on 127.0.0.1:8545
Having the instance running, let’s use Cast to interact with it.
Cast
Cast is a swiss army knife of Foundry.
It lets you query the state of the blockchain, send transactions, perform different kinds of conversions and even do ENS lookups - but again, for more details cast -h
is your friend. ;)
Great thing is that all foundry tools support passing --rpc-url
/ --fork-url
parameter (naming is a bit inconsistent though) so something like below is possible:
$ cast resolve-name --rpc-url https://eth-mainnet.g.alchemy.com/v2/<key> seblw.eth
0xDa8d58b89A0c00877e0912b893a47E947A02Aa32
Cool, right? And there is so much more.
I’ll present more cast use cases in the last part.
Chisel
Chisel is pretty new in Foundry family and honestly, I haven’t played with it much.
You can think of it the same way as typing python
in your commandline - write code and receive instant feedback.
Here’s something just to give you an idea of how it works:
~ ❯ chisel
Welcome to Chisel! Type `!help` to show available commands.
➜ uint256 public number;
➜ number = 1337;
➜ number
Type: uint
├ Hex: 0x539
└ Decimal: 1337
➜ number = number + 1;
➜ number
Type: uint
├ Hex: 0x53a
└ Decimal: 1338
Piecing things together
Let’s see how Foundry tools play together and simulate a simple development workflow.
First, let’s run our anvil instance.
$ anvil
Next, we’ll deploy Counter contract via forge create
as follows:
$ forge create \
--rpc-url http://127.0.0.1:8545 \
--private-key 0xac0974... \
src/Counter.sol:Counter
And finally, we can interact with it on chain using cast.
Let’s use send
subcommand first to invoke increment()
function (thus send a transaction).
$ cast send <addr> "increment()" --private-key 0xac097...
blockHash 0xdcfbdfc7055ac86c5c89ccbe3bf1ce69f0bae8abc68fa2168e8167a94bccba6b
blockNumber 2
contractAddress
cumulativeGasUsed 43404
effectiveGasPrice 3875889325
gasUsed 43404
logs []
logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
000000000000000000
root
status 1
transactionHash 0x69e75fda4b45e0a5deaa7e3f00df415857a6d58c677266db416b1454697c68d9
transactionIndex 0
type
Then use call
subcommand to query number()
getter (no transaction).
$ cast call <addr> "number()"
0x0000000000000000000000000000000000000000000000000000000000000001
Once again send
, this time with setNumber(uint256)
signature and an argument.
$ cast send --private-key 0xac097... <addr> "setNumber(uint256)" 0x1337
$ cast call <addr> "number()"
0x0000000000000000000000000000000000000000000000000000000000001337
See the 1337 there? It means our job for today is done.