On Sep 4, 2026, Notional Finance lost ~$1.73M. Root cause: a debt liability crossed 2^128 and a uint128 downcast truncated it. The books showed near-zero debt while the borrower held the cash.
No reentrancy. No oracle games. One cast.
The bug mechanics
Debt accounting lived in a narrower type than the values flowing through it. The moment cumulative borrows crossed 2^128, the stored debt wrapped — at exactly 2^128, it reads back as zero:
solidity
mapping(address => uint128) public debt;
function borrow(uint256 amount) external {
require(collateral[msg.sender] >= amount, "undercollateralized");
// BUG: silent truncation of any liability >= 2^128
debt[msg.sender] = uint128(debt[msg.sender] + amount);
cash[msg.sender] += amount;
}
uint128(x) in Solidity keeps only the low 128 bits. uint128(2**128) == 0. The risk engine then sees full collateral against zero debt:
solidity
function freeCollateral(address user) external view returns (uint256) {
uint256 d = debt[user]; // already truncated
if (collateral[user] < d) return 0;
return collateral[user] - d;
}
Borrow 2^128 against 2^128 collateral → debt == 0, freeCollateral == 2^128. Unborrowable money becomes withdrawable, or the position reads as perfectly healthy while fully drawn.
The pattern to grep for
solidity
debt[user] = uint128(debt[user] + amount); // ← truncates past 2^128
shares[user] = uint96(shares[user] + mint); // ← same class, tighter width
balance[user] = uint128(balance[user] - out); // ← truncation on the way out, too
Any unchecked downcast on debt, shares, or escrow balances where the input is uint256-range is this bug waiting on liquidity to reach the cast width. The fix is boring on purpose — full-width accounting:
solidity
mapping(address => uint256) public debt;
function borrow(uint256 amount) external {
require(collateral[msg.sender] >= amount, "undercollateralized");
debt[msg.sender] += amount; // FIX: no downcast, nothing to truncate
cash[msg.sender] += amount;
}
If storage packing genuinely needs the narrow slot, check-then-cast with SafeCast and revert above max — never silently wrap a liability.
Why bounded fuzzing catches it in seconds
Unit tests with "reasonable" borrow sizes never touch 2^128. A two-line bounded fuzz does:
solidity
function testFuzz_debtEqualsBorrowed(uint256 a, uint256 b) public {
a = bound(a, 1, uint256(1) << 130);
b = bound(b, 1, uint256(1) << 130);
// ... deposit, borrow(a), assert debt == a ...
}
bound(a, 1, 2^130) forces the fuzzer across the cast boundary every run. Invariant under test: cumulative borrows == recorded debt. On the vulnerable build it breaks almost immediately; on the fixed build 256+ runs pass clean. Rule of thumb: every downcast gets a fuzz run bounded past its width, asserting recorded == moved.
Demo as proof
I reproduced the exact class in 60 lines — VulnerableEscrow vs FixedEscrow — with 4/4 tests green:
bash
git clone https://github.com/roblambert9/invariant-review-demo
cd invariant-review-demo
forge test -vv
# [PASS] test_debtVanishesAt2e128() — borrow 2^128, books show ZERO debt
# [PASS] test_fixedTracksExactly() — same flow, full-width accounting holds
# [PASS] testFuzz_debtEqualsBorrowed — bounded fuzz breaks the vuln past 2^128
# [PASS] testFuzz_fixedAlwaysExact — bounded fuzz can't break the fix
The vanishing-debt test is the whole post in one assertion: cash credited, debt zero, risk engine blind.
Top comments (0)