Unlocking Deeper Security: An Introduction to Handler-Based Invariant Testing
Unlocking Deeper Security: An Introduction to Handler-Based Invariant Testing
In my previous article, we explored the fundamentals of invariant testing, a powerful technique for discovering bugs that traditional unit tests often miss. We learned that the core idea is to test a contract’s core properties (invariants) by feeding it a stream of randomized inputs. However, when applied to real-world, complex protocols, this approach can quickly hit a wall.
Today, we’ll dive into a more advanced and effective strategy: Handler-Based Testing. This method addresses the shortcomings of traditional invariant testing and is essential for achieving a high level of security confidence in sophisticated systems.
The Challenge of Open Invariant Testing
In a traditional or “open” invariant test, the Foundry fuzzer directly calls the public functions of the protocol’s contracts. The goal is to see if any sequence of these calls can break an invariant.
The diagram below illustrates this model:
While effective for simple contracts, this approach often fails for complex protocols due to two key issues:
- Excessive Reverts: Many protocol functions have strict prerequisites. For example, a
transfer
function requires the sender to have a sufficient token balance. A fuzzer calling these functions with entirely random inputs will lead to a flood of reverting transactions. The fuzzer spends most of its time hitting invalid states, leading to poor test coverage. - Unrealistic Scenarios: Without any guiding logic, the fuzzer’s calls can be completely unrealistic. It might try to call
withdraw
before a user has made adeposit
, for instance. This makes the test less meaningful for uncovering real-world vulnerabilities.
The Solution: Handler-Based Testing
Handler-Based Testing solves these problems by introducing an intermediary layer called a Handler. The fuzzer no longer calls the protocol contracts directly; instead, it calls the functions of the Handler. The Handler, in turn, performs the necessary setup and then calls the protocol’s functions in a controlled manner.
This model creates a far more meaningful and effective test campaign, as illustrated by the diagram below:
Here, the fuzzer’s interactions are guided by the Handler’s logic, ensuring that each function call to the protocol is valid and moves the system to a new, relevant state.
Key Advantages of Handler-Based Testing
Leveraging handlers in your invariant testing strategy offers several significant benefits:
- Fewer Reverts, Deeper Coverage: By managing preconditions, handlers ensure that fuzzer calls are successful. This allows the test campaign to explore the deeper and more complex parts of the protocol’s state machine, leading to better code coverage and a higher chance of finding subtle bugs.
- Actor Management: Handlers can simulate multiple actors (users) in the system. They can be configured to call functions on behalf of different addresses, mimicking a realistic, multi-user environment.
- Ghost Variables: Handlers can use “ghost variables”—state variables in the test environment that track off-chain metrics. For example, a handler can track the sum of all token balances and use this value to assert that the protocol’s
totalSupply
invariant is never violated. - Bounded Inputs: Handlers can use Foundry’s
bound()
helper function to constrain fuzzer-generated inputs to a realistic range. This prevents the test from wasting time on values that would never occur on-chain. - Function-Level Assertions: Handlers allow you to perform fine-grained assertions on state changes as they happen. You can check if a user’s balance has decreased by the correct amount after a transfer, for example.
A Simple Code Example with my Stablecoin Project
Now, let’s use the code from my DeFi stablecoin project to demonstrate how Handler-Based Testing can effectively validate protocol invariants. In the project, Handler.t.sol
simulates user actions, while Invariants.t.sol
defines and checks the core properties of the system.
1. The Handler Contract (Handler.t.sol
)
This Handler
contract is the primary target for the fuzzer. It contains public functions that mimic user interactions with the DSCEngine
protocol, such as depositing collateral, redeeming it, and minting the stablecoin.
depositCollateral()
: This function simulates a user depositing collateral. It bounds the randomamountCollateral
to a reasonable size and then performs the necessary steps: minting the collateral, approving thedsce
contract, and finally callingdsce.depositCollateral()
. It also keeps track of which users have deposited collateral by adding them to theusersWithCollateralDeposited
array.redeemCollateral()
: This function handles collateral redemption. Before allowing the redemption, it performs a crucial check to ensure the user’s health factor remains above the minimum threshold after the transaction. This prevents the fuzzer from executing transactions that would cause an invalid state and helps focus the test on valid protocol behaviors.mintDsc()
: This function simulates a user minting the stablecoin. It first calculates the maximum amount of DSC a user can mint based on their collateral value and health factor. It then usesbound()
to ensure the fuzzer’s randomamount
is within this valid range, ensuring that every minting transaction is legitimate. It also increments a publictimesMintCalled
counter, a form of “ghost variable” that tracks this key operation.
1 |
|
2. The Invariant Test Contract (Invariants.t.sol
)
This contract defines the core invariants that I want to validate. It inherits from StdInvariant
, which is Foundry’s key base class for Invariant Testing.
setUp()
: This function deploysDSCEngine
andDecentralizedStableCoin
contracts. Crucially, it then deploysHandler
contract and sets it as the target for the fuzzer (targetContract(address(handler))
). This tells Foundry to call the public functions of theHandler
instead of the protocol itself.invariant_protocolMustHaveMoreValueThanTotalSupply()
: This is the first and most critical invariant. It checks that the total value of all collateral in the protocol is always greater than or equal to the total supply of your stablecoin. This is the fundamental property that prevents the protocol from becoming insolvent.invariant_getterShouldNotRevert()
: This is an “evergreen” invariant that ensures all public view or pure functions do not revert under any circumstances. This is vital for a robust and predictable protocol.
1 |
|