Strengthening Smart Contracts: Unit Testing, Fuzzing, and Invariant Testing with Foundry

25 min read

March 16, 2025

Strengthening Smart Contracts: Unit Testing, Fuzzing, and Invariant Testing with Foundry

Table of contents

Over the past few weeks, I’ve been exploring different testing methodologies for smart contracts, and in this chapter, we’ll go through three key techniques to ensure contract reliability and security:

  • Unit Testing – Verifies that individual functions return expected results under controlled conditions.
  • Fuzzing – Generates random, extreme, or unexpected inputs to uncover vulnerabilities.
  • Invariant Testing – Ensures that fundamental rules (such as token supply consistency) always hold, regardless of transaction order or volume.

Testing is the process of verifying that a program behaves as expected across different scenarios. In smart contracts, this is especially important since once deployed, contracts cannot be modified, meaning that any bug or exploit could lead to financial loss, security breaches, or permanently locked funds. Good testing goes beyond checking if a function returns the right value; it also considers edge cases, unexpected inputs, and adversarial conditions to identify potential failures before they reach production.

We’ll work through various examples to demonstrate how each of these techniques can help detect and prevent issues before deployment. For today’s chapter, we’ll focus exclusively on Foundry with its basic configuration, though Foundry provides a wide range of advanced testing options that we’ll explore in future chapters.

By the end of this chapter, you’ll have a solid understanding of how these testing techniques apply to smart contracts, with practical examples of how they help identify and fix vulnerabilities before deployment.

Let’s dive in!

DesertCoin Smart Contract

Now that we understand what testing is and why it’s crucial, let’s look at an example using a modified version of the smart contract we built in the previous article. This time, our ERC20 token will serve as an in-game currency, allowing players to buy items, trade with other users, and even stake their tokens for rewards.

DesertCoin
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";

contract DesertCoin is ERC20, Ownable, Pausable {
    uint256 public faucetAmount = 1000 * (10 ** decimals());
    mapping(address => uint256) public lastFaucetClaim;
    mapping(uint256 => uint256) public itemPrices;
    mapping(address => mapping(uint256 => bool)) public purchasedItems;
    mapping(address => uint256) public stakedBalance;

    event ItemPurchased(address indexed buyer, uint256 indexed itemId);
    event ItemPriceSet(uint256 indexed itemId, uint256 price);
    event TokensStaked(address indexed user, uint256 amount);
    event TokensUnstaked(address indexed user, uint256 amount);

    constructor(uint256 initialSupply) ERC20("DesertCoin", "DSC") Ownable(msg.sender) {
        _mint(msg.sender, initialSupply);
    }

    /**  Faucet to receive tokens */
    function claimFaucet() public {
        require(block.timestamp >= lastFaucetClaim[msg.sender] + 1 days, "Wait 24h to claim again");
        _mint(msg.sender, faucetAmount);
        lastFaucetClaim[msg.sender] = block.timestamp;
    }

    /**  Set price for an in-game item (Only Owner) */
    function setItemPrice(uint256 itemId, uint256 price) public onlyOwner {
        require(price > 0, "Price must be greater than zero");
        itemPrices[itemId] = price;
        emit ItemPriceSet(itemId, price);
    }

    /**  Buy game items */
    function buyItem(uint256 itemId) public {
        require(itemPrices[itemId] > 0, "Item not for sale");
        require(balanceOf(msg.sender) >= itemPrices[itemId], "Not enough DSC");

        _burn(msg.sender, itemPrices[itemId]);
        purchasedItems[msg.sender][itemId] = true;
        emit ItemPurchased(msg.sender, itemId);
    }

    /**  Trade tokens with another player */
    function trade(address to, uint256 amount) public {
        require(balanceOf(msg.sender) >= amount, "Insufficient balance");
        require(to != address(0), "Invalid address");

        _transfer(msg.sender, to, amount);
    }

    /**  Block the tokens for staking */
    function stake(uint256 amount) public {
        require(balanceOf(msg.sender) >= amount, "Insufficient DSC");

        _burn(msg.sender, amount);
        stakedBalance[msg.sender] += amount;
        emit TokensStaked(msg.sender, amount);
    }

    function unstake(uint256 amount) public {
        require(stakedBalance[msg.sender] >= amount, "Not enough staked");

        _mint(msg.sender, amount);
        stakedBalance[msg.sender] -= amount;
        emit TokensUnstaked(msg.sender, amount);
    }

    /** ️ Pause the contract */
    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    /**  Prevent transfers when paused */
    function _update(address from, address to, uint256 amount) internal virtual override {
        require(!paused(), "ERC20Pausable: token transfer while paused");
        super._update(from, to, amount);
    }
}

At the core of the contract, we inherit from OpenZeppelin’s ERC20, Ownable, and Pausable contracts:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";

This not only simplifies the implementation but also ensures that our token follows the ERC20 standard, includes ownership-based restrictions, and provides a built-in security mechanism that allows us to pause operations if needed.

To manage different aspects of the game, the contract includes several global variables:

uint256 public faucetAmount = 1000 * (10 ** decimals());
    mapping(address => uint256) public lastFaucetClaim;
    mapping(uint256 => uint256) public itemPrices;
    mapping(address => mapping(uint256 => bool)) public purchasedItems;
    mapping(address => uint256) public stakedBalance;

These variables help track faucet claims (ensuring users can only claim tokens once per day), store item prices, register which items have been purchased by each player, and maintain a record of staked balances.

We also define key events to log important actions, such as when an item is purchased, a new item price is set, or tokens are staked or unstaked:

    event ItemPurchased(address indexed buyer, uint256 indexed itemId);
    event ItemPriceSet(uint256 indexed itemId, uint256 price);
    event TokensStaked(address indexed user, uint256 amount);
    event TokensUnstaked(address indexed user, uint256 amount);

These events play a crucial role in tracking blockchain activity, as they allow external applications and users to monitor contract interactions.

The constructor initializes the token’s name, symbol, and initial supply while assigning ownership:

constructor(uint256 initialSupply) ERC20("DesertCoin", "DSC") Ownable(msg.sender) {
        _mint(msg.sender, initialSupply);
    }

From here, we introduce several key functions. The first is claimFaucet, which allows users to receive free tokens every 24 hours:

    /**  Faucet to receive tokens */
    function claimFaucet() public {
        require(block.timestamp >= lastFaucetClaim[msg.sender] + 1 days, "Wait 24h to claim again");
        _mint(msg.sender, faucetAmount);
        lastFaucetClaim[msg.sender] = block.timestamp;
    }

This ensures that players can periodically receive small amounts of DesertCoin without draining the total supply.

Next, we have a function that lets the owner set the price of in-game items. This prevents unauthorized modifications that could disrupt the economy:

/**  Set price for an in-game item (Only Owner) */
    function setItemPrice(uint256 itemId, uint256 price) public onlyOwner {
        require(price > 0, "Price must be greater than zero");
        itemPrices[itemId] = price;
        emit ItemPriceSet(itemId, price);
    }

The buyItem function allows players to purchase in-game items using DSC tokens. When an item is bought, the corresponding amount of tokens is burned from the buyer’s balance, and the purchase is registered:

    /**  Buy game items */
    function buyItem(uint256 itemId) public {
        require(itemPrices[itemId] > 0, "Item not for sale");
        require(balanceOf(msg.sender) >= itemPrices[itemId], "Not enough DSC");

        _burn(msg.sender, itemPrices[itemId]);
        purchasedItems[msg.sender][itemId] = true;
        emit ItemPurchased(msg.sender, itemId);
    }

We also introduce a trading mechanism that allows players to transfer tokens among themselves:

function trade(address to, uint256 amount) public {
    require(balanceOf(msg.sender) >= amount, "Insufficient balance");
    require(to != address(0), "Invalid address");

    _transfer(msg.sender, to, amount);
}

Next, we implement a staking system where players can stake their tokens. Staked tokens are burned, and the balance is recorded:

  /**  Block the tokens for staking */
    function stake(uint256 amount) public {
        require(balanceOf(msg.sender) >= amount, "Insufficient DSC");

        _burn(msg.sender, amount);
        stakedBalance[msg.sender] += amount;
        emit TokensStaked(msg.sender, amount);
    }

Users can unstake their tokens at any time, which mints them back into circulation:


    function unstake(uint256 amount) public {
        require(stakedBalance[msg.sender] >= amount, "Not enough staked");

        _mint(msg.sender, amount);
        stakedBalance[msg.sender] -= amount;
        emit TokensUnstaked(msg.sender, amount);
    }

For security reasons, we also introduce pause and unpause functions, allowing the contract owner to disable transfers and other interactions in case of emergency:

    /** ️ Pause the contract */
    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

Finally, we override the _update function to prevent token transfers when the contract is paused:

/**  Prevent transfers when paused */
    function _update(address from, address to, uint256 amount) internal virtual override {
        require(!paused(), "ERC20Pausable: token transfer while paused");
        super._update(from, to, amount);
    }

Verifying Core Functionality with Unit Tests

In previous articles, we’ve explored how to use Foundry for testing, emulating exploits, and simulating different attack scenarios. By now, you should be comfortable with writing and running tests in Foundry, but today, we’re shifting our focus to unit testing—a fundamental part of any smart contract audit.

When auditing a project, developers typically provide a suite of unit tests alongside the smart contract. These tests serve as the first line of defense against bugs and logic errors, ensuring that the contract behaves as expected under normal conditions. However, as auditors, we can’t just rely on the tests they provide. Unit tests are usually written to confirm that functions return the correct values, but they don’t always explore edge cases, adversarial conditions, or the unintended ways a contract could break. That’s where fuzzing and invariant testing come in—but we’ll get to those later.

Unit Testing of DesertCoin
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Test.sol";
import "../src/DesertCoin.sol";

contract DesertCoinTest is Test {
    DesertCoin desertCoin;
    address owner = address(1);
    address user1 = address(2);
    address user2 = address(3);

    uint256 initialSupply = 1_000_000 ether;

    function setUp() public {
        vm.startPrank(owner); // Set the owner for testing
        desertCoin = new DesertCoin(initialSupply);
        desertCoin.transfer(user1, 1000 ether); // Give some tokens to user1
        vm.stopPrank();
    }

    /**  1. Faucet */
    function testClaimFaucet() public {
        vm.warp(block.timestamp + 1 days); // Simulate 24h passing

        vm.prank(user1);
        desertCoin.claimFaucet();

        assertEq(
            desertCoin.balanceOf(user1),
            1000 ether + desertCoin.faucetAmount()
        );
    }

    function testFailClaimFaucetTwice() public {
        vm.prank(user1);
        desertCoin.claimFaucet();

        vm.prank(user1);
        desertCoin.claimFaucet(); // Should fail because only once every 24h
    }

    /**  2. Marketplace */
    function testSetItemPrice() public {
        vm.prank(owner);
        desertCoin.setItemPrice(1, 500 ether);

        assertEq(desertCoin.itemPrices(1), 500 ether);
    }

    function testFailSetItemPriceByNonOwner() public {
        vm.prank(user1);
        desertCoin.setItemPrice(1, 500 ether); // Should fail since user1 is not the owner
    }

    function testBuyItem() public {
        vm.prank(owner);
        desertCoin.setItemPrice(1, 500 ether);

        vm.prank(user1);
        desertCoin.buyItem(1);

        assertTrue(desertCoin.purchasedItems(user1, 1));
        assertEq(desertCoin.balanceOf(user1), 500 ether);
    }

    function testFailBuyItemInsufficientFunds() public {
        vm.prank(owner);
        desertCoin.setItemPrice(1, 2000 ether); // More than user's balance

        vm.prank(user1);
        desertCoin.buyItem(1); // Should fail
    }

    /**  3. Transfers */
    function testTrade() public {
        vm.prank(user1);
        desertCoin.trade(user2, 500 ether);

        assertEq(desertCoin.balanceOf(user1), 500 ether);
        assertEq(desertCoin.balanceOf(user2), 500 ether);
    }

    function testFailTradeInsufficientBalance() public {
        vm.prank(user1);
        desertCoin.trade(user2, 2000 ether); // Should fail (not enough balance)
    }

    function testFailTradeWhilePaused() public {
        vm.prank(owner);
        desertCoin.pause();

        vm.prank(user1);
        desertCoin.trade(user2, 100 ether); // Should fail due to pause
    }

    function testTradeAfterUnpause() public {
        vm.prank(owner);
        desertCoin.pause();

        vm.prank(owner);
        desertCoin.unpause();

        vm.prank(user1);
        desertCoin.trade(user2, 100 ether); // Should succeed
    }

    /** 4. Staking */
    function testStake() public {
        vm.prank(user1);
        desertCoin.stake(500 ether);

        assertEq(desertCoin.stakedBalance(user1), 500 ether);
        assertEq(desertCoin.balanceOf(user1), 500 ether);
    }

    function testFailStakeInsufficientFunds() public {
        vm.prank(user1);
        desertCoin.stake(2000 ether); // Should fail
    }

    function testUnstake() public {
        vm.prank(user1);
        desertCoin.stake(500 ether);

        vm.prank(user1);
        desertCoin.unstake(500 ether);

        assertEq(desertCoin.stakedBalance(user1), 0);
        assertEq(desertCoin.balanceOf(user1), 1000 ether);
    }

    function testFailUnstakeMoreThanStaked() public {
        vm.prank(user1);
        desertCoin.stake(500 ether);

        vm.prank(user1);
        desertCoin.unstake(1000 ether); // Should fail
    }

    /** 5. Pausing */
    function testPauseAndUnpause() public {
        vm.prank(owner);
        desertCoin.pause();

        vm.prank(owner);
        vm.expectRevert("ERC20Pausable: token transfer while paused");
        desertCoin.transfer(user1, 100 ether); // Should fail

        vm.prank(owner);
        desertCoin.unpause();

        vm.prank(owner);
        desertCoin.transfer(user1, 100 ether); // Should succeed
    }
}

For now, let’s analyze a unit test suite for DesertCoin, written in Foundry. This will help us understand what developers typically test, what gaps might exist, and how we can extend our testing methodology beyond the basics.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "forge-std/Test.sol";
import "../src/DesertCoin.sol";

contract DesertCoinTest is Test {
    DesertCoin desertCoin;
    address owner = address(1);
    address user1 = address(2);
    address user2 = address(3);

    uint256 initialSupply = 1_000_000 ether;

    function setUp() public {
        vm.startPrank(owner);
        desertCoin = new DesertCoin(initialSupply);
        desertCoin.transfer(user1, 1000 ether);
        vm.stopPrank();
    }

The setUp function is executed before each test, ensuring a clean and predictable environment. It deploys the contract, assigns initial tokens to user1, and sets owner as the privileged address. This setup mirrors the kind of controlled conditions developers use when writing unit tests—they aim to verify that the contract performs as expected under normal circumstances.

Testing the Faucet System

One of the first things tested is the faucet function, which allows users to claim tokens every 24 hours.

function testClaimFaucet() public {
    vm.warp(block.timestamp + 1 days);

    vm.prank(user1);
    desertCoin.claimFaucet();

    assertEq(desertCoin.balanceOf(user1), 1000 ether + desertCoin.faucetAmount());
}

Here, vm.warp is used to simulate time passing, ensuring that the user can successfully claim tokens after the cooldown period. A common mistake in contracts with time-based restrictions is failing to properly enforce delays, so verifying this behavior is crucial.

The following test checks that users can’t claim the faucet reward twice within 24 hours:

function testFailClaimFaucetTwice() public {
    vm.prank(user1);
    desertCoin.claimFaucet();

    vm.prank(user1);
    desertCoin.claimFaucet(); // Should fail
}

This confirms that the cooldown period is correctly enforced, preventing users from draining the faucet balance.

Testing the Marketplace

The next set of tests focuses on the marketplace functionality, ensuring that only the contract owner can set item prices:

function testSetItemPrice() public {
    vm.prank(owner);
    desertCoin.setItemPrice(1, 500 ether);

    assertEq(desertCoin.itemPrices(1), 500 ether);
}

If a regular user attempts to set an item price, the transaction should fail:

function testFailSetItemPriceByNonOwner() public {
    vm.prank(user1);
    desertCoin.setItemPrice(1, 500 ether); // Should fail
}

Once prices are set, users should be able to purchase items:

function testBuyItem() public {
    vm.prank(owner);
    desertCoin.setItemPrice(1, 500 ether);

    vm.prank(user1);
    desertCoin.buyItem(1);

    assertTrue(desertCoin.purchasedItems(user1, 1));
    assertEq(desertCoin.balanceOf(user1), 500 ether);
}

However, buying an item without enough balance should not be possible:

function testFailBuyItemInsufficientFunds() public {
    vm.prank(owner);
    desertCoin.setItemPrice(1, 2000 ether);

    vm.prank(user1);
    desertCoin.buyItem(1); // Should fail
}

Testing Transfers

The trade function in DesertCoin allows users to transfer tokens to one another. This is a basic ERC20 feature, but since we’ve modified the contract to include staking, pausing, and other mechanisms, it’s important to verify that transfers function correctly in all conditions.

First, let’s check that a valid transfer between two users works as expected:

function testTrade() public {
    vm.prank(user1);
    desertCoin.trade(user2, 500 ether);

    assertEq(desertCoin.balanceOf(user1), 500 ether);
    assertEq(desertCoin.balanceOf(user2), 500 ether);
}

This confirms that user1 can successfully send 500 DSC tokens to user2, reducing user1’s balance and increasing user2’s accordingly.

However, if a user attempts to transfer more tokens than they own, the transaction should fail:

function testFailTradeInsufficientBalance() public {
    vm.prank(user1);
    desertCoin.trade(user2, 2000 ether); // Should fail (not enough balance)
}

This ensures that balance checks are enforced, preventing users from sending tokens they don’t have.

One crucial edge case to test is how the pause function affects transfers. Since DesertCoin implements Pausable, we need to verify that transactions are blocked when the contract is paused:

function testFailTradeWhilePaused() public {
    vm.prank(owner);
    desertCoin.pause();

    vm.prank(user1);
    desertCoin.trade(user2, 100 ether); // Should fail due to pause
}

Finally, we check that after unpausing, transfers resume as expected:

function testTradeAfterUnpause() public {
    vm.prank(owner);
    desertCoin.pause();
    
    vm.prank(owner);
    desertCoin.unpause();

    vm.prank(user1);
    desertCoin.trade(user2, 100 ether); // Should succeed
}

Testing Staking and Unstaking

Next, we have staking and unstaking, where users can lock up tokens in the contract.

function testStake() public {
    vm.prank(user1);
    desertCoin.stake(500 ether);

    assertEq(desertCoin.stakedBalance(user1), 500 ether);
    assertEq(desertCoin.balanceOf(user1), 500 ether);
}

This verifies that staked tokens are deducted from the user’s balance and correctly reflected in the stakedBalance mapping.

If a user tries to stake more tokens than they have, the function should revert:

function testFailStakeInsufficientFunds() public {
    vm.prank(user1);
    desertCoin.stake(2000 ether); // Should fail
}

Unstaking should restore the user’s balance:

function testUnstake() public {
    vm.prank(user1);
    desertCoin.stake(500 ether);

    vm.prank(user1);
    desertCoin.unstake(500 ether);

    assertEq(desertCoin.stakedBalance(user1), 0);
    assertEq(desertCoin.balanceOf(user1), 1000 ether);
}

If a user tries to unstake more than they have, the function should fail:

function testFailUnstakeMoreThanStaked() public {
    vm.prank(user1);
    desertCoin.stake(500 ether);

    vm.prank(user1);
    desertCoin.unstake(1000 ether); // Should fail
}

Testing the Pause Mechanism

Finally, we test the pause and unpause functions, ensuring that transfers are blocked while paused.

function testPauseAndUnpause() public {
    vm.prank(owner);
    desertCoin.pause();

    vm.prank(owner);
    vm.expectRevert("ERC20Pausable: token transfer while paused");
    desertCoin.transfer(user1, 100 ether); // Should fail

    vm.prank(owner);
    desertCoin.unpause();

    vm.prank(owner);
    desertCoin.transfer(user1, 100 ether); // Should succeed
}

This test ensures that the pause mechanism works as intended, preventing transfers while active and resuming them once disabled.

Running the Tests with Foundry

Once our test suite is written, we can run it using Foundry’s forge test command. Running these tests is a crucial step in auditing a smart contract, as it allows us to quickly verify that all unit tests pass before diving into more advanced testing techniques like fuzzing and invariant testing.

To execute the tests for DesertCoin, navigate to the root directory of your project and run:

forge test 

Upon execution, Foundry will compile the contracts, run all the test cases, and display a summary of the results. Here’s an example output:

[⠒] Compiling...
[⠊] Compiling 2 files with Solc 0.8.28
[⠒] Solc 0.8.28 finished in 776.29ms
Compiler run successful!

Ran 15 tests for test/DesertCoin.t.sol:DesertCoinTest
[PASS] testBuyItem() (gas: 80049)
[PASS] testClaimFaucet() (gas: 52558)
[PASS] testFailBuyItemInsufficientFunds() (gas: 42315)
[PASS] testFailClaimFaucetTwice() (gas: 12881)
[PASS] testFailSetItemPriceByNonOwner() (gas: 12763)
[PASS] testFailStakeInsufficientFunds() (gas: 12835)
[PASS] testFailTradeInsufficientBalance() (gas: 15086)
[PASS] testFailTradeWhilePaused() (gas: 25084)
[PASS] testFailUnstakeMoreThanStaked() (gas: 50519)
[PASS] testPauseAndUnpause() (gas: 36459)
[PASS] testSetItemPrice() (gas: 37767)
[PASS] testStake() (gas: 52111)
[PASS] testTrade() (gas: 48063)
[PASS] testTradeAfterUnpause() (gas: 52444)
[PASS] testUnstake() (gas: 42420)
Suite result: ok. 15 passed; 0 failed; 0 skipped; finished in 1.20ms (1.43ms CPU time)

Ran 1 test suite in 5.71ms (1.20ms CPU time): 15 tests passed, 0 failed, 0 skipped (15 total tests)

Each line in the output provides valuable insights:

  • [PASS] indicates a successful test.
  • The gas usage for each function is displayed, which is useful for optimizing contract efficiency.
  • The summary at the end confirms that all tests passed, none failed, and none were skipped.
  • The execution time is also displayed, showing how quickly the tests ran.

How to Identify and Address Untested Code

When auditing a smart contract, one of the key challenges is identifying unverified or weakly tested parts of the code. Even if developers provide a suite of unit tests, they often focus on expected behaviors rather than edge cases or adversarial conditions. This means that vulnerabilities could exist in functions that haven't been thoroughly tested.

To help detect these gaps, Foundry provides the forge coverage command. This tool generates a code coverage report, showing which parts of the contract have been executed during testing and which haven't. The untested sections are potential risk areas, as they might contain logic flaws or vulnerabilities that were never examined under real test conditions.

To illustrate this, let’s take an example: What happens if we comment out the test cases for trade()?

    /** 🔄 3. Transfers */
    //function testTrade() public {
    //    vm.prank(user1);
    //    desertCoin.trade(user2, 500 ether);

    //    assertEq(desertCoin.balanceOf(user1), 500 ether);
    //    assertEq(desertCoin.balanceOf(user2), 500 ether);
    //}

    //function testFailTradeInsufficientBalance() public {
    //    vm.prank(user1);
    //    desertCoin.trade(user2, 2000 ether); // Should fail (not enough balance)
    //}

    //function testFailTradeWhilePaused() public {
    //    vm.prank(owner);
    //    desertCoin.pause();

    //    vm.prank(user1);
    //    desertCoin.trade(user2, 100 ether); // Should fail due to pause
    //}

    //function testTradeAfterUnpause() public {
    //    vm.prank(owner);
    //    desertCoin.pause();

    //    vm.prank(owner);
    //    desertCoin.unpause();

    //    vm.prank(user1);
    //    desertCoin.trade(user2, 100 ether); // Should succeed
    //}

To analyze test coverage for DesertCoin, we can run:

$ forge coverage test/DesertCoin.t.sol  --report lcov 
[⠊] Compiling...
[⠃] Compiling 30 files with Solc 0.8.28
[⠒] Solc 0.8.28 finished in 1.82s
Compiler run successful with warnings:
[...]

Ran 11 tests for test/DesertCoin.t.sol:DesertCoinTest
[PASS] testBuyItem() (gas: 84531)
[PASS] testClaimFaucet() (gas: 54709)
[PASS] testFailBuyItemInsufficientFunds() (gas: 44471)
[PASS] testFailClaimFaucetTwice() (gas: 13321)
[PASS] testFailSetItemPriceByNonOwner() (gas: 14118)
[PASS] testFailStakeInsufficientFunds() (gas: 13740)
[PASS] testFailUnstakeMoreThanStaked() (gas: 52636)
[PASS] testPauseAndUnpause() (gas: 40735)
[PASS] testSetItemPrice() (gas: 40053)
[PASS] testStake() (gas: 55208)
[PASS] testUnstake() (gas: 45871)
Suite result: ok. 11 passed; 0 failed; 0 skipped; finished in 41.54ms (4.39ms CPU time)

This confirms that all tests passed, but it doesn’t tell us which parts of the contract were left untested. To visualize the coverage details, we generate an HTML report:

$ genhtml --rc derive_function_end_line=0 -o coverage-report lcov.info
Found 1 entries.
Found common filename prefix "/home/rsgbengi/Projects/web3/FuzzTesting"
Generating output.
Processing file src/DesertCoin.sol
  lines=27 hit=24 functions=10 hit=9
Overall coverage rate:
  lines......: 88.9% (24 of 27 lines)
  functions......: 90.0% (9 of 10 functions)

Examining the LCOV report, we see that most of the contract is covered, except for the trade function.

Since we commented out the trade tests, this function was never executed during testing. The red-highlighted lines in the LCOV report confirm this.

This is a major red flag because:

  1. Balance validation wasn’t tested – What if _transfer() is incorrectly handling balances?
  2. Zero-address transfers weren’t checked – Could this be exploited to send tokens to an invalid destination?
  3. Pause functionality might not be enforced – Does trade() still execute if the contract is paused?

If this function contained a security flaw, it would have gone unnoticed because no test ever triggered it.

Advanced Testing: Pushing Smart Contracts Beyond Unit Tests

Up to this point, we’ve focused on unit testing, which developers typically provide when delivering a smart contract for audit. These tests ensure that basic functionality works as expected, but they don’t tell us how the contract behaves under unpredictable conditions, adversarial inputs, or complex interactions over time. This is where fuzzing and invariant testing come in.

Unlike unit tests, these advanced techniques aren’t usually included in the initial test suite. As auditors, it’s up to us to implement them ourselves or have a prepared set of cases to quickly assess vulnerabilities depending on the contract we’re analyzing. By incorporating these methods into our workflow, we can uncover issues that traditional tests might miss.

Thinking Like an Attacker: Brainstorming Potential Threats

Before jumping into fuzzing and invariant testing, it’s important to take a step back and think about the contract from an attacker's perspective. Instead of just verifying that functions return expected outputs, we should ask ourselves:

  • What are the most critical functions in this contract?
  • How could an attacker break them?
  • Are there any unintended behaviors if we pass extreme or malformed inputs?
  • Could race conditions, reentrancy, or gas manipulation cause issues?
  • What happens if multiple users interact at the same time?

This type of threat modeling helps us design better fuzzing tests. By brainstorming potential weaknesses, we can identify which areas deserve aggressive testing and ensure that our test cases reflect realistic attack scenarios.

Introducing Fuzzing: Breaking the Contract with Unexpected Inputs

Now that we’ve thought like an attacker and considered potential threats, it’s time to apply fuzzing to stress-test the contract. Unlike unit tests, which check for specific inputs and expected outputs, fuzzing generates random or extreme values to test contract behavior under unpredictable conditions.

The goal of fuzzing is to break the contract—or at least find unexpected behaviors that developers might not have accounted for. This technique helps us uncover vulnerabilities such as:

  • Integer overflows and underflows
  • Logic errors caused by edge cases
  • Unexpected reverts or unhandled failures
  • State inconsistencies when multiple users interact
  • Gas exhaustion vulnerabilities

As auditors, we rarely receive fuzzing tests from developers, meaning it’s up to us to implement them ourselves. Depending on the type of contract we’re analyzing, we can define a set of fuzzing cases in advance to check for common vulnerabilities. For example, in ERC20-based tokens, we might want to test transfers, staking, and marketplace interactions under extreme conditions.

To illustrate how we can implement fuzzing, let’s walk through a series of examples using Foundry.

Fuzzing testing of DesertCoin
import "forge-std/Test.sol";
import "../src/DesertCoin.sol";

contract DesertCoinFuzzTest is Test {
    DesertCoin desertCoin;
    address owner = address(1);
    address user1 = address(2);
    address user2 = address(3);

    uint256 initialSupply = 1_000_000 ether;

    function setUp() public {
        vm.startPrank(owner);
        desertCoin = new DesertCoin(initialSupply);
        desertCoin.transfer(user1, 1000 ether);
        vm.stopPrank();
    }

    /** Fuzz test for trade() */
    function testFuzzTrade(uint256 amount) public {
        vm.assume(amount > 0 && amount <= 1000 ether);

        uint256 initialBalanceUser1 = desertCoin.balanceOf(user1);
        uint256 initialBalanceUser2 = desertCoin.balanceOf(user2);

        if (amount <= initialBalanceUser1) {
            vm.prank(user1);
            desertCoin.trade(user2, amount);

            assertEq(desertCoin.balanceOf(user1), initialBalanceUser1 - amount);
            assertEq(desertCoin.balanceOf(user2), initialBalanceUser2 + amount);
        } else {
            vm.expectRevert("Insufficient balance");
            vm.prank(user1);
            desertCoin.trade(user2, amount);
        }
    }

    /** Fuzz test for stake() */
    function testFuzzStake(uint256 amount) public {
        vm.assume(amount > 0 && amount <= 1000 ether);

        uint256 initialBalance = desertCoin.balanceOf(user1);

        if (amount <= initialBalance) {
            vm.prank(user1);
            desertCoin.stake(amount);

            assertEq(desertCoin.stakedBalance(user1), amount);
            assertEq(desertCoin.balanceOf(user1), initialBalance - amount);
        } else {
            vm.expectRevert("Insufficient DSC");
            vm.prank(user1);
            desertCoin.stake(amount);
        }
    }

    /** Fuzz test for unstake() */
    function testFuzzUnstake(uint256 amount) public {
        vm.assume(amount > 0 && amount <= 1000 ether);

        vm.prank(user1);
        desertCoin.stake(500 ether);

        uint256 initialStaked = desertCoin.stakedBalance(user1);
        uint256 initialBalance = desertCoin.balanceOf(user1);

        if (amount <= initialStaked) {
            vm.prank(user1);
            desertCoin.unstake(amount);

            assertEq(desertCoin.stakedBalance(user1), initialStaked - amount);
            assertEq(desertCoin.balanceOf(user1), initialBalance + amount);
        } else {
            vm.expectRevert("Not enough staked");
            vm.prank(user1);
            desertCoin.unstake(amount);
        }
    }

    /** Fuzz test for buyItem() */
    function testFuzzBuyItem(uint256 price) public {
        vm.assume(price > 0 && price <= 1000 ether);

        vm.prank(owner);
        desertCoin.setItemPrice(1, price);

        uint256 initialBalance = desertCoin.balanceOf(user1);

        if (price <= initialBalance) {
            vm.prank(user1);
            desertCoin.buyItem(1);

            assertTrue(desertCoin.purchasedItems(user1, 1));
            assertEq(desertCoin.balanceOf(user1), initialBalance - price);
        } else {
            vm.expectRevert("Not enough DSC");
            vm.prank(user1);
            desertCoin.buyItem(1);
        }
    }

    /** Fuzz test for setItemPrice() */
    function testFuzzSetItemPrice(uint256 price) public {
        vm.assume(price > 0 && price <= 1000 ether);

        vm.prank(owner);
        desertCoin.setItemPrice(1, price);

        assertEq(desertCoin.itemPrices(1), price);
    }

    function testFailFuzzSetItemPriceByNonOwner(uint256 price) public {
        vm.assume(price > 0 && price <= 1000 ether);

        vm.prank(user1);
        desertCoin.setItemPrice(1, price); // Should fail
    }
}

Fuzzing the Trade Function

Transfers between users are one of the most common operations in ERC20-based contracts. If not properly tested, they can be exploited to manipulate balances, bypass restrictions, or trigger unintended behaviors. To ensure the reliability of transfers, it's important to test various scenarios. This includes verifying that random trade amounts work correctly for any valid input, ensuring that users cannot send more tokens than they own to prevent negative balances, and confirming that zero-value transfers don’t break contract logic. Additionally, handling extreme values, such as the maximum uint256, helps uncover potential overflow issues that could lead to unexpected contract behavior.

function testFuzzTrade(uint256 amount) public {
    vm.assume(amount > 0 && amount <= 1000 ether);

    uint256 initialBalanceUser1 = desertCoin.balanceOf(user1);
    uint256 initialBalanceUser2 = desertCoin.balanceOf(user2);

    if (amount <= initialBalanceUser1) {
        vm.prank(user1);
        desertCoin.trade(user2, amount);

        assertEq(desertCoin.balanceOf(user1), initialBalanceUser1 - amount);
        assertEq(desertCoin.balanceOf(user2), initialBalanceUser2 + amount);
    } else {
        vm.expectRevert("Insufficient balance");
        vm.prank(user1);
        desertCoin.trade(user2, amount);
    }
}

Fuzzing the Staking Mechanism

Staking requires burning tokens, so miscalculations could lead to lost funds or negative balances. To ensure proper functionality, it's crucial to test different stake amounts, verifying that balance updates are correctly tracked. Users should not be able to stake more than they own, preventing unintended losses. Additionally, testing extreme values like 0 or MAX_UINT256 helps identify unexpected behaviors that could compromise the contract's integrity.

function testFuzzStake(uint256 amount) public {
    vm.assume(amount > 0 && amount <= 1000 ether);

    uint256 initialBalance = desertCoin.balanceOf(user1);

    if (amount <= initialBalance) {
        vm.prank(user1);
        desertCoin.stake(amount);

        assertEq(desertCoin.stakedBalance(user1), amount);
        assertEq(desertCoin.balanceOf(user1), initialBalance - amount);
    } else {
        vm.expectRevert("Insufficient DSC");
        vm.prank(user1);
        desertCoin.stake(amount);
    }
}

Fuzzing the Unstaking Function

Users should only be able to unstake the amount they have actually staked. If not properly enforced, this could lead to fund inflation or unauthorized withdrawals. To ensure correct behavior, testing should cover various unstake amounts, verifying that users cannot withdraw more than their staked balance. Additionally, it's important to confirm that balances update correctly after unstaking and to test multiple sequential unstake calls to detect potential inconsistencies.

function testFuzzUnstake(uint256 amount) public {
    vm.assume(amount > 0 && amount <= 1000 ether);

    vm.prank(user1);
    desertCoin.stake(500 ether);

    uint256 initialStaked = desertCoin.stakedBalance(user1);
    uint256 initialBalance = desertCoin.balanceOf(user1);

    if (amount <= initialStaked) {
        vm.prank(user1);
        desertCoin.unstake(amount);

        assertEq(desertCoin.stakedBalance(user1), initialStaked - amount);
        assertEq(desertCoin.balanceOf(user1), initialBalance + amount);
    } else {
        vm.expectRevert("Not enough staked");
        vm.prank(user1);
        desertCoin.unstake(amount);
    }
}

Fuzzing the Buy Item Function

Buying in-game items with DesertCoin requires accurate balance checks. Miscalculations could allow users to obtain items for free, spend negative amounts due to unchecked math, or disrupt the in-game economy. Testing should include a range of item prices to ensure flexibility, verifying that users cannot purchase items without sufficient balance. It’s also crucial to handle edge cases, such as zero-price items or unusually large values, to prevent unintended behaviors.

function testFuzzBuyItem(uint256 price) public {
    vm.assume(price > 0 && price <= 1000 ether);

    vm.prank(owner);
    desertCoin.setItemPrice(1, price);

    uint256 initialBalance = desertCoin.balanceOf(user1);

    if (price <= initialBalance) {
        vm.prank(user1);
        desertCoin.buyItem(1);

        assertTrue(desertCoin.purchasedItems(user1, 1));
        assertEq(desertCoin.balanceOf(user1), initialBalance - price);
    } else {
        vm.expectRevert("Not enough DSC");
        vm.prank(user1);
        desertCoin.buyItem(1);
    }
}

Running the test

Now that we have implemented fuzzing tests for key functions in DesertCoin, let's see how we can execute them and analyze the results. We'll demonstrate how fuzzing initially passes all tests, and then, after introducing a bug in unstake(), the test suite detects the issue.

Initially, we run the fuzzing tests without modifying the contract:

forge test test/DesertCoinFuzzing.t.sol

The output confirms that all fuzzing tests pass successfully:

To simulate a real-world vulnerability, we modify the unstake function to remove the balance check:

function unstake(uint256 amount) public {
        //require(stakedBalance[msg.sender] >= amount, "Not enough staked");

        _mint(msg.sender, amount);
        stakedBalance[msg.sender] -= amount;
        emit TokensUnstaked(msg.sender, amount);
    }

This allows users to unstake more tokens than they actually staked, effectively creating free tokens out of thin air.

Now, we rerun the same fuzzing tests:

forge test test/DesertCoinFuzzing.t.sol

This time, the fuzzer detects an issue in testFuzzUnstake():

The test fails because the function allows negative staked balances, triggering an arithmetic underflow.

Invariant Testing: Ensuring Smart Contract Stability Over Time

Traditional unit tests focus on individual function calls, verifying expected inputs and outputs. However, smart contracts often experience unpredictable interactions, where users call functions in varying sequences. Invariant testing ensures that a contract’s fundamental properties always hold, regardless of how functions are executed.

Foundry automates this process by randomly selecting and executing contract functions with fuzzed inputs, simulating real-world usage. After each call, it checks whether the contract still satisfies predefined invariants—such as token supply consistency, balance integrity, or state transitions. If an invariant breaks, Foundry halts the test and provides a detailed trace, making it easy to pinpoint vulnerabilities.

Unlike standard tests that examine isolated cases, invariant testing runs hundreds or thousands of transactions in a single test, exposing subtle bugs that emerge over time. This makes it an essential tool for smart contract security, especially in financial protocols where stability and consistency are critical. Let's see some examples:

Invariant Testing DesertCoin
pragma solidity ^0.8.19;

import "forge-std/Test.sol";
import "forge-std/StdInvariant.sol";
import "../src/DesertCoin.sol";

contract DesertCoinInvariantTest is StdInvariant, Test {
    DesertCoin desertCoin;
    address owner = address(1);
    address user1 = address(2);
    address user2 = address(3);

    uint256 initialSupply = 1_000_000 ether;

    function setUp() public {
        vm.startPrank(owner);
        desertCoin = new DesertCoin(initialSupply);
        desertCoin.transfer(user1, 1000 ether);
        desertCoin.transfer(user2, 1000 ether);
        vm.stopPrank();

        // Register contract for invariant testing
        targetContract(address(desertCoin));
    }

    function invariant_totalSupplyConstant() public {
        assertEq(desertCoin.totalSupply(), initialSupply);
    }

    function invariant_tradeDoesNotDestroyTokens() public {
        address sender = user1;
        address recipient = user2;
        uint256 tradeAmount = 10 ether;

        uint256 balanceSenderBefore = desertCoin.balanceOf(sender);
        uint256 balanceRecipientBefore = desertCoin.balanceOf(recipient);
        uint256 totalSupplyBefore = desertCoin.totalSupply();

        if (balanceSenderBefore >= tradeAmount) {
            vm.prank(sender);
            desertCoin.trade(recipient, tradeAmount);
        }

        uint256 balanceSenderAfter = desertCoin.balanceOf(sender);
        uint256 balanceRecipientAfter = desertCoin.balanceOf(recipient);
        uint256 totalSupplyAfter = desertCoin.totalSupply();

        assertEq(
            balanceSenderBefore + balanceRecipientBefore,
            balanceSenderAfter + balanceRecipientAfter
        );
        assertEq(totalSupplyBefore, totalSupplyAfter);
    }

    function invariant_stakedBalanceCorrect() public {
        address staker = user1;
        uint256 stakeAmount = 10 ether;

        uint256 balanceBefore = desertCoin.balanceOf(staker);
        uint256 stakedBefore = desertCoin.stakedBalance(staker);
        uint256 totalSupplyBefore = desertCoin.totalSupply();

        if (balanceBefore >= stakeAmount) {
            vm.prank(staker);
            desertCoin.stake(stakeAmount);
        }

        uint256 balanceAfter = desertCoin.balanceOf(staker);
        uint256 stakedAfter = desertCoin.stakedBalance(staker);
        uint256 totalSupplyAfter = desertCoin.totalSupply();

        assertEq(balanceBefore - balanceAfter, stakedAfter - stakedBefore);
        assertEq(totalSupplyBefore, totalSupplyAfter);
    }

    function invariant_validItemPurchases() public {
        uint256 itemId = 1;
        uint256 itemPrice = 50 ether;

        // Owner establece el precio del ítem
        vm.prank(owner);
        desertCoin.setItemPrice(itemId, itemPrice);

        uint256 balanceBefore = desertCoin.balanceOf(user1);

        if (balanceBefore >= itemPrice) {
            vm.prank(user1);
            desertCoin.buyItem(itemId);
        } else {
            vm.expectRevert("Not enough DSC");
            vm.prank(user1);
            desertCoin.buyItem(itemId);
        }

        uint256 balanceAfter = desertCoin.balanceOf(user1);
        assertLe(balanceAfter, balanceBefore);
    }


    function invariant_cannotUnstakeMoreThanStaked() public {
        address staker = user1;
        uint256 unstakeAmount = 20 ether;

        uint256 stakedBalanceBefore = desertCoin.stakedBalance(staker);
        uint256 balanceBefore = desertCoin.balanceOf(staker);

        if (unstakeAmount > stakedBalanceBefore) {
            vm.expectRevert("Not enough staked");
            vm.prank(staker);
            desertCoin.unstake(unstakeAmount);
        } else {
            vm.prank(staker);
            desertCoin.unstake(unstakeAmount);
        }

        uint256 stakedBalanceAfter = desertCoin.stakedBalance(staker);
        uint256 balanceAfter = desertCoin.balanceOf(staker);

        assertLe(stakedBalanceAfter, stakedBalanceBefore);
        assertGe(balanceAfter, balanceBefore);
    }
}

Total Supply Must Remain Constant

function invariant_totalSupplyConstant() public {
    assertEq(desertCoin.totalSupply(), initialSupply);
}

This test enforces the most fundamental property of a token: the total supply must remain unchanged unless explicitly modified by an authorized mechanism. No function in the contract should accidentally mint or burn tokens, ensuring that no unexpected inflation or deflation occurs.

Token Transfers Do Not Destroy Tokens

function invariant_tradeDoesNotDestroyTokens() public {
    // Simulates a trade between two users
    address sender = user1;
    address recipient = user2;
    uint256 tradeAmount = 10 ether;

    uint256 balanceSenderBefore = desertCoin.balanceOf(sender);
    uint256 balanceRecipientBefore = desertCoin.balanceOf(recipient);
    uint256 totalSupplyBefore = desertCoin.totalSupply();

    if (balanceSenderBefore >= tradeAmount) {
        vm.prank(sender);
        desertCoin.trade(recipient, tradeAmount);
    }

    uint256 balanceSenderAfter = desertCoin.balanceOf(sender);
    uint256 balanceRecipientAfter = desertCoin.balanceOf(recipient);
    uint256 totalSupplyAfter = desertCoin.totalSupply();

    // Ensure no tokens were destroyed or created during a trade
    assertEq(balanceSenderBefore + balanceRecipientBefore, balanceSenderAfter + balanceRecipientAfter);
    assertEq(totalSupplyBefore, totalSupplyAfter);
}

A trade operation should only redistribute tokens between users without affecting the total supply. This test ensures that transfers are lossless and that token balances adjust correctly after each transaction.

Staked Balance Integrity

function invariant_stakedBalanceCorrect() public {
    address staker = user1;
    uint256 stakeAmount = 10 ether;

    uint256 balanceBefore = desertCoin.balanceOf(staker);
    uint256 stakedBefore = desertCoin.stakedBalance(staker);
    uint256 totalSupplyBefore = desertCoin.totalSupply();

    if (balanceBefore >= stakeAmount) {
        vm.prank(staker);
        desertCoin.stake(stakeAmount);
    }

    uint256 balanceAfter = desertCoin.balanceOf(staker);
    uint256 stakedAfter = desertCoin.stakedBalance(staker);
    uint256 totalSupplyAfter = desertCoin.totalSupply();

    // Ensure the amount staked matches the amount removed from the balance
    assertEq(balanceBefore - balanceAfter, stakedAfter - stakedBefore);
    assertEq(totalSupplyBefore, totalSupplyAfter);
}

When users stake tokens, their balance should decrease while their staked balance increases by the same amount. This test ensures that the contract correctly accounts for all staked tokens, preventing inconsistencies in the staking logic.

Only Valid Item Purchases Are Allowed

function invariant_validItemPurchases() public {
    uint256 itemId = 1;
    uint256 itemPrice = 50 ether;

    // Owner sets the item price
    vm.prank(owner);
    desertCoin.setItemPrice(itemId, itemPrice);

    uint256 balanceBefore = desertCoin.balanceOf(user1);

    if (balanceBefore >= itemPrice) {
        vm.prank(user1);
        desertCoin.buyItem(itemId);
    } else {
        vm.expectRevert("Not enough DSC");
        vm.prank(user1);
        desertCoin.buyItem(itemId);
    }

    uint256 balanceAfter = desertCoin.balanceOf(user1);

    // Ensure no illegal transactions occur
    assertLe(balanceAfter, balanceBefore);
}

Users should only be able to purchase items if they have enough funds. This test prevents unintended purchases and ensures that users cannot buy items without sufficient balance. If the purchase is invalid, the transaction should revert properly, maintaining contract security.

Users Can’t Unstake More Than They Staked

function invariant_cannotUnstakeMoreThanStaked() public {
    address staker = user1;
    uint256 unstakeAmount = 20 ether;

    uint256 stakedBalanceBefore = desertCoin.stakedBalance(staker);
    uint256 balanceBefore = desertCoin.balanceOf(staker);

    if (unstakeAmount > stakedBalanceBefore) {
        vm.expectRevert("Not enough staked");
        vm.prank(staker);
        desertCoin.unstake(unstakeAmount);
    } else {
        vm.prank(staker);
        desertCoin.unstake(unstakeAmount);
    }

    uint256 stakedBalanceAfter = desertCoin.stakedBalance(staker);
    uint256 balanceAfter = desertCoin.balanceOf(staker);

    // Ensure users can't unstake more than they actually staked
    assertLe(stakedBalanceAfter, stakedBalanceBefore);
    assertGe(balanceAfter, balanceBefore);
}

Staking only works if users can properly withdraw their staked funds—but they should never be able to unstake more than they originally staked. This test ensures unstaking logic remains correct, preventing unintended balance manipulation.

Running the tests

If we execute our invariant tests, we immediately notice a failure in invariant_stakedBalanceCorrect.

The error occurs because the total supply changes when staking and unstaking due to the use of _burn and _mint. This violates the invariant that the total token supply should remain constant unless explicitly modified by the contract logic.

Since staking is just locking tokens, burning and minting aren’t strictly necessary. Instead, we can transfer tokens to the contract and back. This approach ensures that the total supply remains unchanged while still enforcing staking logic.

However, it's important to note that this is just one possible implementation. Some protocols prefer using _burn and _mint for staking mechanisms to prevent potential reentrancy issues, avoid token accumulation in the contract, and ensure compatibility with standards like ERC-4626. While transferring tokens directly to the contract can simplify tracking and reduce supply fluctuations, it may introduce security risks if not properly managed. Choosing between these approaches depends on the specific needs and security considerations of the protocol.

    /**  Block the tokens for staking */
    function stake(uint256 amount) public {
        require(balanceOf(msg.sender) >= amount, "Insufficient DSC");

        //_burn(msg.sender, amount);
        _transfer(msg.sender, address(this), amount);
        stakedBalance[msg.sender] += amount;
        emit TokensStaked(msg.sender, amount);
    }

    function unstake(uint256 amount) public {
        require(stakedBalance[msg.sender] >= amount, "Not enough staked");

        //_mint(msg.sender, amount);
        stakedBalance[msg.sender] -= amount;
        _transfer(address(this), msg.sender, amount); 
        emit TokensUnstaked(msg.sender, amount);
    }

With this correction, we execute the invariant tests again:

Now, invariant_stakedBalanceCorrect passes successfully, confirming that the total token supply remains unchanged while still enforcing correct staking behavior.

Conclusions

In this chapter, we explored unit testing, fuzzing, and invariant testing to identify and mitigate vulnerabilities in smart contracts.

  • Unit testing ensures functions behave as expected under controlled conditions.
  • Fuzzing exposes hidden edge cases and unexpected failures.
  • Invariant testing verifies that core contract properties remain intact across multiple transactions.

Effective testing is key to securing smart contracts. Combining these methods enhances reliability and helps catch potential flaws before deployment.

References

Chapters

Botón Anterior
Hacking ERC-20: Pentesting the Most Common Ethereum Token Standard

Previous chapter

Fuel for the Ritual: Gas Mechanics and Misfires in Web3

Next chapter

Enjoyed the article?

Subscribe to the newsletter and get technical insights, cybersecurity tips, and development content straight to your inbox. Or support my work with a coffee ☕ if you found it useful!

📫 Subscribe now ☕ Buy me a coffee