How Fuzz testing improves Smart Contract Security in Web3
Web3 SecurityTestingSolidity

How Fuzz testing improves Smart Contract Security in Web3

17 de abril de 2024
Carlos (Bloqarl)
Carlos (Bloqarl)
Learn how fuzz testing strengthens smart contract security in Web3. Discover key techniques, real-world examples, and how to get started with Foundry.

What Is Fuzz Testing and Why It Matters

In this article, we dive into the fascinating role of fuzz testing in blockchain security. Before setting up our tests in Foundry, let’s unpack the core concept behind this powerful technique.
Fuzzing, or fuzz testing, is a method where invalid, unexpected, or random data is fed into a system to discover coding errors and vulnerabilities. When applied to web applications, it targets data injection points to find weak spots. In the world of Web3, it becomes an essential tool for hardening smart contracts against edge-case exploits.

Understanding Fuzzing Through Analogy

Here’s a simple way to grasp the idea of fuzzing:
Imagine a program that asks for your age and expects a number. Instead of giving it "30", you input strange data like "banana", "9999999999", or leave it blank. If the program crashes or behaves oddly, that’s a sign of a bug.

Applying Fuzzing to Smart Contract Security

In Web3, think of fuzzing as a rebellious robot testing a smart contract—like a digital vending machine meant to dispense crypto only when it receives a valid token. If the robot can trick the vending machine into giving out crypto with bad inputs, the contract has a flaw.
Fuzz testing helps blockchain developers catch these issues early, before they become exploitable bugs.

Web2 vs Web3: Key Differences in Fuzz Testing

Fuzzing originated during the Web2 era, where it was mainly used on centralised systems—like applications hosted on company servers. Tools such as AFL, LibFuzzer, and go-fuzz would hurl malformed data at web apps to break them. The goal? Crash the program and find failure points.
In contrast, Web3 fuzz testing is decentralised and property-based. Instead of just trying to crash the program, it verifies that a smart contract behaves correctly across a range of inputs. This is critical because blockchain transactions are immutable.

Property-Based Testing and Invariants

In Web3 fuzz testing, we define properties—or invariants—that describe expected contract behaviour. For instance:
  • No user should withdraw more than they deposited.
  • An ERC20 token’s total supply changes only when mint or burn is called.
The tester is responsible for identifying and embedding these invariants into the fuzz tests.

Why Fuzz Testing Is Crucial for Web3 Projects

Smart contracts handle large sums of money and complex logic. Fuzz testing helps:
  • Uncover hard-to-find bugs that traditional tests might miss.
  • Stress-test contracts under extreme conditions.
  • Guarantee security and correctness, especially for DeFi applications.

Example: Fuzzing a DeFi Smart Contract

Let’s say you're building a DeFi smart contract that accepts crypto deposits and calculates interests over time.
The smart contract is responsible for handling user deposits, calculating interest, and enabling withdrawals.

Risks Without Fuzz Testing:

  • Reentrancy Vulnerabilities: Malicious actors might exploit the withdrawal logic.
  • Incorrect Logic: Mistakes in interest calculations could lead to overpaying or underpaying users.
  • Poor Edge Case Handling: Huge deposits or weird input values could crash the contract.

What Fuzz Testing Catches:

  • Hidden Flaws: Like unsafe fallback functions or unchecked math.
  • Logical Inconsistencies: Such as returning more than was deposited.

Getting Started with Foundry for Fuzz Testing

Here’s how to begin fuzz testing with Foundry, a blazing-fast toolkit for Ethereum development.
This quick 4-step process will get us up and running:
  1. Installation
  2. First steps with Foundry
  3. Fuzz Testing
  4. Invariant Testing

Installation

The first command to run on your terminal is:
curl -L https://foundry.paradigm.xyz | bash
Then, if you notice, the terminal will also point out to run:
foundryup
Now, you have to install cargo to have available the Rust compiler:
curl https://sh.rustup.rs -sSf | sh
And the last step is going to be installing Forge + Cast, Anvil, and Chisel:
cargo install --git https://github.com/foundry-rs/foundry --profile local --force foundry-cli anvil chisel

First steps with Foundry

Create a new project

Let’s create a new project with Foundry and play with it.
Run:
forge init safe-example
Now, get inside the newly created project cd safe-example and compile it:
forge build

How do you execute Foundry tests?

In order to run all test cases from the repository run:
forge test
However, let’s say you’re working on test cases for a specific contract (in this example let’s consider we’re working on Safe.t.sol test file) and you only want those to be executed. So what you can do here is:
forge test --match-path test/Safe.t.sol
Another great feature that Foundry offers while executing the tests is to introduce more logs on the results as per demand.
That is controlled by adding a flag with two to five -v‘s such as:
forge test --match-path test/Safe.t.sol -vvv
The more v’s you add the more verbosity you will get in the test results.
1Verbosity levels:
2- 2: Print logs for all tests
3- 3: Print execution traces for failing tests
4- 4: Print execution traces for all tests, and setup traces for failing tests
5- 5: Print execution and setup traces for all tests
There’s a bonus feature that we get to have while executing tests with Foundry…
And it is the option to add a flag to get the gas report from the contract’s functions:
forge test --match-path test/Counter.t.sol --gas-report

Fuzz Testing

Let's use this example from Foundry’s documentation to go through this.
Here’s a simple contract:
1contract Safe {
2 receive() external payable {}
3
4 function withdraw() external {
5 payable(msg.sender).transfer(address(this).balance);
6 }
7}
Then, we want to write one unit test to make sure that the withdraw function works:
1import "forge-std/Test.sol";
2
3contract SafeTest is Test {
4 Safe safe;
5
6 // Needed so the test contract itself can receive ether
7 // when withdrawing
8 receive() external payable {}
9
10 function setUp() public {
11 safe = new Safe();
12 }
13
14 function test_Withdraw() public {
15 payable(address(safe)).transfer(1 ether);
16 uint256 preBalance = address(this).balance;
17 safe.withdraw();
18 uint256 postBalance = address(this).balance;
19 assertEq(preBalance + 1 ether, postBalance);
20 }
21}
This test is simply checking that the balance from before withdrawing + the amount transferred is the same as the balance after withdrawing.
Now, who is to say that it works for all amounts, not just 1 ether?

Here’s when Stateless Fuzzing comes in

Forge will run any test that takes at least one parameter as a property-based test, so let’s rewrite:
1contract SafeTest is Test {
2 // ...
3
4 function testFuzz_Withdraw(uint256 amount) public {
5 payable(address(safe)).transfer(amount);
6 uint256 preBalance = address(this).balance;
7 safe.withdraw();
8 uint256 postBalance = address(this).balance;
9 assertEq(preBalance + amount, postBalance);
10 }
11}
By running this, we can see that Forge runs the property-based test, but it fails for high values of amount:
1$ forge test
2Compiling 1 files with 0.8.10
3Solc 0.8.10 finished in 1.69s
4Compiler run successful
5
6Running 1 test for test/Safe.t.sol:SafeTest
7[FAIL. Reason: EvmError: Revert Counterexample: calldata=0x215a2f200000000000000000000000000000000000000001000000000000000000000000, args=[79228162514264337593543950336]] testWithdraw(uint256) (runs: 47, μ: 19554, ~: 19554)
8Test result: FAILED. 0 passed; 1 failed; finished in 8.75ms
Here we have the first example of fuzzing, instead of testing one scenario, by adding as a parameter the value that we need to test, it is going to use a vast amount of semi-random values.
And that might lead to finding a contract’s vulnerability.
Forge is printing as well some useful information when the test fails.
testWithdraw(uint256) (runs: 47, μ: 19554, ~: 19554)
  • “runs” refers to the number of scenarios the fuzzer tested. By default, the fuzzer will generate 256 scenarios.
  • “μ” (Greek letter mu) is the mean gas used across all fuzz runs
  • “~” (tilde) is the median gas used across all fuzz runs
If this use of foundry already seems impressive, then keep reading. There's much more potential of Foundry to be explored.

Invariant Testing

An invariant test in Foundry is a stateful fuzz test, and this is another way you’re going to be hearing/reading from people who refer to the same type of test.
The key here is that in the same way, as mentioned above, tests in Foundry have to start with the prefix test_ and testFail_, in order to write this type of test you will have to use the prefix invariant.
And now is the time to see Stateful Fuzzing in action
Let’s see this in action by modifying a bit the contract we have used before:
1contract Safe {
2 address public seller = msg.sender;
3 mapping(address => uint256) public balance;
4
5 function deposit() external payable {
6 balance[msg.sender] += msg.value;
7 }
8
9 function withdraw() external {
10 uint256 amount = balance[msg.sender];
11 balance[msg.sender] = 0;
12 (bool s, ) = msg.sender.call{value: amount}("");
13 require(s, "failed to send");
14 }
15
16 function sendSomeEther(address to, uint amount) public {
17 (bool s, ) = to.call{value: amount}("");
18 require(s, "failed to send");
19 }
20}
Here we have modified the withdraw function and added the chance to deposit ether.
The important point we want to test here is that the amount withdrawn must always be the same as the amount deposited.
1contract InvariantSafeTest is Test {
2 Safe safe;
3
4 function setUp() external {
5 safe = new Safe();
6 vm.deal(address(safe), 100 ether); // Sets an address' balance, (who, newBalance)
7 }
8
9 function invariant_withdrawDepositedBalance() external payable {
10 safe.deposit{value: 1 ether}();
11 uint256 balanceBefore = safe.balance(address(this));
12 assertEq(balanceBefore, 1 ether);
13 safe.withdraw();
14 uint256 balanceAfter = safe.balance(address(this));
15 assertGt(balanceBefore, balanceAfter);
16 }
17
18 receive() external payable {}
19}
This is the first output you'll get when you first run this (you’re going to like this):
1% forge test
2[⠢] Compiling...
3[⠒] Compiling 2 files with 0.8.19
4[⠆] Solc 0.8.19 finished in 819.69ms
5Compiler run successful!
6
7Running 1 test for test/Counter.t.sol:InvariantSafeTest
8[PASS] invariant_withdrawDepositedBalance() (runs: 256, calls: 3840, reverts: 883)
9Test result: ok. 1 passed; 0 failed; finished in 199.57ms
Yes, it passes.
But REMEMBER, always read the output. Because in this case, you can see that by running invariant_withdrawDepositedBalance() test, it gets this result:
(runs: 256, calls: 3840, reverts: 883)
And, that’s correct, it is in fact reverting a total of 883 times.
Why didn’t it fail?
Don’t worry, it is an expected result. In order to revert, we have to add the following to the foundry.toml file.
1[invariant]
2fail_on_revert = true
and if you run it again now, the result is different:
1Running 1 test for test/InvariantSafeTest.t.sol:InvariantSafeTest
2[FAIL. Reason: failed to send]
3 [Sequence]
4 sender=0x0000000000000000000000000000000000000003 addr=[src/Counter.sol:Counter]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f
5 calldata=withdraw(), args=[]
6 sender=0x000000000000000000000000000000000000005b addr=[src/Counter.sol:Counter]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f
7 calldata=sendSomeEther(address,uint256),
8 args=[0xa18255fD90ed742e6439A9b834A179E386DC0c1e, 115792089237316195423570985008687907853269984665640564039457584007913129639935]
9
10 invariant_withdrawDepositedBalance() (runs: 1, calls: 2, reverts: 1)
11Test result: FAILED. 0 passed; 1 failed; finished in 9.57ms
There’s a super interesting thing happening here. Please notice the part with
calldata=sendSomeEther(address,uint256)
That is how the output tells you the last function triggered that broke this contract’s invariant.
By adding a function to the contract that can be used to send ether to a different account, the invariant of withdrawing always the same amount of deposited ether is broken.
If we comment out the function sendSomeEther from the contract, then the results would be the following:
1forge test
2[⠆] Compiling...
3[⠊] Compiling 2 files with 0.8.19
4[⠢] Solc 0.8.19 finished in 724.85ms
5Compiler run successful!
6
7Running 1 test for test/InvariantSafeTest.t.sol:InvariantSafeTest
8[PASS] invariant_withdrawDepositedBalance() (runs: 256, calls: 3840, reverts: 0)
9Test result: ok. 1 passed; 0 failed; finished in 218.12ms

Final Thoughts: Make Fuzz Testing Part of Your Security Stack

Fuzz testing offers a proactive, rigorous approach to blockchain security. It helps developers surface vulnerabilities before attackers do by improving not just smart contract security, but ecosystem trust.
As Web3 adoption grows, so does the importance of adopting smart, automated testing strategies like fuzzing. Whether you’re building dApps, deploying DeFi protocols, or auditing third-party contracts, make fuzzing part of your process.
At Zealynx, we specialise in smart contract audits, fuzz testing, and penetration testing to help you uncover vulnerabilities before they become threats. If you're building in Web3 and want peace of mind around security, let’s chat.

oog
zealynx

Subscribe to Our Newsletter

Stay updated with our latest security insights and blog posts

© 2024 Zealynx