Introduction

Manually guided fuzzing (MGF) is a testing methodology that finds critical vulnerabilities by systematically testing smart contract behavior through guided test scenarios.

Unlike traditional fuzzing that relies on randomness alone, MGF allows developers to define specific test flows and invariants, providing more targeted and effective vulnerability detection.

Learn MGF to strengthen your smart contract security.

Comprehensive documentation: Wake Testing Framework – Fuzzing

Comparison With Foundry Fuzz Testing and Invariant Testing

In any smart contract test, the most critical element is defining clear invariants – properties that should always remain true regardless of the contract’s state changes.

The Wake manually guided fuzzing approach compares expected behavior (defined in Python) against actual contract behavior, without relying on the contract’s internal logic.

This methodology forces testers to verify behavior at every step, ensuring comprehensive coverage and catching edge cases that other testing approaches might miss.

Wake MGF lifecycle

Wake MGF follows a different execution lifecycle compared to Foundry’s fuzz tests or invariant tests.

The Wake MGF execution lifecycle:

Where:

  • flow_count defines the number of flow function calls executed after each pre_sequence function (contract initialization)
  • sequence_count defines the number of complete test sequences to execute

Each sequence consists of one pre_sequence followed by the specified number of flow function calls.

Learn more about execution hooks in Wake.

Implementation Guide

Prerequisites

  • Wake framework installed on your system
  • Basic understanding of Python and Solidity
  • A Solidity project ready for testing or use the code in the Appendix.

Full Source Code

Full source code available in the Appendix.

1. Compile the project with Wake

  1. Run $ wake up to compile your Solidity contracts and generate Python type definitions
  2. The pytypes are automatically generated in the pytypes directory
  3. Create your test file: tests/test_fuzz.py

The pytypes provide Python interfaces for your Solidity contracts, enabling type-safe interaction during testing.

Action item: Set up your project structure with the test file in the correct location.

2. Import Wake

Import Wake testing.

from wake.testing import *
from wake.testing.fuzzing import *

3. Import pytypes

Import pytypes by looking at the pytypes directory and importing your contract pytypes.

from pytypes.contracts.Token import Token

4. Define the Test Class and Call From the Test

Fuzzing base class FuzzTest is defined in wake.testing.fuzzing.

from wake.testing import *
from wake.testing.fuzzing import *

import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

from pytypes.contracts.Token import Token

def revert_handler(e: RevertError):
    if e.tx is not None:
        print(e.tx.call_trace)

class TokenFuzz(FuzzTest):

    def pre_sequence(self):
        pass

    @flow()
    def flow_example(self):
        pass

    @invariant()
    def invariant_example(self):
        pass


@chain.connect()
@on_revert(revert_handler)
def test_default():
    TokenFuzz.run(sequences_count=1, flows_count=100)

The following sections detail how to implement logic within the TokenFuzz class.

5. Contract Initialization and Python State Definition

The pre_sequence function serves as the setup phase for each test sequence:

  1. Deploy contracts: Initialize the contracts you want to test
  2. Define actors: Set up accounts that will interact with your contracts
  3. Initialize Python state: Create data structures to track expected contract state

This separation ensures each test sequence starts with a clean, known state.

class TokenFuzz(FuzzTest):

    token_owner: Account
    token: Token
    token_balances: dict[Account, int]

    def pre_sequence(self):
        self.token_owner = random_account()
        self.token = Token.deploy(from_=self.token_owner)
        self.token_balances = defaultdict(int)

6. Defining Flows

What is a flow function?

Flow functions are the core of MGF testing. Each flow function generates test inputs, executes contract calls, validates behavior, and updates Python state to mirror contract changes.

Flow functions simulate real-world usage patterns and edge cases by systematically testing different input combinations and execution paths.

This is the flow function.

@flow()
def flow_mint_tokens(self):
    ##1. prepare random input
    recipient = random_account() # or random.choice(list(chain.accounts) + [self.token])
    amount = random_int(0, 10**30)
    actor = random_account()

    ##2. run transaction
    with may_revert() as e:
        tx = self.token.mintTokens(recipient, amount, from_=actor)
    if e.value is not None:
        ## 3. check revert
        if actor != self.token_owner:
            assert e.value == Token.NotAuthorized(actor.address)
            return "Not authorized"
        assert False

    ##4. check events
    events = [e for e in tx.events if isinstance(e, Token.TokensMinted)]
    assert len(events) == 1
    assert events[0].to == recipient.address
    assert events[0].amount == amount

    ##5. update python state
    self.token_balances[recipient] += amount

    ##6. logging for debug
    logger.info(f"Minted {amount} tokens to {recipient.address}")

Follow this structured approach in every flow function:

  • Prepare random input
  • Execute transaction with revert handling
  • Validate events and assertions
  • Update Python state
  • Add logging for debugging (if necessary)

Step 1: Prepare random input

Generate test inputs using Wake’s built-in random functions like random_account(), random_int(min, max), and random_bytes(length). These functions ensure comprehensive test coverage across different input scenarios.

Full documentation: https://ackee.xyz/wake/docs/latest/testing-framework/fuzzing/#random-functions

Step 2: Execute transaction with revert handling

Use the may_revert() context manager to handle both successful and failing transactions. This enables branching logic for success/failure cases. Use assert False to catch unexpected revert conditions, and return descriptive strings for expected reverts to track test statistics.

with may_revert() as e:
    tx = self.token.mintTokens(recipient, amount, from_=actor)
if e.value is not None:
    if condition:
        # assert e.value == RevertError()
        return "Reason"
    elif other_condition:
        # assert e.value == RevertOtherError()
        return "OtherReason"
    assert False

Step 3: Check events and assertions

Always check errors.

Always check events for the testing target.

Events and RevertErrors can be checked in these ways:

events = [e for e in tx.events if e == Token.TokensMinted(recipient.address, amount)]
assert len(events) == 1

Or filter by event and assert parameters

events = [e for e in tx.events if isinstance(e, Token.TokensMinted)]
assert len(events) == 1
assert events[0].to == recipient.address
assert events[0].amount == amount

The isinstance() approach is recommended for complex validation scenarios, such as transactions that emit multiple events or when parameter values require complex calculations. This method provides precise error reporting, showing exactly which parameters failed assertion checks.

Step 4: Update Python state

Mirror the contract’s state changes in your Python variables. This parallel state tracking enables accurate invariant checking. Never derive state updates from view functions – always update based on the known effects of your transactions.

Invariant Functions

Invariant functions validate that critical properties hold true throughout contract execution. They compare your Python state against the actual contract state using view functions.

For complex protocols, invariants may include sophisticated logic to verify multi-contract interactions. Never modify state within invariant functions. If state-changing operations are required for validation, use snapshot_and_revert() to avoid affecting the test sequence.

Definition of Invariants in MGF

Check view functions with Python state.

Check invariant statements and conditional invariants.

All @invariant() functions are called after each @flow function call.

No state changes in these functions.

@invariant()
def invariant_token_balances(self):
    for account in list(self.token_balances.keys()) + [self.token]:
        assert self.token.getBalance(account.address) == self.token_balances[account]

@invariant()
def invariant_token_owner(self):
    assert self.token.owner() == self.token_owner.address

Running The Test

Run the test:

$ wake test tests/test_token_fuzz.py

This is a running example with a smaller flow_number.

Use debug mode when the test fails:

$ wake test tests/test_token_fuzz.py -d

The execution shows the random seed hex value. You can use this hex value to reproduce the same test, including failures.

Set a specific random seed for reproducible testing:

$ wake test tests/test_token_fuzz.py -S 235ab3

More fuzzing tips and professional methodology: follow @wakeframework on X.

Conclusion

Manually guided fuzzing provides a systematic approach to verify contract behavior while offering deep insight into contract logic and edge cases.

Appendix – Full Code

token.sol

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


contract Token {
    address public immutable owner;
    mapping(address => uint256) public tokenBalance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event TokensMinted(address indexed to, uint256 amount);

    error NotEnoughTokens(uint256 requested, uint256 balance);
    error NotAuthorized(address caller);

    constructor() {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        if (msg.sender != owner) {
            revert NotAuthorized(msg.sender);
        }
        _;
    }

    function mintTokens(address recipient, uint256 amount) external onlyOwner {
        tokenBalance[recipient] += amount;
        emit TokensMinted(recipient, amount);
    }

    function transfer(address to, uint256 amount) external {
        if (tokenBalance[msg.sender] < amount) {
            revert NotEnoughTokens(amount, tokenBalance[msg.sender]);
        }

        tokenBalance[msg.sender] -= amount;
        tokenBalance[to] += amount;

        emit Transfer(msg.sender, to, amount);
    }

    function transferWithBytes(bytes calldata data) external {
        (address to, uint256 amount) = abi.decode(data, (address, uint256));
        if (tokenBalance[msg.sender] < amount) {
            revert NotEnoughTokens(amount, tokenBalance[msg.sender]);
        }
        tokenBalance[msg.sender] -= amount;
        tokenBalance[to] += amount;
        emit Transfer(msg.sender, to, amount);
    }

    function getBalance(address account) external view returns (uint256) {
        return tokenBalance[account];
    }
}

test_token_fuzz.py

from wake.testing import *
from collections import defaultdict
from wake.testing.fuzzing import *

from pytypes.contracts.Token import Token

import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

# Print failing tx call trace
def revert_handler(e: RevertError):
    if e.tx is not None:
        print(e.tx.call_trace)
class TokenFuzz(FuzzTest):

    token_owner: Account
    token: Token
    token_balances: dict[Account, int]

    def pre_sequence(self):
        self.token_owner = random_account()
        self.token = Token.deploy(from_=self.token_owner)
        self.token_balances = defaultdict(int)

    @flow()
    def flow_mint_tokens(self):
        ## prepare random input
        recipient = random_account() # or list(chain.accounts) + [self.token]
        amount = random_int(0, 10**30)
        actor = random_account()

        ## run transaction
        with may_revert() as e:
            tx = self.token.mintTokens(recipient.address, amount, from_=actor)
        if e.value is not None:
            if actor != self.token_owner:
                assert e.value == Token.NotAuthorized(actor.address)
                return "Not authorized"
            assert False

        ## check events
        events = [e for e in tx.events if isinstance(e, Token.TokensMinted)]
        assert len(events) == 1
        assert events[0].to == recipient.address
        assert events[0].amount == amount

        ## update python state
        self.token_balances[recipient] += amount

        logger.info(f"Minted {amount} tokens to {recipient.address}")

    @flow()
    def flow_transfer_tokens(self):
        recipient = random_account()
        amount = random_int(0, 10**30)
        actor = random_account()
        with may_revert() as e:
            tx = self.token.transfer(recipient.address, amount, from_=actor)

        if e.value is not None:
            if self.token_balances[actor] < amount:
                assert e.value == Token.NotEnoughTokens(amount, self.token_balances[actor])
                return "Not enough tokens"
            assert False

        events = [e for e in tx.events if isinstance(e, Token.Transfer)]
        assert len(events) == 1
        assert events[0].from_ == actor.address
        assert events[0].to == recipient.address
        assert events[0].value == amount

        self.token_balances[recipient] += amount
        self.token_balances[actor] -= amount

        logger.info(f"Transferred {amount} tokens from {actor.address} to {recipient.address}")


    @flow()
    def flow_transfer_tokens_with_bytes(self):
        recipient = random_account()
        amount = random_int(0, 10**30)
        actor = random_account()
        with may_revert() as e:
            tx = self.token.transferWithBytes(abi.encode(recipient.address, uint256(amount)), from_=actor)
        if e.value is not None:
            if self.token_balances[actor] < amount:
                assert e.value == Token.NotEnoughTokens(amount, self.token_balances[actor])
                return "Not enough tokens"
            assert False

        events = [e for e in tx.events if isinstance(e, Token.Transfer)]
        assert len(events) == 1
        assert events[0].from_ == actor.address
        assert events[0].to == recipient.address
        assert events[0].value == amount

        self.token_balances[recipient] += amount
        self.token_balances[actor] -= amount

        logger.info(f"Transferred {amount} tokens from {actor.address} to {recipient.address}")

    @invariant()
    def invariant_token_balances(self):
        for account in list(self.token_balances.keys()) + [self.token]:
            assert self.token.getBalance(account.address) == self.token_balances[account]


    @invariant()
    def invariant_token_owner(self):
        assert self.token.owner() == self.token_owner.address



@chain.connect()
def test_default():
    TokenFuzz.run(sequences_count=10, flows_count=10000)