DEV Community

Rob Lambert
Rob Lambert

Posted on

How $1.73M vanished in a single cast

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.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)