October 2, 2026
|
Exploit Postmortem

Notional Finance's $1.73M Loss and How Olympix Would Have Prevented It

On September 4, 2026, an attacker drained about $1.73M from a legacy Notional Finance V1 escrow contract, and the whole thing turned on a single unchecked line. Notional's V1 lending system screened every borrower for collateral before letting them take on debt, and that screening converted a position's liability into a smaller numeric type as part of the math. The attacker deliberately built a liability of exactly 2 to the 128th power, the one value that conversion collapses to zero, so the contract read an enormous debt as no debt at all. With the liability reading as zero, the account looked fully collateralized, and the attacker borrowed against a debt the protocol could no longer see. The stolen DAI and USDC were swapped into roughly 689 ETH and routed through Tornado Cash. No price was manipulated, no key was stolen. One numeric conversion erased the exact number the solvency check depended on.

The contract that lost the money was Notional's legacy V1 escrow, not the version the team actually works on day to day. That detail matters, because it's the quiet way a lot of money disappears in this space. A contract doesn't become safe just because everyone stopped looking at it.

Background

A lending market is only ever as safe as its answer to one question: does this borrower actually have enough collateral to cover what they owe. Every borrow, every liquidation, every solvency guarantee is computed from that comparison. If the number representing the debt can be made wrong, the comparison is meaningless, and the market will extend credit it can never recover.

Notional V1 represented a borrower's future obligations as tokens called fCash, and when it checked an account's collateral it had to express that fCash debt in common terms. Part of that calculation converted the debt's magnitude into a 128-bit number. A 128-bit unsigned integer can only hold values below 2 to the 128th power, and anything at or above that limit doesn't error, it wraps around. The value 2 to the 128th, converted into that type, becomes exactly zero. That single property is the entire exploit.

Root Cause

The flaw lived in Portfolios.mintfCashPair(), and it had two parts that combined.

The first is the unchecked cast. Inside the collateral math, the contract converted a position's balance with a raw, unchecked conversion, uint128 absBalance = uint128(balance.abs()), rather than the safe conversion that verifies a value fits before converting it. A raw conversion keeps only the low 128 bits and silently discards the rest, so a debt of 2 to the 128th became zero with no error and no revert. Notably, the protocol used the safe, checked conversion elsewhere in the very same file. The defense existed in the codebase. It just wasn't applied on this path.

The second is where the check was pointed. mintfCashPair() creates a matched pair of positions, a payer and a receiver, in a single call, and it checks free collateral on the payer only. The code assumes the receiver's position always increases in value and therefore never needs its own check. The attacker used that by acting as the payer twice in succession, first with a trivial notional of 1 and then with a notional of 2 to the 128th, so the fabricated debt was created on an account whose solvency the function was structured not to question properly.

Put together, the attacker manufactured an enormous liability, the unchecked conversion flattened it to zero, and the one-sided collateral check never caught it. The account read as debt-free while holding the borrowed cash.

Attack Flow

  1. The attacker deployed a helper contract and, at 23:58 UTC on September 3, ran a setup transaction that called into Portfolios.mintfCashPair() through the fCash token's transfer path.
  2. Acting as the payer, they created a first position with a notional of 1, then a second with a notional of 2 to the 128th, the exact value that the unchecked uint128 conversion truncates to zero.
  3. When the free-collateral check converted that liability, the raw cast wrapped it to zero, so the account's recorded debt read as nothing while the real obligation was enormous.
  4. Reading as fully collateralized, the account borrowed against the phantom solvency, and three minutes later, at 00:01 UTC, the attacker withdrew about $1.73M in DAI and USDC from the escrow.
  5. The funds were swapped into roughly 689 ETH and sent through Tornado Cash.

Impact

The legacy V1 escrow lost about $1.73M in real stablecoins, borrowed against a debt the contract had been tricked into valuing at zero. Reporting indicates the protocol's current positions, collateral balances, and fixed-term loans were not affected, since the flaw was confined to the old V1 escrow, but the money that left was real and gone within minutes. A loss like this falls on the protocol and its remaining V1 liquidity, not on the attacker, and it does not come back on its own.

The Problem With Audit-Only Security

If you read the vulnerable line on its own, you'd walk right past it. Converting one number type into another is about as routine as code gets, and this conversion does its job perfectly for every value Notional ever expected to see. It only misbehaves for one input in the entire range, a debt engineered to land exactly on the point where the conversion overflows. Nobody writes a test for that number, because nobody thinks of it, which is the whole problem with trusting a human read to catch it.

This is the real difference between reading code and attacking it. A reviewer confirms the logic works for the situations they can picture. An attacker spends their time hunting for the one situation nobody pictured, and an unchecked numeric conversion hands them a standing invitation, because there's always a value big enough to break it. The danger was never in what the line does. It was in what the line does at its very edge, and edges are where review runs out of imagination.

The part that really stings is that Notional already had the safe version of this conversion, the one that refuses to drop digits, and used it elsewhere in the very same file. This wasn't a team that didn't know better. It was one unguarded path sitting among a dozen guarded ones, a single missed spot in a large contract, and spotting that one spot by eye is close to the hardest thing you can ask of a human reviewer. It's also close to the easiest thing to ask of a tool that tests every conversion against the value that would break it. An audit is worth having and a good reviewer catches a lot, but counting on someone to notice the one cast out of many that forgot its guardrail is counting on a good day.

How Olympix Catches This

This is a bug Olympix catches, and it's worth saying exactly how, because the how is the whole reassurance. An unchecked conversion feeding a collateral check is one of the most familiar shapes of vulnerability in Solidity, and the rule it breaks is simple to state: a number must mean the same thing after a conversion as it did before, across every value an attacker could possibly supply. Olympix works that rule out from the contract and then goes looking for the input that breaks it, driving the conversion all the way to its 128-bit edge and past it, the exact place where a raw cast and a safe one stop agreeing. Testing that boundary isn't a clever guess the engine got lucky on, it's just what the engine does, so the 2-to-the-128th debt that reads as zero shows up as a break the moment the conversion is pushed that far. We've surfaced this class of flaw before, and this is squarely the kind it's built to find.

Had Notional run this through Olympix before the V1 escrow went live, the unguarded cast would have come back as a finding, with the exploit path attached, long before an attacker ever went looking for it. The money that left three minutes after midnight was always reachable through that one line, and the one line was always testable. That's the gap a tool closes that a deadline-pressed read can't promise to: not catching the bugs you thought to look for, but catching the one you didn't.

None of this makes the auditor redundant. A sharp reviewer who checks every cast finds this, and the review is worth having. The point is that catching it shouldn't depend on whether one person had enough attention left for one line on one particular day. Olympix tests the conversion against the value that breaks it every time it runs, which is how a flaw like this goes from something you hope someone spots to something that simply gets caught.

Takeaway

A lending market's safety rests entirely on the number it uses for debt, so a debt that can be made to read as zero isn't an accounting quirk, it's a total bypass of the solvency check. Notional V1 converted a liability through an unchecked cast on one path, the attacker built the one debt value that conversion flattens to nothing, and $1.73M left an escrow that believed the account owed it nothing. The safe conversion was already sitting in the same file, which is the whole point: this wasn't exotic, it was one guard missed among many applied, and finding that one missed guard is exactly the kind of boundary test a machine runs by default and a human runs only if they happen to think of it. Testing what a conversion does at its edge, before the contract holds a dollar, is the difference between a collateral check that holds and one that can be convinced a fortune is nothing. And it's a reminder that a contract doesn't stop being exploitable just because a newer version exists, the old one is still live until it isn't. Olympix tests the numeric conversions in your own contracts against the values that break them and flags the unguarded ones before they ship. You can see it run in a technical walkthrough.

On-chain references

Protocol: Notional Finance (legacy V1 escrow / fCash system)

Chain: Ethereum

Date: setup 23:58 UTC Sep 3, 2026 (block 25,900,220); drain 00:01 UTC Sep 4, 2026 (block 25,900,234)

Loss: ~$1.73M (69,257 DAI + 1,658,524 USDC), swapped to ~689 ETH and routed through Tornado Cash

Vulnerable function: Portfolios.mintfCashPair()

Root cause: a raw unchecked cast, uint128(balance.abs()), used in place of SafeCast.toUint128() in the free-collateral calculation, so a debt of 2^128 truncates to zero. mintfCashPair() also checks free collateral on the payer only, so a fabricated debt on that account is never caught. The safe checked cast was already used elsewhere in the same file.

Note: confined to legacy V1; current Notional positions reported unaffected.

An unchecked numeric conversion that feeds a collateral check is a textbook, testable flaw, and it was the whole of this exploit. If you run contracts whose safety depends on a number being what it says, see Olympix test your conversions at the boundaries that break them, and flag the unguarded ones before they ship. Book a technical walkthrough.

More from Olympix:

No items found.

Ready to Shift Security Assurance In-House? Talk to Our Security Experts Today.