Solidity Invariant Testing
An invariant is a rule that must hold after every sequence of calls to a contract. A handler is a small contract that performs a sequence of calls and checks the rule after each step.
The vault balance always equals deposits minus withdrawals
The vault tracks every deposit and every withdrawal. Its ether balance must always equal the first total minus the second.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract EtherVault {
uint256 public totalIn;
uint256 public totalOut;
function deposit() public payable {
totalIn += msg.value;
}
function withdraw(uint256 amount) public {
require(amount <= address(this).balance, "insufficient vault balance");
totalOut += amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
A handler checks the invariant after each step
The handler deposits twice and withdraws once. It checks the invariant after each step, so a break would be caught at once.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract EtherVault {
uint256 public totalIn;
uint256 public totalOut;
function deposit() public payable {
totalIn += msg.value;
}
function withdraw(uint256 amount) public {
require(amount <= address(this).balance, "insufficient vault balance");
totalOut += amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
A failed withdrawal cannot break the invariant
A withdrawal larger than the vault balance reverts. The totals stay unchanged, so the invariant still holds.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract EtherVault {
uint256 public totalIn;
uint256 public totalOut;
function deposit() public payable {
totalIn += msg.value;
}
function withdraw(uint256 amount) public {
require(amount <= address(this).balance, "insufficient vault balance");
totalOut += amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract SavingsVault {
uint256 public totalIn;
uint256 public totalOut;
function deposit() public payable {
totalIn += msg.value;
}
function withdraw(uint256 amount) public {
require(amount <= address(this).balance, "insufficient vault balance");
totalOut += amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
Key points
- Invariant: an invariant is a rule that stays true no matter which calls came before.
- Handler: a handler is a small contract that drives a contract through a sequence of calls.
- Check after each step: the handler checks the invariant after every call, not only at the end.
- Failed calls: a call that reverts changes nothing, so the invariant must still hold after it.
- Use an invariant to catch a bug that only appears after several calls in a row.
In this example, a handler deposits twice, tries a withdrawal that is too large, and then withdraws a smaller amount. The invariant is checked after each successful step and again at the end, and the failed withdrawal leaves the totals unchanged.
Note
true invariant testing lets the runner pick the call sequence at random, and this lesson shows the same check on a fixed sequence.
Try it Yourself
Edit the code, then press Run.
Running is simulated · Remix IDE
Exercise
What is an invariant?