Notional V1 uint128 Overflow: How $1.727M Was Drained in Two Transactions | PandaSecurity
Web3 Security

Notional V1 uint128 Overflow: How $1.727M Was Drained in Two Transactions

PandaSecurity Team •
#Notional Finance#Smart Contract Audit#Integer Overflow
Diagram of the Notional V1 fCash uint128 overflow attack showing the call flow between Escrow, Portfolios, and the ERC-1155 contract

Notional V1 uint128 Overflow: How $1.727M Was Drained in Two Transactions

The short version

In early September, Notional Finance’s legacy V1 contract stack (ERC-1155 fCash + Portfolios + Escrow) was drained by an attacker using exactly two public transactions — no admin keys, no privileged calls, nothing exotic. The loss was roughly $1.727M in DAI and USDC. The attacker never touched a price oracle and never needed a flash loan. What they needed was something more mundane and, frankly, more dangerous: a transfer path that skipped a balance check, and an integer ladder that quietly wrapped around.

At PandaSecurity, we walk through a couple of real on-chain incidents every week — not for the drama, but because the patterns inside them keep showing up, almost verbatim, in the codebases we’re asked to review. The Notional V1 incident is a clean example of that. The bug itself isn’t conceptually hard. What’s hard to explain is how long it sat there unnoticed.

What happened

The timing was tight and deliberate. The first transaction set up the malformed position; the second, roughly 73 seconds later, settled and withdrew. That gap wasn’t accidental — it was timed against the exact fCash maturity window.

Root cause: a transfer that should have been rejected, but wasn’t

To understand the bug, you need to understand how Notional V1 treats an fCash transfer.

In most protocols, an ERC-1155 transfer just moves a token the sender already owns. Notional V1 does something different: every safeTransferFrom call is treated as minting a matched pair of positions, not moving an existing one.

Here’s the part that matters: the protocol mints this pair regardless of whether from ever held the underlying position. Call safeTransferFrom, and the system assumes you’re “selling” something — and dutifully records both the debt and the claim, even if you never had the asset to begin with.

If the minted quantity were small, this would just be a bookkeeping quirk with minor implications for liquidation logic. But the attacker minted a short sized at type(uint128).max — roughly 2^128. Notional’s Escrow tracks each account’s net position in a uint128 cash ladder. When the netting arithmetic hits a value near 2^128, the ladder overflows. An account that should read as catastrophically under-collateralized instead reads as non-negative, and sails straight through the protocol’s own solvency check (freeCollateralViewAggregateOnly >= 0).

The counterparty’s receivable — which should never have existed in the first place — now looks, from the protocol’s own bookkeeping, like a fully legitimate claim that will settle normally at maturity. Once maturity hits, calling settleMaturedAssets followed by Escrow.withdraw does exactly what it’s supposed to do with a “real” claim: it hands over actual DAI and USDC sitting in the Escrow, sized to the face value of that claim.

Root cause, in one line: the mint size was pulled straight from the victim contract’s own live balance, and the ledger meant to absorb it was only 128 bits wide.

Walking through the attack

The attacker used four helper contracts — not EOAs — specifically to keep each account’s fCash ladder isolated from the others. That separation was intentional; it’s what let the overflow land cleanly on one account without disturbing the others.

RolePurpose
A1Receives a 1-unit hedge position, used to confirm the exploit contract’s own collateral ratio nets to zero
B_HUBReceives the 2^128-1 seed short — the account where the overflow actually triggers
C_DAIReceives a long position from B_HUB sized to the Escrow’s live DAI balance
D_USDCReceives a long position from B_HUB sized to the Escrow’s live USDC balance

Transaction 1 (setup, timestamp 1,788,479,927):

  1. Deploy the four helper contracts and wire up setApprovalForAll between them.
  2. Read the currently active maturities, and read the Escrow’s live DAI and USDC balances directly — the attack size is pegged to the victim’s own reserves.
  3. Mint the seed positions: a 1-unit hedge to keep the exploit contract’s own collateral ratio at zero, and the 2^128-1 short to B_HUB. The collateral check still passes cleanly at this point.
  4. From B_HUB, push out two legs sized to match the Escrow’s real balances — one payer position to C_DAI, one receiver position to D_USDC. B_HUB never held any near-maturity fCash beforehand, but the protocol mints the pair anyway, and the uint128 ladder wraps. Check B_HUB’s collateral ratio again, and it now reads non-negative — the exact moment the exploit slips past the protocol’s own self-check.

Transaction 2 (drain, timestamp 1,788,480,095 — about 95 seconds after maturity):

  1. C_DAI and D_USDC each call settleMaturedAssets to settle their now-matured claims.
  2. Each then calls Escrow.withdraw to pull the real DAI and USDC.
  3. Everything is forwarded to the attacker’s EOA.

Two transactions. No signer permissions, no admin path, no external dependency — just a sequence of ordinary calls to the protocol’s own public functions.

Why this pattern is easy to miss

A few things about this incident are worth writing down for anyone doing contract design or audit work:

“Transfer that secretly mints” is a semantic that reviewers tend to trust by default. Most audits, seeing safeTransferFrom, immediately check for reentrancy and access control. Fewer people stop to ask: “does this transfer actually require from to hold the token first?” When a protocol’s transfer function has non-standard mint-on-transfer behavior baked in, a standard ERC-1155 security checklist simply won’t catch it — because the checklist assumes the standard semantics apply.

Sizing the attack off the victim’s own live balance is hard to catch with static rules. The attacker didn’t hardcode any numbers. setup() reads IERC20(DAI).balanceOf(address(ESCROW)) directly and uses that as the mint size. That means the exploit scales automatically to whatever the Escrow happens to be holding — there’s no fixed threshold a simple limit check would flag.

Fixed-width integer ladders without overflow protection are a classic landmine in older code. Notional V1 predates some of the conventions that are now standard practice — Solidity 0.8’s built-in overflow checks weren’t necessarily applied consistently across every accounting type back then. Any place in a codebase today that still uses a fixed-width type (especially something in the “looks big enough” range like uint128) to track net positions, balances, or collateral ratios deserves a dedicated second look — particularly when that value derives from user-controlled input or the protocol’s own live state.

What we’d check in an audit

If your protocol has any “transfer that implicitly mints a position” pattern — common in perpetuals, options, fixed-rate lending, or share-issuance logic for structured products — here’s where we’d start:

  1. Preconditions on minting. Any function that transfers value but implicitly mints a new position must explicitly verify that from already holds the corresponding position, or require explicit posted collateral. Never let the default path silently mint an unbacked pair.
  2. Ledger data types and overflow protection. Every cash ladder, net position, and collateral calculation should use int256 or a properly checked wider type. Anywhere a value gets narrowed (e.g. uint128(abs())), that truncation needs to be explicit and reverting, not silent.
  3. Boundary testing near type limits. Write fuzz tests and edge cases specifically for inputs near the maximum of whatever type is in use. Collateral math that behaves fine across normal ranges often has never been tested against inputs approaching 2^128 or 2^256.
  4. Caps on single-mint size. Even with a safe ledger type, it’s worth capping any single mint or transfer relative to the protocol’s own reserves or historical activity — so a contract’s own liquidity can’t become the exact scale of its own exploit.
  5. Re-examine the settlement path separately. Logic like settleMaturedAssets generally assumes that anything reaching the settlement stage was minted legitimately. If the minting step has a flaw, settlement will faithfully execute on top of it. The trust assumptions baked into settlement deserve their own line item in an audit scope, not just a review of the minting function.

Closing thought

The Notional V1 incident is another data point for something we keep coming back to in our audits: large losses often don’t come from a deep cryptographic flaw — they come from a business-logic assumption that was never lined up against the actual bounds of a data type. Treating fCash transfers as implicit mints may well have been a reasonable simplification when the system was first designed. But it quietly pushed an ownership check that belongs at the transfer layer down into the ledger layer’s overflow behavior — and that ledger layer was 128 bits wide.

For teams still running legacy contract stacks, this is a good reminder that old code doesn’t become safe just because it’s been running quietly for years — especially when it involves custom transfer semantics and fixed-width accounting. PandaSecurity treats “implicit mint plus fixed-width ledger” as a high-priority pattern in every review, precisely so it gets caught on our side of the table instead of an attacker’s.


This attack-chain breakdown is based on the public PoC and on-chain transaction data, reproducible line-by-line in the evm-hack-analyzer environment. If your protocol has custom transfer semantics, share-minting logic, or fixed-width integer ledgers, reach out to the PandaSecurity Team for a dedicated review.

Related articles

← Back to Blog