Merge pull request 'fix: fix: Restore proper VWAP — gas-efficient volume-weighted pricing (revert TWAP) (#603)' (#605) from fix/issue-603 into master
This commit is contained in:
commit
4258045c8c
3 changed files with 12 additions and 129 deletions
|
|
@ -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 { }
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue