Testing Solidity Contracts with Rust
In this article, we explore how Polytope Labs tests solidity smart contracts in Rust using Foundry.
Published on
Do not index
Do not index
Who says you can’t leverage Rust’s rich ecosystem of libraries, fuzzers and a powerful inbuilt testing suite? You can use Rust to test your smart contracts with randomized values and also optimize the code.
Polytope Labs built one of the most advanced solidity libraries for Merkle (multi) proof verification of different kinds of Merkle trees and also wrote tests for this library in Rust.
To construct a Merkle tree in solidity would be an arduous task, this is why Polytope labs used Rust for fuzz testing in the Solidity Merkle tree library. You can check the sauce code here. After you’ve followed this tutorial, you should be able to read the source code and adapt it to your projects.
Before we continue we need to go through some prerequisites.
- Some experience writing and reading Solidity
- Some experience writing and reading Rust
- Some knowledge about Merkle Mountain Range. Read more here
- Have Foundry installed on your machine? If you haven't, you can follow this tutorial.
In this article, you will learn how to write tests in Rust for Solidity smart contracts using Foundry’s forge.
Why should you use Rust to test your smart contracts?
- Concurrency Control: Rust provides powerful concurrency control through its ownership model. This is particularly useful in smart contracts where multiple transactions may execute concurrently. Rust's ownership rules help avoid race conditions and data races, ensuring the reliability of your contracts.
- Static Typing: Rust is statically typed, which means that many errors are caught at compile-time rather than runtime. This reduces the likelihood of runtime failures in your smart contracts and provides early feedback on potential issues.
- Fuzzing Libraries: Rust has several robust fuzzing libraries, such as
cargo-fuzz
andlibfuzzer
, that make it relatively easy to integrate fuzz testing into your smart contract development workflow. These libraries provide tools for generating random inputs and efficiently exploring the code's execution paths.
- Integration with Existing Tools: Rust has good integration with various blockchain platforms and smart contract development tools. You can combine fuzz testing with other testing and analysis tools specific to your chosen blockchain ecosystem, enhancing your smart contract's overall security and reliability.
What is Polytope Labs?
Polytope Labs is a blockchain research & development lab at the forefront of the decentralization revolution. They are building the missing keys needed in the Web3 ecosystem both in Ethereum and Polkadot. We have great projects that are already being implemented in the Web3 ecosystem.
- Solidity Merkle trees library: A sophisticated library for Merkle tree verification algorithms, it currently supports Merkle Trees (supports unbalanced trees), Merkle Mountain Ranges and Merkle-Patricia Trie.
- Simnode: An excellent library for testing and simulating live chain states and transactions, especially for testing pallets that require an entire runtime not a mocked runtime.
- ISMP: Polkadot has spearheaded the concept of a modular blockchain framework, with the ISMP protocol relying on consensus clients and state machine clients to validate consensus proofs and state proofs, respectively. This approach facilitates seamless, trustless interoperability among various blockchain networks.
There are also other projects, you can read more here.
What is Foundry?
Foundry is a blazingly fast, portable and modular toolkit for Ethereum application development written in Rust.
Foundry consists of:
- Forge: Ethereum testing framework (like Truffle, Hardhat and DappTools).
- Cast: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data.
- Anvil: Local Ethereum node, akin to Ganache, Hardhat Network.
- Chisel: Fast, useful, and verbose solidity REPL.
Firstly, let’s build a smart contract using Foundry.
Creating the project
We’ll be using thirdweb and forge. Run this command.
npx thirdweb@latest create mycontract
It will prompt you to answer a series of questions to set up your project.
- Give your project a name
- Select
Forge
as the framework
- Select ERC721 as the base contract
- Select
Drop
as an optional extension
Finally, run the following command in your directory.
forge clean && foundryup && forge update
Once everything is installed, let’s start writing some solidity.
Writing smart contracts
If you don’t have a file called
MyContract.sol
, Let’s create one. Give it the same name within the src
directory and copy the following code.// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.0;
//I imported it this way, because node_modules is not being detected for some reason, you can use the other way if you want
import "node_modules/@thirdweb-dev/contracts/base/ERC721Drop.sol";
contract MyContract is ERC721Drop {
constructor(
address _defaultAdmin,
string memory _name,
string memory _symbol,
address _royaltyRecipient,
uint128 _royaltyBps,
address _primarySaleRecipient
)
ERC721Drop(
_defaultAdmin,
_name,
_symbol,
_royaltyRecipient,
_royaltyBps,
_primarySaleRecipient
)
{}
function mint(address _to, uint256 _amount) external {
require(_amount > 0, 'You must mint at least one token!');
_safeMint(_to, _amount);
}
}
This contract mints an ERC721 token, it contains the
constructor
and a method mint
for minting an amount of tokens.Writing solidity tests
Let’s write some tests for our smart contract. Foundry also has a testing framework as mentioned earlier, we will be using Forge to test our smart contract, and spoiler alert (write rust tests for the smart contract).
Let’s go into the
test
directory and into MyContract.t.sol
. Inside MyContract.t.sol
, make sure to have both of the required imports: the contract we are testing and the standard library test contract provided by Forge that should already be included. Like this below.import "forge-std/Test.sol";
import "../src/Contract.sol";
Copy the code below into the file.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/MyContract.sol";
contract ContractTest is Test {
MyContract drop;
// make addr is a helper function to create an address from a string
address testAddr = makeAddr("Test");
function setUp() public {
drop = new MyContract(
testAddr,
"MintTest",
"MT",
testAddr,
500,
testAddr
);
}
function testDropWithZeroTokens() public returns (uint256) {
vm.expectRevert("You must mint at least one token!");
drop.mint(testAddr, 0);
assertEq(drop.balanceOf(testAddr), 0);
return 0;
}
function testDropWithTwoTokens() public returns (uint256) {
drop.mint(testAddr, 2);
assertEq(drop.balanceOf(testAddr), 2);
return 2;
}
}
We have two tests here.
testDropWithZeroTokens
tests the mint feature with no tokens and expects a revert(fail) while testDropWithTwoTokens
tests the mint feature with two tokens only. Let’s test the smart contract by running the following command.forge test
Our tests should all be successful. You should get an output similar to this.
Testing solidity smart contracts in rust
So far, we’ve created our smart contract project, created a simple mint contract and also tested the functionality in Solidity.
Since solidity tests are also smart contracts, we will also need a way to detect all the tests (written in solidity) in our rust environment. For you to be able to test the smart contracts in Rust, you need to have access to a workspace that contains all the smart contracts (compiled). In our case, the smart contracts live in the
src
folder. You will also need access to a local EVM and an executor environment.Creating a Rust project
Let’s create a Rust project.
Since we are mainly writing tests, let’s create a library project. In your project directory (outside the
src
folder) open up your terminal and run this command.cargo new --lib forge_tests
Add dependencies
Next, let’s add all the necessary dependencies that we’ll need for our tests and configurations.
forge = { git = "https://github.com/foundry-rs/foundry", rev = "25d3ce7ca1eed4a9f1776103185e4221e8fa0a11" }
foundry-common = { git = "https://github.com/foundry-rs/foundry", rev = "25d3ce7ca1eed4a9f1776103185e4221e8fa0a11" }
foundry-config = { git = "https://github.com/foundry-rs/foundry", rev = "25d3ce7ca1eed4a9f1776103185e4221e8fa0a11" }
foundry-evm = { git = "https://github.com/foundry-rs/foundry", rev = "25d3ce7ca1eed4a9f1776103185e4221e8fa0a11" }
ethers = { git = "https://github.com/gakonst/ethers-rs", features = ["ethers-solc"] }
once_cell = "1.17.0"
tokio = { version = "1.17.0", features = ["macros", "rt-multi-thread"] }
primitive-types = "0.12.1"
Local forge file
We need to create a file where we will write the code that detects the smart contracts in our workspace. We also need to create a local EVM with cheat codes and EVM options. Then we will create a runner and executor. This is for running the nodes and executing smart contracts respectively.
Create a new file in your
src
folder, let’s call it forge.rs
and copy the following block of code.use ethers::{
abi::{Detokenize, Tokenize},
solc::{Project, ProjectCompileOutput, ProjectPathsConfig},
types::U256,
};
We imported the Ethereum library written in Rust, it contains bare-bone features that we can use for compiling, encoding and decoding solidity ABI in Rust. The
abi
type that exports Detokenize
and Tokenize
is for decoding and encoding the abi
respectively. The solc
exports some utilities for working with solidity and compiling native solidity projects. Project
represents a project workspace type and handles solc
compiling of all contracts in that workspace we use this with ProjectPathConfig
type which contains methods that we can use for configuring/instructing the compiler where our smart contracts live, while ProjectCompileOutput
contains a mixture of already compiled/cached artefacts and the input set of sources that still need to be compiled. We also imported one of the native types of U256
rust implementation, you’ll see how we are going to use this type as we move on in this article. You can also read more about the Rust Ethers library here.Next, we are going to
use
one of Foundry's tools, called Forge. use forge::{
executor::{
inspector::CheatsConfig,
opts::{Env, EvmOpts},
},
result::TestSetup,
ContractRunner, MultiContractRunner, MultiContractRunnerBuilder,
};
Here we imported the
executor
type which also exports opt
ions which contains the Env
which also imports the macro type env!
for detecting Cargo
workspaces and the EvmOpts
for configuring our local EVM. We build our ContractRunner
with the CheatsConfig
(i.e. additional configurations that the inspector needs, including EvmOpts
). MultiContractRunner
detects all the test contracts in your workspace, ContractRunner
deploys a single contract to a local EVM implementation and allows you to call methods on the contract inside the local EVM and I think you can guess what MultiContractRunnerBuilder
does😉Let’s do more imports.
use foundry_config::{fs_permissions::PathPermission, Config, FsPermissions};
use foundry_evm::executor::{Backend, EvmError, ExecutorBuilder, SpecId};
Here, we imported Foundry’s
config
type which contains the types for configuring permissions for detecting, reading and writing in our workspace. We also imported the executor
type that contains the Backend
for creating the local EVM which includes the database, this DB could be an in-memory database or a forked database that is forked off a remote client. The ExecutorBuilder
allows you to configure the EVM executor
while SpecId
contains the activation block Information. Let’s do the last imports needed in this file.
use once_cell::sync::Lazy;
use std::{
fmt::Debug,
path::{Path, PathBuf},
};
We need access to the compiled project(smart contract) output and a great library we can use is
once_cell
is great for initialising your program with all the necessary data and it does this once (only when needed). It also doesn’t reinitialise again which is great for when you are doing some processes like I/O operations like in our case.Of course, we can’t access the
path
to the workspace without our very own std
library provided by the Rust devs. The local EVM, executor and its parts
Let’s continue writing the program, and copy the following code block. Look out for comments within the code!
static PROJECT: Lazy<Project> = Lazy::new(|| {
// detect Cargo.toml
let mut root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
root = PathBuf::from(root.parent().unwrap());
// detect the path where our smart contract live
let paths = ProjectPathsConfig::builder()
.root(root.clone())
.build()
.unwrap();
// build the path
Project::builder()
.paths(paths)
.ephemeral()
.no_artifacts()
.build()
.unwrap()
});
// These are the env configurations needed by forge
static EVM_OPTS: Lazy<EvmOpts> = Lazy::new(|| EvmOpts {
env: Env {
gas_limit: 18446744073709551615,
chain_id: Some(foundry_common::DEV_CHAIN_ID),
tx_origin: Config::DEFAULT_SENDER,
block_number: 1,
block_timestamp: 1,
..Default::default()
},
sender: Config::DEFAULT_SENDER,
initial_balance: U256::MAX,
ffi: true,
memory_limit: 2u64.pow(24),
..Default::default()
});
static COMPILED: Lazy<ProjectCompileOutput> = Lazy::new(|| {
let out = (*PROJECT).compile().unwrap();
if out.has_compiler_errors() {
eprintln!("{out}");
panic!("Compiled with errors");
}
out
});
/// Builds a base runner
fn base_runner() -> MultiContractRunnerBuilder {
// Builds the base runner with a configured address
MultiContractRunnerBuilder::default().sender(EVM_OPTS.sender)
}
fn manifest_root() -> PathBuf {
let mut root = Path::new(env!("CARGO_MANIFEST_DIR"));
// need to check here where we're executing the test from, if in `forge` we need to also allow
// `testdata`
if root.ends_with("forge") {
root = root.parent().unwrap();
}
root.to_path_buf()
}
/// Builds a non-tracing runner
fn runner_with_config(mut config: Config) -> MultiContractRunner {
// add cargo
config.allow_paths.push(manifest_root());
base_runner()
.with_cheats_config(CheatsConfig::new(&config, &EVM_OPTS))
.sender(config.sender)
.evm_spec(SpecId::SHANGHAI)
.build(
&PROJECT.paths.root,
(*COMPILED).clone(),
EVM_OPTS.local_evm_env(),
EVM_OPTS.clone(),
)
.unwrap()
}
/// Builds a non-tracing runner
pub fn runner() -> MultiContractRunner {
// more project configurations
let mut config = Config::with_root(PROJECT.root());
// permissions needed when accessing the path. we detect all smart contracts in the workspace here
config.fs_permissions = FsPermissions::new(vec![PathPermission::read_write(manifest_root())]);
runner_with_config(config)
}
// executes the call to a smart contract with the arguments specifed to the smart contract
pub async fn execute<T, R>(
runner: &mut MultiContractRunner,
contract_name: &'static str,
fn_name: &'static str,
args: T,
) -> Result<R, EvmError>
where
T: Tokenize,
R: Detokenize + Debug,
{
// This is where our local EVM lives (i.e our local EVM implementation)
let db = Backend::spawn(runner.fork.take()).await;
// this is where we extract, the artifacts, abis and byte code from the compiled smart contract
let (id, (abi, deploy_code, libs)) = runner
.contracts
.iter()
.find(|(id, (abi, _, _))| id.name == contract_name && abi.functions.contains_key(fn_name))
.unwrap();
// returns an identified artifact
let identifier = id.identifier();
// gets a smart contract function
let function = abi.functions.get(fn_name).unwrap().first().unwrap().clone();
// the executor executes the smart contract calls
let executor = ExecutorBuilder::default()
.with_cheatcodes(runner.cheats_config.clone())
.with_config(runner.env.clone())
.with_spec(runner.evm_spec)
.with_gas_limit(runner.evm_opts.gas_limit())
.set_tracing(runner.evm_opts.verbosity >= 3)
.set_coverage(runner.coverage)
.build(db.clone());
// detects a single contract on the EVM and gives you powers(methods) to call on the contract inside.
let mut single_runner = ContractRunner::new(
&identifier,
executor,
abi,
deploy_code.clone(),
runner.evm_opts.initial_balance,
runner.sender,
runner.errors.as_ref(),
libs,
);
// deploys the single contract inside the runner from a sending account
let setup = single_runner.setup(true);
// extracts the smart contract address
let TestSetup { address, .. } = setup;
// executes the contract call
let result = single_runner.executor.execute_test::<R, _, _>(
single_runner.sender,
address,
function,
args,
0.into(),
single_runner.errors,
)?;
// we should see this when we run tests individually
println!("Smart contract function name called {fn_name}: Gas used {:#?}", result.gas_used);
Ok(result.result)
}
We created a place in memory for storing the compiled project, we did configurations for our local EVM, and we built a simplified
non-tracing runner
. Non-tracing runners refer to nodes or services that do not actively participate in executing transactions with tracing enabled. These nodes might prioritize factors such as lower resource usage, reduced complexity, and faster synchronization over providing detailed execution traces. In this case, it is a test environment, so a non-tracing runner is preferred. Finally, we built an executor on top of Foundry
's executor which executes the call to a smart contract. Make sure you read the code as I omitted some parts for brevity. Re-exporting into the lib file
We are going to be using all the types and functions we declared in
forge.rs
delete all the contents in your lib.rs
and copy the following. pub mod forge;
pub use ethers::{abi::Token, types::U256};
pub use crate::forge::{execute, runner};
Writing tests with Rust
Next, create a new file. Call it
mycontract.rs
and make the following imports.#![cfg(test)]
use crate::{forge::execute, runner};
use primitive_types::U256;
We imported our own
forge
implementation and the Rust implementation of the U256
type that we used in our smart contract.Please note, that we are testing the smart contract tests that we wrote. Since smart contract tests are also smart contracts, it is safe to say we are testing smart contracts.
Copy this code. We just created our runner and executor which are crucial and we wrapped these two in a function so that we can call it repeatedly.
pub async fn test_mycontract_with_zero_token_minted() {
// runner
let mut runner = runner();
// executor
let tokens_minted =
execute::<_, U256>(&mut runner, "ContractTest", "testDropWithZeroTokens", ())
.await
.unwrap();
assert_eq!(tokens_minted, U256::from(0));
}
We made this function async because we need to await the result of our called smart contract. If you notice, you can see
ContractTest
which is the name of the test contract and testDropWithZeroTokens
which is one of the test smart contract functions from MyContract.t.sol
you can see the function declaration below.function testDropWithZeroTokens() public returns (uint256) {
vm.expectRevert("You must mint at least one token!");
drop.mint(testAddr, 0);
assertEq(drop.balanceOf(testAddr), 0);
return 0;
}
Mind-blowing, right? wait till you run the actual tests!
Copy the following code block.
#[tokio::test(flavor = "multi_thread")]
async fn test_minted_zero_tokens() {
test_mycontract_with_zero_token_minted().await;
}
We used
tokio
because we are running asynchronous threads and we don’t want them to wait for each other that is why we are using the multi_thread
flavor. Before you run your tests, let’s import our tests into
lib.rs
like this.pub mod my_contract;
Now we can run an individual test by running
cargo test --package forge_tests --lib -- my_contract::test_minted_zero_tokens --exact --nocapture
You might run into an error that looks like this. This error is caused by a dependency conflict found in the Foundry and Ethers library. It looks like this
All you need to do is head to my repo, copy the Cargo.lock file and replace it.
Now you can run the command above. You should see something like this.
There! you just tested a Solidity smart contract with Rust!🎉
I wrote more Solidity smart contract tests. Notice the one that requires you to add arguments to your test functions. Copy this code into
MyContract.t.sol
file.function testDropWithTwoTokens() public returns (uint256) {
drop.mint(testAddr, 2);
assertEq(drop.balanceOf(testAddr), 2);
return 2;
}
// takes custom specified amount of tokens
function testDropWithCustomTokens(uint256 amount) public returns (uint256) {
drop.mint(testAddr, amount);
assertEq(drop.balanceOf(testAddr), amount);
return amount;
}
I also wrote more rust tests to specify how you would test with smart contracts that require arguments like
testDropWithCustomTokens
in MyContract.t.sol
file, check test_mycontract_with_custom_tokens_minted
function mycontract
.rs
for more info. Copy the following code into mycontract.rs
your file should look like this.#![cfg(test)]
use crate::{forge::execute, runner};
use primitive_types::U256;
pub async fn test_mycontract_with_zero_token_minted() {
// runner
let mut runner = runner();
// executor
let tokens_minted =
execute::<_, U256>(&mut runner, "ContractTest", "testDropWithZeroTokens", ())
.await
.unwrap();
assert_eq!(tokens_minted, U256::from(0));
}
pub async fn test_mycontract_with_custom_tokens_minted(amount: U256) {
// runner
let mut runner = runner();
// executor
let tokens_minted = execute::<_, U256>(
&mut runner,
"ContractTest",
"testDropWithCustomTokens", // smart contract method name
amount, // notice the arguments
)
.await
.unwrap();
assert_eq!(tokens_minted, amount);
}
async fn test_mycontract_with_two_tokens_minted() {
let mut runner = runner();
let tokes_minted =
execute::<_, U256>(&mut runner, "ContractTest", "testDropWithTwoTokens", ())
.await
.unwrap();
assert_eq!(tokes_minted, U256::from(2));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_minted_zero_tokens() {
test_mycontract_with_zero_token_minted().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn test_minted_two_tokens() {
test_mycontract_with_two_tokens_minted().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn test_minted_custom_tokens() {
test_mycontract_with_custom_tokens_minted(U256::from(3)).await;
}
You can run
cargo test -- no-capture
and you should see the logs we made earlier in our code. You should see something like this.You can notice functions like
testDropWithTwoTokens
and testDropWithCustomTokens
and the Gas used. One more cool thing to do, if you’re using
rust-analyzer
you can create a .vscode
folder in your root folder, outside mycontractproject
folder. Create a settings.json
file. Add the following code. This will allow the analyzer to detect the rust workspace.
"rust-analyzer.linkedProjects": [
"mycontractproject/forge_tests/Cargo.toml"
],
}
If you still run into errors, you can clone the source code here
Conclusion
In this article, we went through how to detect solidity contracts with forge, create a local EVM executor, and execute smart contracts in a local EVM using Rust to interface with the EVM to test our smart contracts.