5 Smart Contract Vulnerabilities That Unit Tests Will Never Catch
By Alex · Security researcher5 Smart Contract Vulnerabilities That Unit Tests Will Never Catch
Unit tests are necessary but not sufficient. They verify that individual functions behave correctly under specific, developer-chosen scenarios. But the most devastating DeFi exploits do not come from individual functions misbehaving — they come from unexpected interactions between functions, accumulation of tiny errors, and state conditions no developer imagined.
Here are five categories of vulnerabilities that unit tests are structurally incapable of catching, and how invariant testing finds each one.
1. Cross-Function Reentrancy
The vulnerability: A contract makes an external call in function A, and the callback re-enters through function B, which reads stale state that function A has not yet updated.
contract Vulnerable {
mapping(address => uint256) public balances;
mapping(address => uint256) public rewards;
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
// State not yet updated when external call happens
(bool ok,) = msg.sender.call{value: amount}("");
require(ok);
balances[msg.sender] -= amount; // Updated AFTER the call
}
function claimReward() external {
// Reads balances[msg.sender] which hasn't been decremented yet
uint256 reward = balances[msg.sender] * rewardRate / 1e18;
rewards[msg.sender] += reward;
}
}
Why unit tests miss it: A unit test for withdraw tests withdraw in isolation. A unit test for claimReward tests claiming in isolation. Neither test calls claimReward from within a withdraw callback. The developer would need to anticipate this exact cross-function interaction.
How invariant testing catches it: The fuzzer generates random sequences of function calls. Eventually, it creates a callback contract that calls claimReward during a withdraw, and the solvency invariant breaks. For more on reentrancy patterns, see our reentrancy guide.
2. Rounding Accumulation Over Many Transactions
The vulnerability: A single rounding error of 1 wei is harmless. But after thousands of operations, these errors accumulate into a material discrepancy.
// Each deposit rounds up shares by at most 1 wei
function deposit(uint256 assets) external {
uint256 shares = (assets * totalShares + totalAssets - 1) / totalAssets;
// After 10,000 deposits: up to 10,000 wei of phantom shares
}
Why unit tests miss it: Unit tests typically test one or two operations. No developer writes a test that performs 10,000 sequential deposits and then checks cumulative accounting. The error per operation is so small it would not trigger any reasonable assertion threshold.
How invariant testing catches it: The fuzzer runs thousands of operations as part of its normal exploration. A solvency invariant — "total assets >= sum of all claims" — eventually fails as the accumulated rounding error grows large enough. See our coverage of this topic at integer overflow and rounding.
3. State-Dependent Access Control Bypass
The vulnerability: Access control is correct in normal states but can be bypassed by manipulating the contract into an unexpected state first.
contract Governor {
bool public paused;
address public guardian;
function setPaused(bool _paused) external {
require(msg.sender == guardian);
paused = _paused;
}
function executeProposal(uint256 id) external {
require(!paused, "Paused");
// Execute the proposal...
}
function emergencyExecute(uint256 id) external {
require(paused, "Must be paused");
require(msg.sender == guardian);
// Execute without normal checks...
}
// Bug: guardian can be changed through a proposal
function setGuardian(address _guardian) external {
require(msg.sender == address(this)); // Only via proposal
guardian = _guardian;
}
}
Why unit tests miss it: Each function works correctly when tested individually. The bypass requires a specific sequence: execute a proposal that changes the guardian, then the new guardian can pause, then use emergencyExecute to bypass governance. Unit tests do not explore multi-step state manipulation chains.
How invariant testing catches it: Define a property: "Only approved proposals can be executed." The fuzzer will eventually find the sequence that manipulates the guardian, pauses, and bypasses governance.
4. Oracle Manipulation Across Multiple Blocks
The vulnerability: TWAP oracles can be manipulated by performing large swaps before and after the observation window.
Why unit tests miss it: Unit tests run in a single block context. They do not simulate price changes across multiple blocks or model how an attacker might manipulate a liquidity pool over time. Even tests that mock oracle prices only test one price at a time.
How invariant testing catches it: Stateful fuzzers can warp time and block numbers between calls. With proper target functions that include oracle manipulation actions, the fuzzer discovers sequences where a user manipulates the price, takes a favorable action, and profits at the protocol's expense.
A useful property pattern:
function invariant_oracleBoundedDeviation() public returns (bool) {
uint256 oraclePrice = pool.getOraclePrice();
uint256 spotPrice = pool.getSpotPrice();
uint256 deviation = oraclePrice > spotPrice
? oraclePrice - spotPrice
: spotPrice - oraclePrice;
// Oracle and spot should not deviate more than 10%
return deviation * 100 / spotPrice <= 10;
}
5. Dust Amount Accounting Drift
The vulnerability: When very small amounts (dust) are deposited or transferred, rounding causes tracking variables to drift from actual balances over time.
contract StakingPool {
uint256 public totalStaked;
function stake(uint256 amount) external {
token.transferFrom(msg.sender, address(this), amount);
// Fees or rebasing can cause actual received to differ
totalStaked += amount; // Tracks intended, not actual
}
}
Why unit tests miss it: With normal-sized amounts, the drift is negligible. Unit tests use round numbers (1e18, 100e6) that do not trigger dust issues. The drift only becomes significant after hundreds of small operations with fee-on-transfer or rebasing tokens.
How invariant testing catches it: The fuzzer generates all kinds of amounts, including very small ones. Over hundreds of operations, the cumulative drift between totalStaked and the actual token balance becomes significant:
function invariant_accountingSync() public returns (bool) {
uint256 actual = token.balanceOf(address(stakingPool));
uint256 tracked = stakingPool.totalStaked();
return actual >= tracked;
}
The Common Thread
These five vulnerability categories share a common trait: they only manifest through sequences of operations that no human would think to test. This is exactly the domain where fuzzers excel. By generating millions of random transaction sequences and checking fundamental properties after each step, invariant testing systematically discovers the bugs that are invisible to unit testing.
This isn't theoretical. These are the exact bug categories we keep finding in real engagements, across protocols managing hundreds of millions in TVL.
Why These Bugs Escape Unit Testing
Unit tests check a developer's mental model of the system. If the developer does not imagine a scenario, it does not get tested. This is not a criticism of developers — it is a structural limitation of the methodology. Unit tests encode known expectations. The five bug categories above all share a common trait: they emerge from interactions between operations, not from individual operations behaving incorrectly.
The state space of a DeFi protocol is combinatorial. Take a protocol with N callable functions, M users, and K possible input amounts. Each test sequence of length L creates N^L * M * K possible execution paths. For a modest protocol with 10 functions, 3 users, 100 meaningful amounts, and sequences of length 20, the state space exceeds 10^20 possibilities. Unit tests cover maybe 100 of those scenarios. A fuzzer covers millions per campaign. The math is clear: manual scenario selection cannot meaningfully sample a space this large.
The developer who wrote the code is the worst person to test it adversarially. They built the system with a specific set of assumptions. Their tests validate those assumptions. The bugs live in the gaps between assumptions — the interactions they did not design for and therefore did not test.
What Invariant Testing Catches Instead
Instead of testing specific scenarios, invariant testing defines what must always be true and lets the fuzzer find counterexamples. You do not need to imagine the attack. You only need to define the property that the attack would violate.
For each of the five vulnerability categories above, there is a corresponding invariant:
- Cross-function reentrancy — solvency invariant: total assets held by the contract always cover total obligations, regardless of call ordering
- Rounding accumulation — accounting consistency invariant: the sum of all individual claims never exceeds the total tracked balance
- State-dependent access control — authorization invariant: privileged state transitions only succeed when initiated by expected callers through expected paths
- Oracle manipulation — value extraction invariant: no actor can profit by manipulating external inputs and then taking a favorable action within the same sequence
- Dust accounting drift — global state consistency invariant: internal accounting variables remain synchronized with actual token balances after any sequence of operations
Writing five good invariants provides more coverage than 500 unit tests because each invariant is checked across millions of randomly generated sequences. A single solvency invariant tested over 1,000,000 sequences exercises more state transitions than an entire unit test suite. The invariant does not care how the violation happens — it only cares that it happened. The fuzzer's job is to find the how.
If you want to find these bugs before attackers do, request an audit with Recon. We will build a comprehensive invariant test suite that covers these categories and more, tailored to your specific protocol.