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 eachpre_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
- Run
$ wake up
to compile your Solidity contracts and generate Python type definitions - The pytypes are automatically generated in the
pytypes
directory - 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:
- Deploy contracts: Initialize the contracts you want to test
- Define actors: Set up accounts that will interact with your contracts
- 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)