Published on

Smart Contract Development with Foundry

Next generation smart contract development tools

Authors

Over the past couple of years, the tooling landscape for smart contract developers has changed quite a bit. If you’ve been around in crypto for a bit longer, you might remember original tools and libraries like TestRPC, also known as Ganache, and Embark, which I’ve worked on myself for a few years (unfortunately, that project got discontinued). These days, most developers use Hardhat, or probably Truffle, to build, test and deploy their applications on EVM compatible platforms. And while these tools do a pretty good job, they sometimes lack robustness and speed. This is partially due to them being written in TypeScript (or JavaScript), where the primary execution environment is Node.

In this post I’m going to explore a rather new tool belt for smart contract development called Foundry. Foundry is written in Rust, a programming language that is designed to be fast and memory safe. That alone is already a productivity boost as we’ll spend less time waiting for things to finish. Foundry is not a single library. Rather, it’s a collection of command line utilities with each having its own purpose. We’ll take a look at a bunch of them. If you’re new to smart contract development, don’t worry. I’ll keep things beginner friendly. Let’s get started.

Installing Foundry

If you’re familiar with Rust, you probably know rustup, a tool to manage Rust compiler installations and toolchains. Foundry comes with a similar manager foundryup which can be installed running the following command in your terminal of choice:

curl -L https://foundry.paradigm.xyz | bash

Once that is done, simply run the foundryup command to install all Foundry tools. Here’s a quick run down on the tools you’ll get:

  • forge - A tool to initialise projects, run tests, and to compile and deploy smart contracts.
  • cast - A tool to interact with smart contracts via RPC, as well as a good handful of utility commands for encoding, data conversion and working with wallets.
  • anvil - A local testnet node to deploy your smart contracts to. This is your new Ganache if you will (if this doesn’t make sense to you because you’re new to this, no worries, we’ll get there).
  • chisel - A REPL to test Solidity snippets in a local network.

Alright cool. If you want to check whether the installation went fine, try running forge —version.

Creating a project

Let’s create our first Foundry project using its init command. The command takes a directory as argument and creates it with a starter template.

$ forge init counter

The starter template comes with a simple smart contract that can be tested and deployed. Just enough to get something going and poke around.

Note: Foundry integrates with existing projects that use different tooling as well, however in this post we’ll stick to the starter template to focus on scope of this post.

After initialising the project, the contents of our counter folder should look something like this:

$ tree -aL 1
.
├── .git
├── .github
├── .gitignore
├── .gitmodules
├── cache
├── foundry.toml
├── lib
├── out
├── script
├── src
└── test

9 directories, 3 files

Let’s take a quick look at the generated smart contract. Once navigated into the counter folder, have a look at the src/Counter.sol file:

pragma solidity ^0.8.13;

contract Counter {
    uint256 public number;

    function setNumber(uint256 newNumber) public {
        number = newNumber;
    }

    function increment() public {
        number++;
    }
}

Even if you’re new to Solidity (the language in which that contract is written in), you’re most likely going to follow what’s happening.

The contract is a simple counter (surprise!) and provides two methods, one to set the counter and another one to increment it. We’ll learn how to interact with the contract in a bit.

Compiling smart contracts

Next, we’ll compile the smart contract so it can be deployed on our local test node that we’ll spin up using anvil (the other tool we got from Foundry). If you’re new to this, the only thing you need to know is that a smart contract needs to be compiled to byte code. The Solidity compiler will give us an ABI (Application Binary Interface) as well, which we’ll use to talk to the smart contract, once its byte code has been deployed on-chain.

Let’s run forge build to compile our project:

$ forge build
[⠢] Compiling...
[⠆] Compiling 21 files with 0.8.19
[⠰] Solc 0.8.19 finished in 1.03s
Compiler run successful

This has created a bunch of files in the out folder, which is where build artefacts end up. We’ll also find the JSON encoded ABI of our counter contract in there.

$ tree -aL 1 out/Counter.sol/
out/Counter.sol/
└── Counter.json

1 directory, 1 file

Feel free to take a look. We’ll explore the ABI of a smart contract more deeply in this post. For now, let’s move on and deploy our contract on a local test node.

Deploying smart contracts

As mentioned earlier, we’ll be using the provided anvil tool to spin up a local test node and deploy our smart contract there. To do that, run the anvil command in a separate terminal.

The output is rather large, so let’s quickly talk about what we’ll see (I’m skipping unimportant stuff).

$ anvil
...

Available Accounts
==================

(0) "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" (10000 ETH)
(1) "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" (10000 ETH)
(2) "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" (10000 ETH)
(3) "0x90F79bf6EB2c4f870365E785982E1f101E93b906" (10000 ETH)
(4) "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65" (10000 ETH)
(5) "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc" (10000 ETH)
(6) "0x976EA74026E726554dB657fA54763abd0C3a0aa9" (10000 ETH)
(7) "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955" (10000 ETH)
(8) "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f" (10000 ETH)
(9) "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720" (10000 ETH)

Private Keys
==================

(0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
(1) 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
(2) 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
(3) 0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6
(4) 0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a
(5) 0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba
(6) 0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e
(7) 0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356
(8) 0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97
(9) 0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6
...

Listening on 127.0.0.1:8545

The two major parts here are the Available Accounts and the Private Keys. In order to call smart contract methods that change state on-chain, we need to send transactions to the test node. Transactions have to be signed by an externally owned account. Typically, that is one of your wallets.

During testing, we obviously don’t want to use wallet accounts that we use in real life so anvil creates a bunch of test accounts for us and outputs them in addition to their private keys, which are needed to sign transactions. Notice how every account has 1000 ETH in their balance. You’re rich now.

Another thing to note is that anvil is listening on 127.0.0.1:8545. That’s the endpoint we’ll connect to, to deploy our smart contract. Which we’ll do now.

To deploy our smart contract, we’ll use forge’s create command. There are a bunch of options available but we’ll stick to just a few to keep things simple. Namely, we specify the RPC endpoint of the node that we will deploy to (the one mentioned above), we’ll choose a private key which represents the account that deploys the contract (I’m just going to take the first one), and we tell forge which contracts we want to deploy:

❯ forge create --rpc-url http://127.0.0.1:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 src/Counter.sol:Counter

Notice the :Counter syntax when we specify the smart contract we want to deploy. This is because there can be multiple contract definitions in a single file. Once that is executed, forge creates the following output (addresses and hashes might be different for you):

Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Transaction hash: 0x4de8f0dfdb3a65e9bc7191537974d014b3a80c822709d18a6c3c57a82a5d3e58

In case this isn’t clear

  • Deployer is the account that signed the transaction to deploy the smart contract
  • Deployed to is the address of our newly deployed smart contract
  • Transaction hash is the hash of the transaction that caused the instantiation of the smart contract on-chain

At the same time, anvil has produced some output as well. What’s really cool about it, is that it gives some insights over what’s happening when forge creates and sends the transaction:

eth_chainId
eth_getTransactionCount
eth_getBlockByNumber
eth_feeHistory
eth_estimateGas
eth_getBlockByNumber
eth_feeHistory
eth_sendRawTransaction

    Transaction: 0x4de8f0dfdb3a65e9bc7191537974d014b3a80c822709d18a6c3c57a82a5d3e58
    Contract created: 0x5fbdb2315678afecb367f032d93f642f64180aa3
    Gas used: 106719

    Block Number: 1
    Block Hash: 0xa9cd85d59c75ef875c0925d107f3dc69dbdeb938ceb8ff3048887f91cdc427e5
    Block Time: "Fri, 24 Mar 2023 22:36:06 +0000"

eth_getTransactionByHash
eth_getTransactionReceipt

Here’s what happened:

  • First of all, all eth_-prefixed messages are logs of RPC calls that were performed on the node.
  • To create a transaction object, forge needs to know things like the current account’s transaction count (aka nonce), and block number. The eth_feeHistory and eth_estimateGas calls were done to calculate and find a good amount of fees being spent on the transaction.
  • After the transaction was sent and processed, anvil outputs its hash, block number, and other block info.
  • Lastly, the transaction receipt was fetched, which was used in forge to generate the output we’ve analysed earlier.

Congratulations! You’ve just deployed a smart contract using Foundry. Next, let’s take a look at how we can interact with it using cast.

Interacting with smart contracts

With the contract being deployed on our local test node, it can now be interacted with using the node’s RPC API. There are various ways to talk to a node, but we’ll use a tool called cast provided by Foundry which was specifically designed for that.

To get an idea what the command provides, run the following command:

$ cast --help

Alternatively, head over to the Foundry book. It comes a nice command reference.

Alright, the first thing we’re going to do is read the smart contract’s state, its counter value. cast comes with a call command which takes any valid Ethereum address (our smart contract address) and a method signature as arguments.

The following command will call our smart contracts number() function:

$ cast call 0x5fbdb2315678afecb367f032d93f642f64180aa3 "number()"
0x0000000000000000000000000000000000000000000000000000000000000000

Couple of things to note here:

  • The signature argument might be a bit surprising. The reason contract APIs are called like that is because they need to be ABI encoded under the hood. I’m going to explain in a future article what that means in case this doesn’t make sense to you right now.
  • There’s actually no number() method defined in the smart contract, however, all public properties will get corresponding getter functions.

The return value is the uint256 encoded in hex bytes. If we want to the decimal representation of that value, we can use cast’s —to-base option:

$ cast --to-base 0x0000000000000000000000000000000000000000000000000000000000000000 10
0

One thing that’s important to note is that reading values from the node does not require a transaction. This is because by reading values, we’re not trying to change the state of the chain. If we want to change state, we’ll have to send a transaction similar to how we’ve done it when we deployed our contract.

With that in mind, if we wanted to call our smart contract’s increment() method, it’s going to be a write operation (the counter will be increased), so a transaction is needed.

To sign and publish transactions, we’ll use cast’s send command. Very similar to call, it needs a destination address and a method signature to create the transaction. In order to sign the transaction, we’ll have to specify an account as well.

Here’s what that looks like:

$ cast send 0x5fbdb2315678afecb367f032d93f642f64180aa3 \
  "increment()" \
  --from 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

Notice that I’ve used the --from option to specify which account is signing the transaction. Because the test accounts are manage by the test node itself, it’s enough to specify the address of the account. If our account came from somewhere else, then the --private-key option would do the trick.

When we execute that command, we’ll get a transaction receipt returned:

blockHash               0x21e9d08b5e634799185ee59b040a0111be9671ea4b86080e828901e40ad5d181
blockNumber             2
contractAddress
cumulativeGasUsed       43404
effectiveGasPrice       3875889325
gasUsed                 43404
logs                    []
logsBloom               0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
root
status                  1
transactionHash         0x69e75fda4b45e0a5deaa7e3f00df415857a6d58c677266db416b1454697c68d9
transactionIndex        0
type                    2

This is a mouthful, but necessary to know the state of your transaction, in which block it ended up and how much gas it used.

Let’s check the state of our smart contract again to see if it indeed incremented the counter:

$ cast call 0x5fbdb2315678afecb367f032d93f642f64180aa3 "number()"
0x0000000000000000000000000000000000000000000000000000000000000001

That looks good! The counter is now at 1. You can perform the same conversion as earlier to verify this. Also, feel free to increment a few more times and see how the hex value changes.

The last thing we want to do is sending a transaction that with a method signature that requires additional arguments.

Our smart contract comes with a setNumber(uint256 newNumber) method. Simply running cast send with the signature alone is not going to be enough. We’ll have to add an argument to it.

The following command sets the counter of our smart contract to 10:

$ cast send 0x5fbdb2315678afecb367f032d93f642f64180aa3 \
  "setNumber(uint256)" 10  \
  --from 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

cast takes care of encoding the argument for us. We can verify, whether this worked by calling the number() getter again:

$ cast call 0x5fbdb2315678afecb367f032d93f642f64180aa3 "number()"
0x000000000000000000000000000000000000000000000000000000000000000a

Keep in mind the returned data is encoded in hex bytes. a is 10 in hexadecimal. We can convert the value again if we want to be super sure:

❯ cast --to-base 0x000000000000000000000000000000000000000000000000000000000000000a 10
10

What’s next…

Hopefully this post has given you a good overview of how to use Foundry to build and deploy your smart contracts. To sum it up, here’s what you’ve learned:

  • forge init - To initialise new Froundy projects
  • forge build - To compile your smart contracts
  • forge create - To deploy your smart contract on a local test node
  • anvil - To spin up a local test node
  • cast call - To call smart contract getter functions
  • cast send - To sign and publish transactions

In future posts, we’ll dive a bit deeper and explore things like ABI encoding, debugging, testing, the EVM and more.

Make sure to subscribe below if you don’t want to miss a post or follow me on Twitter for updates!