Off-By-One
Detects off-by-one errors in loop boundaries that can lead to skipped elements or out-of-bounds array access.
Off-By-One
Overview
The off-by-one detector identifies loop boundary conditions that are likely incorrect: using length - 1 as an upper bound (skipping the last element), using <= with length (accessing beyond the array), or using negated conditions that obscure the true boundary. These errors cause missed processing, out-of-bounds access, or infinite loops at edge cases.
In DeFi, off-by-one errors can mean the last depositor in a reward distribution is skipped, or the last element in a whitelist is not checked, creating an economic exploit.
Why This Is an Issue
Off-by-one errors are among the most common programming mistakes. In smart contracts, they carry financial consequences:
- Skipped last element: A loop over reward recipients using
i < recipients.length - 1skips the last recipient. With a 1000-recipient airdrop, one user permanently loses their claim. - Out-of-bounds access: A loop using
i <= array.lengthaccessesarray[array.length], which in Solidity reverts with an out-of-bounds panic. If this function processes withdrawals, no one can withdraw. - Zero-length edge case:
array.length - 1underflows totype(uint256).maxin Solidity <0.8.0, creating an unbounded loop. Solidity 0.8+ catches this with a panic, but the function still reverts.
How to Resolve
// Before: Vulnerable -- skips last element
for (uint256 i = 0; i < array.length - 1; i++) {
process(array[i]);
}
// After: Fixed -- processes all elements
for (uint256 i = 0; i < array.length; i++) {
process(array[i]);
}
Examples
Vulnerable (Inclusive Upper Bound)
function distributeRewards(address[] memory stakers) external {
for (uint256 i = 0; i <= stakers.length; i++) {
// Reverts on last iteration: array[length] is out of bounds
_sendReward(stakers[i]);
}
}
Fixed
function distributeRewards(address[] memory stakers) external {
for (uint256 i = 0; i < stakers.length; i++) {
_sendReward(stakers[i]);
}
}
Vulnerable (Length Minus One)
function checkAllVoters(address[] memory voters) external view returns (bool) {
for (uint256 i = 0; i < voters.length - 1; i++) {
if (!isEligible(voters[i])) return false;
}
return true; // Last voter is never checked
}
Fixed
function checkAllVoters(address[] memory voters) external view returns (bool) {
for (uint256 i = 0; i < voters.length; i++) {
if (!isEligible(voters[i])) return false;
}
return true;
}
Sample Sigvex Output
[MEDIUM] off-by-one
Potential off-by-one error in 'distributeRewards'
Location: distributeRewards @ block 0, instruction 3
Confidence: 0.70
Potential off-by-one error in 'distributeRewards': loop bound uses
'length - 1' which may skip last element.
Detection Methodology
- Length variable tracking: Identifies variables loaded from memory or storage that contain array length values, based on naming heuristics.
- Subtraction detection: Tracks
SUBoperations where one operand is a length variable and the other is the constant1. - Comparison analysis: Flags comparisons (
LT,GT,SLT,SGT) where one operand is alength - 1variable or whereGT/SGTis used with a length variable (indicating<=semantics). - Negated condition detection: Identifies
ISZEROapplied to comparison results, creating!(i >= length)patterns that are error-prone. - Compiler-aware adjustment: Solidity 0.8+ contracts receive reduced confidence because underflow on
length - 1whenlength == 0triggers a panic, catching the zero-length edge case.
Limitations
False positives: Intentional length - 1 patterns exist in binary search, pair-wise comparison, and sorting algorithms. Audited library code (OpenZeppelin’s EnumerableSet) uses length - 1 correctly. These are suppressed by the detector. False negatives: Off-by-one errors in arithmetic other than loop bounds (e.g., reward calculations) are not targeted by this detector.
Related Detectors
- Loop Gas Exhaustion — detects unbounded loops
- Unchecked Array Bounds — detects array access without bounds validation
- Integer Overflow — detects overflow/underflow in arithmetic