BLOCKCHAIN AI.NEWS

Security · Analysis

The Auditors Found It in 2020. They Marked It Low

Roughly $1.73 million left a dormant Notional escrow on Friday because an enormous debt was converted into a number that read as nothing. Two OpenZeppelin audits flagged that conversion. The lines they named still carry it.

Editorial illustration: an enormous frosted-glass counterweight sits on a chrome balance scale whose beam stays perfectly level and whose readout glows at zero
✓ First flagged on-chain by Specter; first public alert from PeckShieldAlert (Sep 4) · Transaction timeline and addresses: The Crypto Times · Early reporting: crypto.news · Root-cause analysis attributed to CertiK and QuillAudits via Crypto Economy and BeInCrypto · Audit finding: OpenZeppelin · Contract source read directly from notional-finance/contracts by this desk

At 23:58:47 UTC on Wednesday, an address ending Ce38 sent a setup transaction to a Notional Finance escrow contract that most of the market had stopped thinking about years ago. Three minutes later, in block 25,900,234, it took the money: 1,658,524 USDC and 69,257 DAI, roughly $1.73 million, out of a contract nobody had bothered to empty.

By 00:15 UTC the stablecoins were about 689 ETH. By 00:30 they were inside Tornado Cash, deposited in the mixer's standard denominations — batches of 100, 10 and one. The blockchain investigation account Specter noticed the flow first. PeckShield's alert went out at 00:59, hedged in the way these alerts always are at the start: the contract, it said, "may have been exploited."

That hedge was appropriate. Both crypto.news and The Crypto Times published while the mechanism was still unknown, and said so plainly — the alerts, as The Crypto Times put it, did not establish how the money left. The technical account arrived afterward, from CertiK and QuillAudits, and it is worth reading slowly, because it describes one of the oldest failure modes in software and one of the best-documented in this particular codebase.

How a debt becomes zero

Before Notional's V1 system would let an account walk away with collateral, it ran a free-collateral check: total up what the account owes, convert that obligation into ETH terms, and confirm the account is solvent. Standard. The conversion is where it broke.

Per CertiK's analysis, the attacker made two calls to mintfCashPair(), constructing a liability of exactly −2128. The valuation code took the absolute value of that balance — 2128 — and cast it down into a 128-bit unsigned integer. A uint128 holds values from 0 to 2128−1. It cannot hold 2128. Without a check, the cast wraps, and 2128 becomes 0.

Not a small number. Not a rounding error. Zero. The solvency check looked at an account carrying an astronomically large debt and saw an account carrying none, and let it withdraw. QuillAudits made the obvious point: an explicit overflow check would have reverted the transaction rather than quietly discarding the digits.

Five steps from a fabricated debt to an open door

Step Value the code is holding
Two mintfCashPair() calls create a liability−2128
Valuation takes the absolute value2128
Unchecked cast into a 128-bit unsigned integer0 — the one value that wraps exactly
Free-collateral check reads the account's debtNone
Withdrawal permitted$1.73M
Mechanism per CertiK's post-incident analysis, as reported by Crypto Economy and BeInCrypto. Notional has not published its own post-mortem.

The finding, from December 2020

Here is what makes this more than another integer-overflow story. In December 2020, OpenZeppelin audited Notional — the second of two audits the firm performed on the system. Its finding L10 is titled, in full, casting between types without overflow checks.

The finding does not describe the problem in the abstract. It names the functions. Within _convertToETH on line 49 of ExchangeRate.sol, and _convertETHTo on line 80, the auditors wrote, the int256 balance input is explicitly cast to a uint128. Those are the free-collateral conversion functions — the code that turns an obligation into ETH terms so the solvency check can read it. The recommendation was to use a SafeCast library so that the casts "cannot corrupt values."

OpenZeppelin also recorded that this was not the first time it had raised the issue. The firm had flagged unsafe casting in its prior audit of the same system; Notional, it wrote, partially addressed the concern, and the remaining instances were supposed to be removed before this second audit. They were not. "The codebase is not yet entirely free of this issue," the report says, before allowing that the scenarios in which it would bite "may well be unlikely to happen."

The severity assigned to L10 was Low. OpenZeppelin's scale that year ran Critical, High, Medium, Low, Notes. Two audits had raised the finding; the second one rated it fourth out of five.

What the source says now

This desk read the contract source directly. In the public notional-finance/contracts repository, ExchangeRate.sol imports OpenZeppelin's SafeCast library on line 10. It uses it, too — inside both conversion functions, the result of the rate multiplication is wrapped in SafeCast.toUint128(...), exactly as the auditors asked.

One line above each of those safe conversions sits this:

uint128 absBalance = uint128(balance.abs());

Line 49 in _convertToETH. Line 80 in _convertETHTo. The same two line numbers OpenZeppelin printed in December 2020. A raw, unchecked downcast of a signed 256-bit balance into a 128-bit unsigned integer, with the safety library it needs imported at the top of the same file and called on the very next statement.

That is the shape of a half-applied fix. Nobody ignored the audit. Someone read L10, added the import, wrapped the arithmetic result — and did not wrap the input cast the finding actually named. The file has looked defended ever since, which is worse than looking undefended, because a reviewer skimming for SafeCast would find it.

Two caveats belong here, and they are real ones. The repository's current master is not necessarily byte-identical to the bytecode deployed to the escrow contract in 2020 and 2021, and Notional has published no post-mortem identifying the exploited line. The desk is reporting a match between what the auditors named, what the source shows, and what CertiK describes — not a confession. If Notional publishes a different account, this story will carry the correction.

Nobody swept the building

The contract that lost the money was V1. Notional had already moved on twice. After the November 2025 Balancer exploit cascaded into its V3 vaults, the protocol wound that version down. What none of that did was empty the first system. The V1 escrow stayed deployed, stayed funded, and stayed reachable by anyone with a transaction and the patience to read five-year-old Solidity.

There is a small but telling discrepancy in the record here. DefiLlama's incident feed logs the event under Notional V2. Every outlet that has described the affected contract calls it the legacy V1 escrow. The desk has not been able to reconcile the two labels, and Notional's silence is the reason it cannot. When the protocol itself does not say which of its own systems was drained, the trackers guess, and the guesses go into the datasets that everyone else cites next year.

As of publication, Notional Finance had issued no public incident report and had not said whether the drained assets were protocol funds or user deposits. That question has been open since Friday morning.

The dead code nobody owns

Every protocol that has shipped a second version is running this risk, and most of them are not thinking about it. Deprecation is a marketing event — the docs point at the new contracts, the front end stops offering the old ones, the community moves. The bytecode does not move. It sits at its address with whatever balance it had on the day attention left, governed by whatever assumptions were current when it was written, and it will answer a call from anyone forever.

What the Notional drain adds is the audit trail. This was not an unknown flaw in unreviewed code. It was a known flaw, in reviewed code, in a function the reviewers named by line number, in a system whose maintainers had been told about it twice. It survived because it was rated Low — and Low is a judgment about probability, made on a particular day, about a system that was then actively maintained. Nobody re-rates a Low finding when the team that would have fixed it has moved to V3.

The Take

Low severity is not a property of a bug. It is a bet about the environment around the bug, and environments change. OpenZeppelin's reasoning in 2020 was sound: reaching that cast required an attacker to construct a value of exactly 2128, which is not something that happens by accident in a live, watched, maintained system. Five years later the system was none of those things, and the arithmetic that was unlikely became merely difficult. The lesson is not that auditors are wrong to triage — they have to. It is that a severity rating expires along with the assumptions that produced it, and nothing in this industry re-reads old audits against dead code. If you have deprecated contracts still deployed and still funded, the finding to go read tonight is not your Criticals. Those got fixed. It is your Lows.

More on the subject