An unchecked uint128 cast let a fabricated 2^128 debt read as zero, bypassing Notional Finance's entire free collateral check.

On September 3, 2026, roughly $1.73M in DAI and USDC left Notional Finance's V1 Escrow contract in a single transaction. There was no flash loan, no price oracle manipulation, and no compromised key. The attacker minted a fabricated fCash claim equal to Escrow's entire live balance and withdrew it as real tokens, using a raw type-narrowing cast in Notional's collateral valuation code that let a maximally insolvent account report zero debt.
Notional Finance V1 represents fixed-rate lending positions as ERC1155 tokens called fCash. Every position is one half of a payer and receiver pair, the payer owes cash at maturity, the receiver is entitled to collect it. Transferring one of these ERC1155 tokens does not move an existing balance. It calls Portfolios.mintfCashPair(), which mints a brand new offsetting pair on the spot, a design built for OTC block trades. That function's only safeguard is a free collateral check on the payer, computed in Portfolios._freeCollateral() and converted into an ETH value by Escrow.convertBalancesToETH(). The entire system's solvency rests on that one conversion being correct.
An attacker-controlled helper contract deployed four disposable contracts.

The helper contract then called safeTransferFrom on Notional's ERC1155 fCash token, which routed into Portfolios.mintfCashPair(). That function creates a new payer and receiver position in a single call and checks free collateral on the payer only, an assumption written directly into the code that the receiver's position always increases and therefore needs no check of its own.


The same account acted as payer twice in a row, first for a notional of 1, then for a notional of 340282366920938463463374607431768211455, the maximum value a uint128 can hold. Combined, the two debts summed to exactly 2^128, a boundary value chosen with precision rather than brute force.


That combined debt reached Escrow.convertBalancesToETH(), which calls ExchangeRate._convertToETH() to price the payer's balance in ETH for the solvency check. Inside that function, uint128 absBalance = uint128(balance.abs()) casts the debt with a raw, unchecked cast rather than SafeCast.toUint128(). A value of exactly 2^128 truncates cleanly to 0 in that cast, so the largest debt mathematically possible was read as owing nothing.


The second disposable contract, 0x84A060Ed81316E6741Af216A099cFea8bCDd3489, the one holding that fabricated 2^128-1 claim, passed its own free collateral check on the strength of it, even after Notional's standard haircut, a claim that size dwarfs any real-world debt. It called safeTransferFrom twice more, once with a notional of 69,257,372,677,950,923,155,658 sent to a third contract, 0x265ccfF3673bCAb03867988081cd51bFd919C03C, once with a notional of 1,658,524,864,122 sent to a fourth contract, 0x4a3508C5aC0677325932f3bC786Ae7A1C3e9CAfF.


Both calls routed through mintfCashPair() again, so the third contract came away holding a receiver claim exactly equal to Escrow's real DAI balance, and the contract came away holding a receiver claim exactly equal to Escrow's real USDC balance.
In a second transaction, Portfolios.settleMaturedAssets() converted the third contract fabricated claim into a real, internally tracked Escrow cash balance, and Escrow.withdraw() checked that balance, found no offsetting debt, and paid out 69,257.372677950923155658 DAI from Escrow to 0x265ccfF3673bCAb03867988081cd51bFd919C03C, which forwarded the full amount to the attacker's main address, 0xDaCC235a494750193695A111D715c2ca12b5Ce38, in the same transaction.


The same transaction repeated the process for the fourth contract, settling its fabricated claim into an Escrow cash balance and paying out 1,658,524.864122 USDC from Escrow to 0x4a3508C5aC0677325932f3bC786Ae7A1C3e9CAfF, which likewise forwarded the full amount to 0xDaCC235a494750193695A111D715c2ca12b5Ce38.


The root cause is an unchecked, raw uint128() cast on a debt balance inside ExchangeRate._convertToETH(), in place of the reverting SafeCast.toUint128() used elsewhere in the same file.

Two things made that cast reachable in the first place. Portfolios.mintfCashPair() checks solvency on the payer only, so a fabricated debt on one account is never caught by a check on the account that actually benefits from it.

RiskFramework.sol itself computes the debt correctly, using safe, reverting arithmetic throughout, which meant the attacker had to engineer one exact value rather than exploit sloppy math earlier in the chain. Each factor on its own would have limited the damage, together they turned a single missing bounds check into a full drain.
Type-boundary fuzzing on every narrowing cast. Fuzzing _convertToETH() and every other raw uint128() or uint256() cast with boundary values, 0, 2^128 minus 1, and 2^128 itself, would have surfaced the exact wrap that zeroed out the payer's debt.
Two-sided solvency checks on any function that mints offsetting positions. A review of mintfCashPair() against the principle that both sides of a newly created payer and receiver pair need verification, not just the side assumed to be taking on risk, would have flagged the one-sided freeCollateral(payer) check as a design gap on its own, independent of the cast bug.
A project-wide ban on raw narrowing casts in solvency-critical paths. A lint rule or manual pass flagging every uint128(x) or uint256(x) cast on a value that can carry adversarial input, requiring SafeCast or an explicit bounds check instead, would have caught this specific line even without the fuzzing pass above.
The DAI and USDC withdrawn from Escrow were moved through intermediary wallets and consolidated into a single address.

That address swapped the combined DAI and USDC for approximately 689.2 ETH.



The ETH was then deposited into Tornado Cash.

At the time of writing, Notional Finance has not published a tweet, statement, or post-mortem addressing this incident, and has not disclosed an official loss figure or confirmed root cause. This section will be updated once the protocol responds.
Attacker EOAs
Vulnerable Contracts
ExchangeRate._convertToETH: 0x8a134e651432A902041643668940C9a9cD270633Attacker Contracts
Key Transactions
This was not a flash loan attack and not a price manipulation. It was a single unchecked cast, uint128(balance.abs()), sitting inside a function that turns a debt balance into an ETH-denominated solvency check. Because Notional's fCash minting function trusted that check completely and only applied it to one side of every new position, an attacker who could engineer one specific number, 2^128, could make the largest possible debt look exactly like zero. Roughly $1.73M in DAI and USDC left Notional's V1 Escrow contract as a result, swapped to ETH and moved into Tornado Cash. In a system built entirely on solvency checks, the check itself has to be the most carefully verified line in the codebase, because everything downstream believes whatever number it returns.
Contents


From day-zero risk mapping to exchange-ready audits — QuillAudits helps projects grow with confidence. Smart contracts, dApps, infrastructure, compliance — secured end-to-end.