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.
Protocol Background
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.
Hack Analysis
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¹²⁸, 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¹²⁸-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.
Root Cause
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.
How QuillAudits Smart Contract Audit Could Have Prevented This
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.
Funds Flow After Attack
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.
Post-Attack Mitigation
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.
Relevant Addresses and Transactions
Attacker EOAs
0xDaCC235a494750193695A111D715c2ca12b5Ce380x8aaf01B6F9AcC973274B8718BE4D1C1be10E3be60xC95496c917A41a394EfdAC3e0882F5903D24De69
Vulnerable Contracts
Escrow holds protocol funds and exposes the broken conversion path: 0x9abd0b8868546105F6F48298eaDC1D9c82f7f683Escrow implementation contract, delegatecall target containing ExchangeRate._convertToETH: 0x8a134e651432A902041643668940C9a9cD270633Portfolios, mints, and tracks fCash positions: 0x0A4721117040ABF319b954aBF13F654505C34920
Attacker Contracts
0xec434a2f9b7b93aad1bed77d6bc512a75ae90d780x5872B67d58CeF954B52dEb8E97f9F614BB12fa3d0x84A060Ed81316E6741Af216A099cFea8bCDd34890x265ccfF3673bCAb03867988081cd51bFd919C03C0x4a3508C5aC0677325932f3bC786Ae7A1C3e9CAfF
Key Transactions
Position setup, the two mints that engineered the 2¹²⁸ debt: 0xe1589a19fe742f0d553889214abade69551fe944acffac014c28cc07b325d60aDrain, settlement, and withdrawal of the fabricated claims: 0xc3f3e318f7ab2d0daaba59e6ec901d25d1fe8a89aafe2b2b62e3b9aee1a24efa
Conclusion
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.
Original Posted at QuillAudits
Notional Finance $1.73M Free Collateral Integer Overflow Exploit (Explained) was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.
