From 0dd764b8b3c752eff0b623bbf7c33e2ee1713a43 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 12 Mar 2026 08:50:07 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20fix:=20Restore=20proper=20VWAP=20?= =?UTF-8?q?=E2=80=94=20gas-efficient=20volume-weighted=20pricing=20(revert?= =?UTF-8?q?=20TWAP)=20(#603)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace pool.observe() TWAP price source with current pool tick (pool.slot0()) sampled once per recenter - Remove _getTWAPOrFallback() and TWAPFallback event (added by PR #575) - _scrapePositions now takes int24 currentTick instead of uint256 prevTimestamp; price computed via _priceAtTick before the burn loop - Volume weighting (ethFee * 100) is unchanged — fees proxy swap volume over the recenter interval - Direction fix from #566 (shouldRecordVWAP only on sell events) is preserved - Remove test_twapReflectsAveragePriceNotJustLastSwap (tested reverted TWAP behaviour) - ORACLE_CARDINALITY / increaseObservationCardinalityNext retained for _isPriceStable() - All 188 tests pass Co-Authored-By: Claude Sonnet 4.6 --- onchain/src/LiquidityManager.sol | 57 +++---------------- onchain/src/VWAPTracker.sol | 8 +-- onchain/test/VWAPFloorProtection.t.sol | 76 -------------------------- 3 files changed, 12 insertions(+), 129 deletions(-) diff --git a/onchain/src/LiquidityManager.sol b/onchain/src/LiquidityManager.sol index f16d8aa..419e27d 100644 --- a/onchain/src/LiquidityManager.sol +++ b/onchain/src/LiquidityManager.sol @@ -51,8 +51,7 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { /// @notice Last recenter tick — used to determine net trade direction between recenters int24 public lastRecenterTick; - /// @notice Last recenter timestamp — rate limits open recenters and provides the previous - /// recenter time for TWAP interval calculations. + /// @notice Last recenter timestamp — rate limits open recenters. uint256 public lastRecenterTime; /// @notice Minimum seconds between open recenters (when recenterAccess is unset) uint256 internal constant MIN_RECENTER_INTERVAL = 60; @@ -61,9 +60,6 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { /// @notice Emitted on each successful recenter for monitoring and indexing event Recentered(int24 indexed currentTick, bool indexed isUp); - /// @notice Emitted when pool.observe() falls back to anchor midpoint; non-zero elapsed - /// indicates degraded oracle operation rather than normal bootstrap. - event TWAPFallback(uint32 elapsed); /// @notice Custom errors error ZeroAddressInSetter(); @@ -90,7 +86,7 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { token0isWeth = _WETH9 < _kraiken; optimizer = Optimizer(_optimizer); // Increase observation cardinality so pool.observe() has sufficient history - // for TWAP calculations between recenters. + // for _isPriceStable() TWAP checks. pool.increaseObservationCardinalityNext(ORACLE_CARDINALITY); } @@ -150,7 +146,6 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { require(block.timestamp >= lastRecenterTime + MIN_RECENTER_INTERVAL, "recenter cooldown"); require(_isPriceStable(currentTick), "price deviated from oracle"); } - uint256 prevTimestamp = lastRecenterTime; lastRecenterTime = block.timestamp; // Check if price movement is sufficient for recentering @@ -187,7 +182,7 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { } lastRecenterTick = currentTick; - _scrapePositions(shouldRecordVWAP, prevTimestamp); + _scrapePositions(shouldRecordVWAP, currentTick); // Update total supply tracking if price moved up if (isUp) { @@ -222,11 +217,13 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { /// @notice Removes all positions and collects fees /// @param recordVWAP Whether to record VWAP (only when net ETH outflow / price fell since last recenter, or at bootstrap) - /// @param prevTimestamp The block.timestamp of the previous recenter, used to compute TWAP interval - function _scrapePositions(bool recordVWAP, uint256 prevTimestamp) internal { + /// @param currentTick The current pool tick at time of recenter, used as the VWAP price sample + function _scrapePositions(bool recordVWAP, int24 currentTick) internal { uint256 fee0 = 0; uint256 fee1 = 0; - uint256 currentPrice; + // Price at current tick: volume-weighted, sampled once per recenter. + // token0isWeth: tick represents KRK/ETH — negate for price in ETH per KRK terms. + uint256 currentPrice = _priceAtTick(token0isWeth ? -1 * currentTick : currentTick); for (uint256 i = uint256(Stage.FLOOR); i <= uint256(Stage.DISCOVERY); i++) { TokenPosition storage position = positions[Stage(i)]; @@ -240,12 +237,6 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { // Calculate fees fee0 += collected0 - amount0; fee1 += collected1 - amount1; - - // Record price from anchor position for VWAP using pool TWAP oracle. - // Falls back to anchor midpoint when elapsed == 0 or pool.observe() reverts. - if (i == uint256(Stage.ANCHOR)) { - currentPrice = _getTWAPOrFallback(prevTimestamp, position.tickLower, position.tickUpper); - } } } @@ -276,38 +267,6 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle { } } - /// @notice Computes price using pool TWAP oracle between prevTimestamp and now. - /// @dev Falls back to anchor midpoint when the interval is zero or pool.observe() reverts - /// (e.g. insufficient observation history on first recenter or very short intervals). - /// @param prevTimestamp Timestamp of the previous recenter (0 on first recenter) - /// @param tickLower Lower tick of the anchor position (used for fallback midpoint) - /// @param tickUpper Upper tick of the anchor position (used for fallback midpoint) - /// @return priceX96 Price in Q96 format (price * 2^96) - function _getTWAPOrFallback(uint256 prevTimestamp, int24 tickLower, int24 tickUpper) - internal - returns (uint256 priceX96) - { - // Only attempt TWAP when there is a measurable elapsed interval - if (prevTimestamp > 0 && block.timestamp > prevTimestamp) { - uint32 elapsed = uint32(block.timestamp - prevTimestamp); - uint32[] memory secondsAgos = new uint32[](2); - secondsAgos[0] = elapsed; - secondsAgos[1] = 0; - try pool.observe(secondsAgos) returns (int56[] memory tickCumulatives, uint160[] memory) { - int24 twapTick = int24((tickCumulatives[1] - tickCumulatives[0]) / int56(uint56(elapsed))); - return _priceAtTick(token0isWeth ? -1 * twapTick : twapTick); - } catch { - // pool.observe() failed — emit event so monitoring can distinguish - // degraded oracle operation from normal bootstrap (elapsed == 0). - emit TWAPFallback(elapsed); - // Fall through to anchor midpoint - } - } - // Fallback: anchor midpoint (original single-snapshot behaviour) - int24 tick = tickLower + ((tickUpper - tickLower) / 2); - priceX96 = _priceAtTick(token0isWeth ? -1 * tick : tick); - } - /// @notice Allow contract to receive ETH receive() external payable { } diff --git a/onchain/src/VWAPTracker.sol b/onchain/src/VWAPTracker.sol index 9a93a92..1f420a0 100644 --- a/onchain/src/VWAPTracker.sol +++ b/onchain/src/VWAPTracker.sol @@ -7,16 +7,16 @@ import "@openzeppelin/utils/math/Math.sol"; * @title VWAPTracker * @notice Abstract contract for tracking Volume Weighted Average Price (VWAP) data * @dev Provides VWAP calculation and storage functionality that can be inherited by other contracts. - * Price inputs are sourced from the Uniswap V3 pool TWAP oracle (pool.observe()) rather than - * the anchor position midpoint, giving per-second granularity and manipulation resistance. - * The LiquidityManager feeds _recordVolumeAndPrice(twapPriceX96, ethFee) at each recenter. + * Price inputs are sourced from the current pool tick (pool.slot0()) at the time of each + * recenter, giving volume-weighted accuracy without per-swap gas overhead. + * The LiquidityManager feeds _recordVolumeAndPrice(currentPriceX96, ethFee) at each recenter. * * Key features: * - Volume-weighted average with data compression (max 1000x compression) * - Prevents dormant whale manipulation through historical price memory * - Stores price² (squared price) in X96 format for VWAP calculation * - Automatic overflow protection by compressing historic data when needed - * - Price source: pool TWAP oracle (time-weighted, per-second) not anchor midpoint snapshot + * - Price source: current pool tick snapshot at recenter time (not TWAP, not anchor midpoint) */ abstract contract VWAPTracker { using Math for uint256; diff --git a/onchain/test/VWAPFloorProtection.t.sol b/onchain/test/VWAPFloorProtection.t.sol index eb0d9bb..20d099f 100644 --- a/onchain/test/VWAPFloorProtection.t.sol +++ b/onchain/test/VWAPFloorProtection.t.sol @@ -19,7 +19,6 @@ import { LiquidityManager } from "../src/LiquidityManager.sol"; import { ThreePositionStrategy } from "../src/abstracts/ThreePositionStrategy.sol"; import { TestEnvironment } from "./helpers/TestBase.sol"; import { UniSwapHelper } from "./helpers/UniswapTestBase.sol"; -import "@aperture/uni-v3-lib/TickMath.sol"; contract VWAPFloorProtectionTest is UniSwapHelper { address constant RECENTER_CALLER = address(0x7777); @@ -213,81 +212,6 @@ contract VWAPFloorProtectionTest is UniSwapHelper { } } - // ========================================================================= - // TWAP: price reflects average across interval, not just last swap - // ========================================================================= - - /** - * @notice TWAP oracle gives an average price over the recenter interval, - * not merely the last-swap anchor midpoint. - * - * Sequence: - * 1. First recenter → positions set, no fees (lastRecenterTime = t0). - * 2. Warp 100 s → buy KRK: price moves UP, observation written at t0+100. - * 3. Warp 100 s → buy KRK again: price moves further UP, observation at t0+200. - * 4. Warp 100 s → bootstrap recenter (cumulativeVolume==0 → always records). - * elapsed = 300 s; pool.observe([300,0]) gives TWAP over the full interval. - * - * The TWAP covers 100 s at initial price + 100 s at P_mid + 100 s at P_high. - * The old anchor-midpoint approach would record only the initial anchor tick - * (placed during step 1 before any buys occurred). - * Therefore TWAP-based VWAP > initial-anchor-midpoint VWAP because it accounts - * for the price appreciation during the interval. - */ - function test_twapReflectsAveragePriceNotJustLastSwap() public { - // Note: in Foundry, `block.timestamp` within the test function always returns the - // value at test-function entry (1). vm.warp() takes effect for external calls, so - // we track elapsed time with a local variable. - - // Step 1: initial recenter — places positions at the pool's current price. - // No fees yet; lastRecenterTime is set to block.timestamp. - (, int24 initialTick,,,,,) = pool.slot0(); - assertFalse(token0isWeth, "test assumes token0isWeth=false"); - vm.prank(RECENTER_CALLER); - lm.recenter(); - - assertEq(lm.cumulativeVolume(), 0, "no fees before first buy"); - - uint256 t = 1; // track warped time independently - - // Step 2: advance 100 s, then buy (price rises; observation written for prior period). - t += 100; - vm.warp(t); // t = 101 - buyRaw(25 ether); - - // Step 3: advance another 100 s, buy again (price rises further). - t += 100; - vm.warp(t); // t = 201 - buyRaw(25 ether); - - // Capture the current (elevated) tick after two rounds of buying. - (, int24 elevatedTick,,,,,) = pool.slot0(); - // For !token0isWeth: buying KRK increases the tick (KRK price in WETH rises). - assertGt(elevatedTick, initialTick, "price must have risen after buys"); - - // Step 4: advance 100 s then do the bootstrap recenter. - // cumulativeVolume == 0, so shouldRecordVWAP = true regardless of direction. - // elapsed = 300 s → pool.observe([300, 0]) → TWAP tick ≈ avg of three 100-s periods. - t += 100; - vm.warp(t); // t = 301 - vm.prank(RECENTER_CALLER); - lm.recenter(); - - // TWAP over the 300-s window reflects higher prices than the initial anchor tick. - // TWAP tick ≈ (initialTick·100 + midTick·100 + elevatedTick·100) / 300 > initialTick. - // Correspondingly, priceX96(TWAP) > priceX96(initialTick). - // - // Compute a reference: the price at the initial anchor tick. - // For !token0isWeth, _priceAtTick uses the tick directly (no negation). - // We approximate it via TickMath: sqrtRatio² >> 96. - uint256 vwapAfter = lm.getVWAP(); - assertGt(vwapAfter, 0, "VWAP must be bootstrapped after fees from two large buys"); - - uint160 sqrtAtInitial = uint160(uint256(TickMath.getSqrtRatioAtTick(initialTick))); - uint256 initialPriceX96 = uint256(sqrtAtInitial) * uint256(sqrtAtInitial) >> 96; - assertGt(vwapAfter, initialPriceX96, "TWAP VWAP must exceed initial-anchor-midpoint price"); - } - // ========================================================================= // getLiquidityManager override for UniSwapHelper boundary helpers // ========================================================================= From f9f1d15d8db899276216ce18ed6bc340b429d46c Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 12 Mar 2026 09:06:08 +0000 Subject: [PATCH 2/2] ci: retrigger after infra failure