
Testing Solidity Contract Invariants with Assertion-Based Checks
What invariant testing is
An invariant is a property that must always hold for a contract, regardless of the order of operations or the caller. Unlike a unit test, which verifies one expected outcome, an invariant test asks: “No matter what sequence of valid actions occurs, does the contract still satisfy its core rules?”
Common examples include:
- Total token supply equals the sum of all balances
- A vault’s recorded assets never exceed its actual holdings
- A paused contract never allows state-changing user actions
- A bid must never exceed a configured maximum
- A mapping of positions must remain internally consistent with aggregate counters
In Solidity testing, invariants are often implemented with assert statements inside dedicated test functions, helper functions, or contract-level checks. The goal is to fail immediately when the contract reaches an impossible state.
Why assertions are useful in smart contract testing
Assertions are especially valuable because smart contracts are deterministic and stateful. A bug in one function can silently corrupt storage and only become visible much later. By asserting invariants at strategic points, you can catch the corruption at the moment it occurs.
Use assertions when the condition should be mathematically or logically impossible to violate in a correct implementation. For example:
assert(totalShares == sumOfUserShares)assert(address(this).balance >= totalLiabilities)assert(owner != address(0))after initializationassert(positionCount == activePositions.length)
In testing, assertions also document your assumptions. They make the intended contract behavior explicit for future maintainers and help distinguish “expected failure” from “unexpected bug.”
Choosing the right invariant
A good invariant is:
- Stable: it should hold across many valid operations
- Observable: you can verify it from contract state or test harness state
- Meaningful: a violation indicates a real defect, not a trivial implementation detail
- Independent: it should not duplicate the exact logic of the function under test
Avoid invariants that are too weak, such as “the owner is not zero” if ownership never changes. Also avoid invariants that simply restate a single require condition. The best invariants connect multiple pieces of state.
Examples of strong invariants
| Contract type | Useful invariant | Why it matters |
|---|---|---|
| Vault | totalAssets >= totalLiabilities | Prevents insolvency |
| ERC-20-like token | sum(balances) == totalSupply | Detects mint/burn/accounting bugs |
| Staking pool | rewardDebt and accumulatedRewards remain consistent | Prevents reward leakage |
| Marketplace | listedItems == activeListings.length | Detects stale bookkeeping |
| Governance module | proposal state transitions follow a valid sequence | Prevents illegal execution paths |
A practical example: vault accounting
Consider a simple vault where users deposit and withdraw ETH. The contract tracks user shares and total shares. A core invariant is that the vault’s internal accounting must match its actual ETH balance and user share totals.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SimpleVault {
mapping(address => uint256) public shares;
uint256 public totalShares;
function deposit() external payable {
require(msg.value > 0, "zero deposit");
shares[msg.sender] += msg.value;
totalShares += msg.value;
}
function withdraw(uint256 amount) external {
require(shares[msg.sender] >= amount, "insufficient shares");
shares[msg.sender] -= amount;
totalShares -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
function invariant_totalSharesMatchesBalances(address[] memory users) external view {
uint256 sum;
for (uint256 i = 0; i < users.length; i++) {
sum += shares[users[i]];
}
assert(sum == totalShares);
assert(address(this).balance == totalShares);
}
}This example uses a helper invariant function that sums known user balances and checks them against totalShares and the contract balance. In a real test suite, the users array would be maintained by the harness or test setup.
Where to place assertions
There are three common places to use assertions in Solidity testing:
1. Inside the contract under test
This is useful for internal sanity checks during development. For example, after updating balances, you may assert that a derived value still matches the expected total.
Pros:
- Catches bugs close to the source
- Documents intended invariants directly in code
Cons:
- Must be removed or carefully managed in production
- Can be too strict if external calls or rounding introduce acceptable variance
2. In a dedicated test harness
A harness is a wrapper contract used only for testing. It exposes internal state or adds helper methods that call assert on invariants.
Pros:
- Keeps production code clean
- Can inspect internal state more easily
- Can model sequences of actions
Cons:
- Requires extra test scaffolding
3. In the test framework itself
Frameworks such as Foundry, Hardhat, or Truffle can call view functions or inspect storage and assert invariants in JavaScript or Solidity tests.
Pros:
- Flexible and expressive
- Good for cross-contract checks
Cons:
- Some invariants are harder to express externally, especially if internal state is private
Designing a robust invariant test harness
A useful harness should do more than call one function and assert once. It should model realistic user behavior and verify the invariant after each state transition.
A typical harness includes:
- A set of known actors
- Helper functions for deposits, withdrawals, transfers, or admin actions
- A function that checks the invariant
- Optionally, a sequence runner that performs randomized or scripted actions
For example, a vault harness might:
- Deposit from user A
- Deposit from user B
- Withdraw part of user A’s balance
- Check that totals still match
- Repeat with different amounts and call orders
Even without property-based fuzzing, this style of testing is powerful because it explores state transitions rather than isolated calls.
Common assertion patterns
Balance consistency
Use this when the contract tracks an aggregate value derived from individual accounts.
assert(totalDeposits == sumOfDeposits);
assert(address(this).balance >= totalDeposits);State machine validity
Use this when a contract has explicit lifecycle stages.
assert(
status == Status.Pending ||
status == Status.Active ||
status == Status.Closed
);Better yet, assert valid transitions in the test harness after each action.
Conservation of value
Use this for tokenized systems, staking, lending, and vaults.
assert(totalAssets == cashOnHand + investedAssets);If rounding is involved, use bounded checks instead of exact equality.
Access and initialization sanity
Use this for setup-heavy contracts.
assert(admin != address(0));
assert(initialized);These checks are especially helpful after deployment scripts or upgrade steps.
Avoiding false positives and brittle assertions
Assertions should detect bugs, not punish legitimate behavior. A brittle invariant can make tests noisy and reduce trust in the suite.
Be careful with rounding
Many DeFi contracts involve integer division. If a formula truncates, exact equality may not hold after every operation. In such cases, assert a range:
assert(expected >= actual);
assert(expected - actual <= 1);Account for external calls
If your contract transfers ETH or tokens to external addresses, the external environment may affect balances in ways your internal accounting does not fully control. Invariants should focus on what the contract can guarantee.
Do not assert user intent
Avoid assertions like “the user always withdraws exactly what they deposited” unless the contract explicitly guarantees that behavior. Users may interact in many valid ways.
Keep the invariant independent
If the invariant uses the same formula as the production function, it may fail to detect the same bug. Prefer checks that compare different sources of truth, such as internal accounting versus actual balance.
Assertion-based checks versus require and revert
It is important to distinguish testing assertions from runtime validation.
| Mechanism | Purpose | Typical use |
|---|---|---|
require | Reject invalid input or unauthorized actions | Production guardrails |
revert | Abort with a reason or custom error | Production error handling |
assert | Detect impossible internal states | Invariants and sanity checks |
In production Solidity, assert should be reserved for conditions that should never be false if the code is correct. In tests, assertions are the primary tool for invariant verification. If an assert fails, it usually indicates a bug in the contract or test harness, not a user error.
A testing workflow that scales
A practical invariant-testing workflow looks like this:
- Identify the contract’s core safety properties
- What must always remain true?
- Which values are derived and should stay synchronized?
- Write a small harness
- Expose the actions users can take
- Track a reference model if needed
- Assert after each meaningful transition
- After deposit
- After withdrawal
- After admin changes
- After external interactions
- Start with deterministic sequences
- These are easier to debug than randomized tests
- They also document the expected behavior
- Expand coverage with more scenarios
- Different actor orders
- Edge amounts
- Zero-value and maximum-value boundaries
- Repeated operations
This approach gives you fast feedback early and a strong foundation for more advanced testing later.
Debugging a failed invariant
When an assertion fails, the most useful question is not “what line failed?” but “what state transition broke the contract’s assumptions?”
A good debugging checklist:
- Inspect the last successful action before the failure
- Log relevant state variables before and after the action
- Compare derived values with their source data
- Check for missing updates in all branches
- Review external calls that may reenter or fail
- Verify that rounding or truncation is handled consistently
If the invariant is complex, split it into smaller assertions. For example, instead of one large check, assert each component separately:
assert(totalShares == sumShares);
assert(address(this).balance == totalShares);
assert(sumShares >= 0);Smaller assertions make it easier to isolate the exact inconsistency.
Best practices
- Prefer invariants that reflect economic or logical safety properties
- Keep assertions close to the state they validate
- Use a harness for cross-function and cross-user scenarios
- Separate production validation from test-only invariant checks
- Make invariants deterministic and reproducible
- Treat every failed assertion as a signal to inspect state transitions, not just the last function call
A well-designed invariant suite often catches issues that ordinary unit tests miss, especially in contracts with complex accounting, multiple actors, or long-lived state.
