Skip to content
Dark isometric illustration of a three-tier vault seen from a raised corner, the two upper tiers gutted with their cell doors swung open and studs dark behind a drawn roll-down shutter that stops above the packed teal lower tier, where one forced cell burns red with its trays pulled out
exploitsSeptember 10, 20263 min read

Notional Finance $1.73M Exploit: A Debt of 2^128 That Read as Zero

Aron Turner
Aron TurnerCo-Founder & CTO

Updated on September 10, 2026

TL;DR

On September 4, 2026, an attacker drained roughly $1.73 million in DAI and USDC from Notional Finance's V1 escrow contract on Ethereum. The root cause was a numeric truncation bug: the free-collateral check converted a signed debt balance into ETH terms by taking its absolute value and casting it to uint128. The attacker built a liability of exactly 2^128, the one value that a uint128 cast flattens to zero, and the protocol read the account as debt free. Two transactions, two minutes and forty-eight seconds apart, moved 69,257 DAI and 1,658,524 USDC out of a contract deployed in 2020. The funds became about 689 ETH and went into Tornado Cash within half an hour. We found no Notional postmortem published as of September 10, 2026. The bug was in the code, but the loss was in the inventory: a protocol you have stopped operating is not a protocol you have stopped running.


What Happened to Notional Finance?

Notional Finance is a fixed-rate lending protocol on Ethereum. The contract that lost money was not the one users interact with today. It was V1, the original 2020 deployment, and it was still funded.

The timeline is tight. According to The Crypto Times, citing on-chain records, a setup transaction landed at 11:58:47 PM UTC on September 3, 2026 in block 25,900,220. The drain confirmed at 12:01:35 AM UTC on September 4 in block 25,900,234. The escrow sent 69,257.38 DAI and 1,658,524.864122 USDC out in that second transaction, and both amounts were forwarded to the same attacker-controlled address.

Detection came from outside the project. On-chain monitor Specter flagged the abnormal transaction, PeckShield relayed the alert, and CertiK published the mechanism the same morning: two mintfCashPair() calls creating a negative 2^128 liability, truncated to zero by an unsafe uint128() downcast in free-collateral valuation. SlowMist published a full transaction-level reconstruction four days later.

The reported figure varies slightly by source, and the variance is not a disagreement. PeckShield's alert and most coverage say "$1.7 million," a mark-to-market number taken at the time of the alert. crypto.news reported $69,242 in DAI and $1,658,423 in USDC, which are dollar values, not token counts. The token counts on the explorer are 69,257.38 and 1,658,524.86, which sum to $1,727,782 at parity. We use the token counts. Stablecoins trading a fraction of a cent under a dollar explain the whole spread, and the on-chain amounts are the thing that actually moved.

The escrow contract sits at 0x9abd0b8868546105F6F48298eaDC1D9c82f7f683, labeled "Notional Finance: Escrow," a verified proxy with 1,417 lifetime transactions. As of September 10, 2026 it still holds just under $60,000 across 44 token contracts. Six days after the drain, nobody had swept the remainder.


What Is fCash, and How Did the Collateral Check Work?

fCash is a cash claim with an expiration date. In Notional V1, every position has two sides: a receiver, who gets paid at maturity, and a payer, who owes at maturity. The protocol writes both sides into an account's Portfolio, then decides whether the account is solvent enough to hold them.

The minting path is the part that matters. safeTransferFrom on the ERC1155Trade contract looks like a token transfer, but when the asset type is a cash receiver it calls Portfolios.mintfCashPair, which records a liability for the payer and an equal claim for the receiver. SlowMist describes it plainly: the call "actually results in the paired minting of fCash." Nothing is transferred. Debt is created.

After the position lands, the RiskFramework contract sums the payer's fCash liabilities into a signed int256, then asks the Escrow to convert each currency balance into ETH so the totals can be compared. Negative means owed, positive means held. mintfCashPair then requires only that the payer's free collateral is greater than or equal to zero.

That is the whole gate. One signed number, converted to ETH, compared against zero.


The Root Cause: An Unchecked uint128 Downcast

The conversion lives in ExchangeRate._convertToETH, used by the Escrow implementation. SlowMist quotes it:

uint128 absBalance = uint128(balance.abs());
// If balance.abs() exceeds uint128, the conversion result will be silently truncated
int256 result = int256(
    SafeCast.toUint128(rate.mul(absBalance) /* subsequent conversion */)
);
return balance > 0 ? result : result.neg();

balance.abs() returns a uint256. The cast to uint128 keeps the low 128 bits and discards the rest, which is documented behavior for an explicit conversion to a smaller integer type in Solidity rather than a compiler quirk. SlowMist's annotated listing marks the line directly: a value exceeding uint128 "will be silently truncated." 2^128 in binary is a single set bit sitting one position above the top of a uint128. Cut the high bits off and nothing remains. The result is zero.

This is CWE-197, Numeric Truncation Error: data lost when a value is cast to a primitive of smaller size. It is one of the oldest classes in the catalog, and it is boring, which is part of why it survives in code nobody is reading.

The detail that turns an old weakness into a heist is that 2^128 is the only value in range that produces a clean zero. Anything smaller converts to a real, non-zero ETH figure and fails the check. Anything larger overflows the addition first. The attacker had to hit one number exactly, and did, by minting a liability of 1 and a liability of uint128.max. One plus 2^128 minus 1 is 2^128.

The paired liabilities went to two different maturities, September 4 and December 3, 2026. That was not decoration. SlowMist notes that identical bond assets would have been summed directly, and that addition uses SafeMath, so it would have reverted. SafeMath was working. The attacker split the position across two maturities to route around the one arithmetic guard the contract did have, and then walked into the one it did not.

The first mint had a second job. At an amount of 1, its ETH-denominated value rounds down to zero under the exchange rate and precision, so the first free-collateral check passes on a technicality before the large one is ever created. The overflow only needed to survive a single check, on the second call.

QuillAudits found that safer conversion methods were used elsewhere in the same codebase, and not in the function the attacker targeted. The contract imports SafeCast and uses it two lines below the raw cast. The guard was in the file, in the same expression, applied to the wrong operand.


The Attack, Step by Step

#ActionWhy it worked
1Deploy auxiliary contracts, call setApprovalForAll on ERC1155Trade, precompute two maturities and three AssetId valuesSetup transaction 0xe158...d60a, block 25,900,220, 11:58:47 PM UTC September 3
2safeTransferFrom mints an fCash pair of amount 1 to receiver 1, cashGroupId 2, maturity 1788480000Value rounds to zero in ETH terms, so the first collateral check passes
3safeTransferFrom mints an fCash pair of uint128.max to receiver 2, maturity 1796256000Different maturity avoids SafeMath addition overflow on the payer's liability
4RiskFramework sums the payer's liabilities to negative 2^128 and calls convertBalancesToETHabs() gives 2^128, the uint128 cast truncates it to 0, free collateral reads as healthy
5Receiver 2 splits its claim across two more auxiliary contractsReceiver 2 holds a large positive claim, so those transfers pass the check legitimately
6Settle the matured claims, call Escrow.withdrawDrain transaction 0xc3f...24efa, block 25,900,234, 12:01:35 AM UTC September 4

The path branches at two points, which is why the sequence is worth drawing:

rendering diagram…

Two pieces of tradecraft sit around the code. BeInCrypto reports that the attacker tipped block builder Titan 0.07 ETH to route the trade privately, keeping the setup transaction out of the public mempool where a searcher could have copied it. And the exit was rehearsed: the stablecoins became roughly 689.2 ETH, and Tornado Cash deposits in denominations of 100, 10, 1 and 0.1 ETH began at 12:15:59 AM UTC and continued through at least 12:30:11 AM. Fourteen minutes from drain to mixer. Thirty-one minutes from the first transaction to the last laundering deposit.

Worth sitting with the economics, because there are barely any. The attacker deposited nothing. mintfCashPair conjures a liability and a matching claim, then asks whether the payer can carry it, so there was no collateral position to fund and no flash loan in the sequence. The whole capital requirement was gas and a 0.07 ETH tip.


Why Did a 2020 Contract Still Hold Real Money?

Shutting a protocol down is a deploy, and almost nobody ships it.

Notional did not neglect its wind-down. It executed one. After a Balancer V2 vulnerability cascaded into five of its vaults on November 3, 2025, Notional wrote in its Balancer Hack Response that "it would not be possible to return Notional V3 to a functioning state in production," and wound V3 down, distributing user funds. Mainnet ETH lenders took a 56.019% haircut, Arbitrum lenders 19.244%. That post runs through the damage and the distribution math in detail. It does not mention V1 or V2 once.

That is the shape of the failure. The wind-down was scoped to the version the team was operating. V1 had been superseded twice and was not part of the conversation, so the plan that emptied V3 never reached it. The escrow kept custody of $1.73 million in stablecoins and an assortment of long-tail tokens, on a contract deployed in 2020 and running Solidity 0.6.x.

Nobody was watching it, and there was very little to watch. Across nearly six years the escrow accumulated 1,417 transactions. A contract that averages well under one transaction a day is exactly the kind of thing that falls off a dashboard, and exactly the kind of thing where a single anomalous call is unmistakable if anyone is still looking.

We have written this pattern up before under a different disguise. When the Tornado Cash frontend domain lapsed and someone else registered it, 810 ETH walked out through infrastructure the project had stopped thinking about but users had not stopped trusting. Different asset, same accounting error: the org's mental model of what it operated no longer matched what was actually deployed and holding value.


What This Generalizes To

Three things travel beyond Notional.

Solidity 0.8 does not fix this. The most common reaction to "integer overflow" in 2026 is that the compiler handles it. It handles arithmetic. Since 0.8.0, arithmetic operations revert on underflow and overflow, which is why SafeMath is now mostly redundant. Explicit narrowing conversions were never brought under that regime, and uint128(x) still silently drops the high bits in the current compiler. Every codebase that migrated to 0.8 and deleted its SafeMath imports on the assumption that arithmetic was now safe still has this exposure anywhere it downcasts. Cast sites are the residue the migration left behind.

A signed value crossing into an unsigned type is a boundary, and boundaries want invariants. Blame the cast and you have only found half of it. The deeper failure is a solvency check that compared a converted value against zero without ever asserting that the conversion preserved the value. require(converted != 0 || original == 0) in the free-collateral path would have reverted the second mint. Cheap, obvious in hindsight, and absent.

Deprecated does not mean decommissioned. This is the one worth arguing about, because the industry's default answer to "is that old deployment a risk?" is that it was audited once and nobody uses it. An audit certifies the code you showed it, on the day you showed it. It says nothing about whether that code still holds a million and a half dollars six years later, and a 2020 review scope does not extend to a 2026 threat model. The same lesson ran through Raydium's legacy pools earlier this year, and it will run through someone else's V1 next quarter. Contracts do not retire. Teams do.


What Operators Should Do

  1. Inventory every deployment that can still custody value, not every deployment you support. Two different lists. Old factories, escrows, vaults, routers and proxies from superseded versions belong on the second one whether or not your frontend links to them. If the address can hold a balance, it is in scope.

  2. Make decommissioning an executed procedure with an owner. A wind-down plan that stops at the current major version is not a wind-down. Sweep residual balances to a controlled address, pause or renounce where the contract allows it, and write down what you could not sweep and why.

  3. Grep for narrowing casts and treat each one as a finding. uint128(, uint64(, uint32(, int128( against a wider source. Replace with SafeCast, or add an explicit range check that rejects anything above type(uintN).max. Notional's own file shows how this goes wrong: SafeCast present, applied to the wrong value.

  4. Fuzz the boundaries, not the middle. Property tests over type(uint128).max, type(uint128).max + 1, 2^128, and the absolute value of the most negative signed integer. SlowMist's recommendation after this incident is exactly that, and the exploit needed one specific boundary value to work.

  5. Assert conservation across unit conversions. Any function that converts a balance into another denomination should carry an invariant that a non-zero input cannot produce a zero output. This is the single check that would have stopped this attack.

  6. Alarm on balances in contracts you consider dead. A standing rule on "any address on the legacy list holds more than $X" needs no threat intelligence and no anomaly model, and it would have been shouting for years.

On monitoring, the honest answer is split. Four events here were alarmable, and every figure is one already cited above: a setApprovalForAll granted on the V1 ERC1155Trade contract to contracts deployed minutes earlier; an fCash pair minted at uint128.max, a value no real position takes; a free-collateral result of exactly 0 on an account carrying two open liabilities; and 69,257 DAI plus 1,658,524 USDC sitting in a proxy deployed in 2020.

The first three fired inside a 2 minute 48 second window. Alerting does not front-run that, and anyone claiming it would have stopped this drain is selling something. The fourth signal had nearly six years to fire, costs nothing to write, and is the one that mattered. Runtime monitoring earns its keep on that fourth kind. The first three are what an audit or an automated scan of the V1 code should have caught before the money ever arrived, and given that SafeCast was sitting two lines away, a scan is where we would have put the money.


Frequently Asked Questions

How much did Notional Finance lose? 69,257.38 DAI and 1,658,524.864122 USDC, about $1.73 million, drained on September 4, 2026. Security firms reported the figure as "$1.7 million" using prices at the time of the alert. The escrow still holds just under $60,000 in other tokens.

Was Notional V2 or V3 affected? The reported incident is confined to the V1 escrow contract. V3 was already wound down in November 2025 after the Balancer V2 hack. We found no Notional incident report for the V1 exploit as of September 10, 2026, so the scope statement rests on third-party analysis rather than a project postmortem.

Whose money was it? Unconfirmed. Coverage at the time noted that whether the drained funds belonged to users, the treasury, or a third party had not been established, and no project statement has resolved it since.

Could a modern Solidity version have prevented this? Not by itself. Solidity 0.8 made arithmetic operations checked by default, but explicit narrowing conversions still truncate silently in the current compiler. A SafeCast call or an explicit range check was required, and one was already used elsewhere in the same file.

Can the funds be recovered? Unlikely. The stablecoins were swapped into roughly 689.2 ETH and deposited into Tornado Cash within about half an hour of the drain, in fixed denominations designed to break the trail.


Sources / References

Aron Turner
Aron Turner

Co-Founder & CTO

CTO of SigIntZero. Engineering leadership, infrastructure architecture, and security tooling.