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 and libfuzzer, 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.
 
notion image
 

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
notion image
 
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.
 
notion image
 
There! you just tested a Solidity smart contract with Rust!🎉
 
notion image
 
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.
 
notion image
 
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.
 
 

Written by