harb/onchain/test/VWAPFloorProtection.t.sol
johba 63dafd82ca fix: shouldRecordVWAP else-branch fires incorrectly when lastRecenterTick==0 after bootstrap (#609)
Add `_hasRecenterTick` boolean guard to decouple bootstrap detection from
VWAP volume tracking. Before this fix, the bootstrap condition relied solely
on `cumulativeVolume == 0`, which made `lastRecenterTick==0` ambiguous:
it could mean "never recentered" or "previous recenter landed at tick 0
(price = 1.0 token ratio)".

The new guard ensures the direction comparison in the else-branch only
runs after a recenter has explicitly set `lastRecenterTick`, eliminating
the tick-0 ambiguity. Belt-and-suspenders: both `!_hasRecenterTick` and
`cumulativeVolume == 0` trigger bootstrap.

Tests added:
- test_hasRecenterTickGuardPreventsTick0Ambiguity
- test_vwapFrozenDuringBuyOnlyAfterSellRecenter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:28:08 +00:00

397 lines
17 KiB
Solidity

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
/**
* @title VWAPFloorProtectionTest
* @notice Regression tests for issue #543: VWAP must not inflate during buy-only recenter cycles.
*
* Root cause (pre-fix): shouldRecordVWAP was true on BUY events (ETH inflow). An adversary
* running N buy-recenter cycles continuously updated VWAP upward toward the current (inflated)
* price. When VWAP ≈ currentTick the mirrorTick formula placed the floor near the current
* price, crystallising IL when the adversary finally sold all KRK.
*
* Fix: shouldRecordVWAP is now true only on SELL events (price falling / ETH outflow).
* Buy-only cycles leave VWAP frozen at the historical bootstrap level, keeping the floor
* conservatively anchored to that baseline.
*/
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";
contract VWAPFloorProtectionTest is UniSwapHelper {
address constant RECENTER_CALLER = address(0x7777);
LiquidityManager lm;
TestEnvironment testEnv;
address feeDestination = makeAddr("fees");
// How much ETH to give the LM and the attacker
uint256 constant LM_ETH = 100 ether;
uint256 constant ATTACKER_ETH = 2000 ether;
function setUp() public {
testEnv = new TestEnvironment(feeDestination);
(,pool, weth, harberg, , lm, , token0isWeth) =
testEnv.setupEnvironment(false);
vm.deal(address(lm), LM_ETH);
// Fund the swap account used by UniSwapHelper
vm.deal(account, ATTACKER_ETH);
vm.prank(account);
weth.deposit{ value: ATTACKER_ETH / 2 }();
}
// =========================================================================
// Core regression: VWAP must not inflate during buy-only cycles (#543)
// =========================================================================
/**
* @notice VWAP stays at its bootstrap value throughout a buy-only attack sequence.
*
* Sequence:
* 1. First recenter → creates positions (no fees yet, VWAP not recorded).
* 2. Buy KRK (price rises) → second recenter → bootstraps VWAP (cumulativeVolume == 0).
* 3. Repeat buy + recenter several more times.
* 4. Assert getVWAP() is unchanged from the bootstrap recording.
*
* Before the fix this test would fail because every successful buy-direction recenter
* updated VWAP with the new (higher) anchor price, pulling VWAP toward currentTick.
*/
function test_vwapNotInflatedByBuyOnlyAttack() public {
// ---- step 1: initial recenter sets up positions ----
vm.prank(RECENTER_CALLER);
lm.recenter();
assertEq(lm.cumulativeVolume(), 0, "no fees collected yet: cumulativeVolume == 0");
// ---- step 2: first buy + recenter → bootstrap ----
buyRaw(25 ether); // push price up enough to satisfy amplitude check
vm.warp(block.timestamp + 301); // TWAP catches up to post-buy price; cooldown passes
vm.prank(RECENTER_CALLER);
lm.recenter(); // cumulativeVolume == 0 → shouldRecordVWAP = true (bootstrap path)
uint256 bootstrapVWAP = lm.getVWAP();
assertGt(bootstrapVWAP, 0, "VWAP must be recorded at bootstrap");
// ---- step 3: continued buy-only cycles ----
uint256 successfulBuyCycles;
uint256 ts = block.timestamp; // track explicitly to avoid Forge block.timestamp reset
for (uint256 i = 0; i < 10; i++) {
buyRaw(25 ether);
ts += 301; // TWAP catches up; cooldown passes
vm.warp(ts);
vm.prank(RECENTER_CALLER);
// Recenter may fail if amplitude isn't reached; that's fine.
try lm.recenter() {
successfulBuyCycles++;
} catch { }
}
// Ensure at least some cycles succeeded so the test is meaningful.
assertGt(successfulBuyCycles, 0, "at least one buy-recenter cycle must succeed");
// ---- step 4: VWAP must be unchanged ----
uint256 vwapAfterAttack = lm.getVWAP();
assertEq(
vwapAfterAttack,
bootstrapVWAP,
"VWAP must remain frozen at bootstrap value during buy-only cycles"
);
}
/**
* @notice The floor is anchored conservatively (far from the inflated current price)
* after a buy-only attack, making extraction unprofitable.
*
* After N buy cycles the current tick is far above the bootstrap. With VWAP frozen at
* bootstrap, mirrorTick ≈ vwapTick ≈ bootstrapTick — much further from currentTick than
* if VWAP had tracked upward. The floor therefore sits near the original distribution
* price, not the inflated price.
*/
function test_floorConservativeAfterBuyOnlyAttack() public {
vm.prank(RECENTER_CALLER);
lm.recenter();
// Bootstrap via first buy-recenter
buyRaw(25 ether);
vm.warp(block.timestamp + 301); // TWAP catches up; cooldown passes
vm.prank(RECENTER_CALLER);
lm.recenter();
// Run several buy cycles
uint256 ts = block.timestamp; // track explicitly to avoid Forge block.timestamp reset
for (uint256 i = 0; i < 6; i++) {
buyRaw(25 ether);
ts += 301; // TWAP catches up; cooldown passes
vm.warp(ts);
vm.prank(RECENTER_CALLER);
try lm.recenter() { } catch { }
}
// This test is written for token0isWeth=false (set in setUp via setupEnvironment(false,...)).
// For !token0isWeth: buying KRK pushes tick UP (WETH/KRK price rises), so floor must be
// BELOW (lower tick than) the inflated current tick.
assertFalse(token0isWeth, "test assumes token0isWeth=false; update gap logic if changed");
// Read floor and current tick
(, int24 currentTick,,,,,) = pool.slot0();
(, int24 floorTickLower, int24 floorTickUpper) =
lm.positions(ThreePositionStrategy.Stage.FLOOR);
int24 floorCenter = floorTickLower + (floorTickUpper - floorTickLower) / 2;
// The floor must be BELOW the current inflated tick by a substantial margin —
// at minimum the anchor spacing plus some additional buffer from VWAP anchoring.
// We assert that the gap is at least 400 ticks (two tick spacings).
int24 gap = currentTick - floorCenter;
assertGt(gap, 400, "floor must be substantially below the inflated current tick");
}
// =========================================================================
// Positive: VWAP bootstrap still works
// =========================================================================
/**
* @notice The very first fee event always records VWAP regardless of direction.
*
* cumulativeVolume == 0 triggers unconditional recording to avoid the
* vwapX96 == 0 fallback path. This test confirms that path is preserved.
*/
function test_vwapBootstrapsOnFirstFeeEvent() public {
vm.prank(RECENTER_CALLER);
lm.recenter();
assertEq(lm.cumulativeVolume(), 0, "no VWAP data before first fees");
buyRaw(25 ether);
vm.warp(block.timestamp + 301); // TWAP catches up to post-buy price; cooldown passes
vm.prank(RECENTER_CALLER);
lm.recenter();
assertGt(lm.cumulativeVolume(), 0, "bootstrap: cumulativeVolume must be positive");
assertGt(lm.getVWAP(), 0, "bootstrap: VWAP must be positive after first fees");
}
// =========================================================================
// Positive: VWAP updates when price falls (sell-side events)
// =========================================================================
/**
* @notice VWAP can still be updated when price falls between recenters.
*
* Sequence:
* 1. Buy large → recenter (bootstrap VWAP at high price).
* 2. Sell all KRK → price falls below bootstrap.
* 3. Recenter with price-fall direction → shouldRecordVWAP = true.
* 4. If ETH fees were collected (buys happened in the prior cycle), VWAP updates.
* We verify at minimum that the recenter succeeds without reverting — i.e.
* the sell-direction path doesn't break the system.
*/
function test_recenterSucceedsOnSellDirectionWithoutReverts() public {
// Bootstrap
vm.prank(RECENTER_CALLER);
lm.recenter();
uint256 ts = block.timestamp; // track explicitly to avoid Forge block.timestamp reset
buyRaw(25 ether);
ts += 301; // TWAP catches up to post-buy price; cooldown passes
vm.warp(ts);
vm.prank(RECENTER_CALLER);
lm.recenter();
// Sell back: harberg balance of `account` from the prior buy
uint256 harbBalance = harberg.balanceOf(account);
if (harbBalance > 0) {
sellRaw(harbBalance);
}
// Recenter with price now lower (sell direction) — must not revert
ts += 301; // TWAP catches up to post-sell price; cooldown passes
vm.warp(ts);
vm.prank(RECENTER_CALLER);
try lm.recenter() {
// success — sell-direction recenter works
} catch (bytes memory reason) {
// Amplitude not met is the only acceptable failure
assertEq(
keccak256(reason),
keccak256(abi.encodeWithSignature("Error(string)", "amplitude not reached.")),
"unexpected revert in sell-direction recenter"
);
}
}
// =========================================================================
// Deployment bootstrap: seed trade seeds VWAP before protocol goes live
// =========================================================================
/**
* @notice Verifies the deployment bootstrap sequence from DeployLocal.sol / DeployBase.sol.
*
* The deploy scripts execute:
* 1. First recenter — places bootstrap positions; no fees, cumulativeVolume stays 0.
* 2. Seed buy — small swap generates a non-zero WETH fee in the anchor position
* and moves the tick >400 (amplitude gate for the second recenter).
* 3. Second recenter — cumulativeVolume==0 path fires (shouldRecordVWAP=true) and
* ethFee>0, so _recordVolumeAndPrice is called.
*
* After step 3, cumulativeVolume>0 and the bootstrap path is permanently closed to
* external users. This test mirrors that sequence and asserts the invariant holds.
*/
function test_vwapBootstrappedBySeedTrade() public {
// Step 1: Initial recenter — places positions, no fees yet.
vm.prank(RECENTER_CALLER);
lm.recenter();
assertEq(lm.cumulativeVolume(), 0, "no fees before seed trade: cumulativeVolume must be 0");
// Step 2: Seed buy — enough to move the tick >400 (amplitude gate) and generate fee.
// 25 ether against a 100 ETH LM pool reliably satisfies the amplitude check
// (same amount used across other bootstrap tests in this file).
buyRaw(25 ether);
vm.warp(block.timestamp + 301); // TWAP catches up to post-buy price; cooldown passes
// Step 3: Second recenter — bootstrap path records VWAP.
vm.prank(RECENTER_CALLER);
lm.recenter();
assertGt(lm.cumulativeVolume(), 0, "seed trade must bootstrap cumulativeVolume to non-zero");
assertGt(lm.getVWAP(), 0, "seed trade must anchor VWAP to the real launch price");
}
// =========================================================================
// Issue #609: tick-0 edge case — _hasRecenterTick guard
// =========================================================================
/**
* @notice Verifies that the _hasRecenterTick guard prevents the direction
* comparison from running against the Solidity-default lastRecenterTick==0
* before any recenter has established a valid reference tick.
*
* Before fix #609, the bootstrap condition was `cumulativeVolume == 0`.
* Both conditions produce identical behaviour in practice (lastRecenterTick is
* always set before cumulativeVolume can become positive), but the explicit
* _hasRecenterTick flag makes the intent clear and prevents regressions if the
* volume-recording path is ever refactored.
*
* This test mirrors the deployment bootstrap sequence and then runs buy-only
* cycles, asserting that VWAP remains frozen throughout — confirming that the
* direction check (which now only fires after _hasRecenterTick is true) still
* correctly blocks VWAP inflation during buy-only attacks.
*/
function test_hasRecenterTickGuardPreventsTick0Ambiguity() public {
// Before any recenter: lastRecenterTick == 0 (Solidity default).
// The _hasRecenterTick flag must be false, so the bootstrap path fires
// on the first recenter regardless of the default-0 comparison.
assertEq(lm.lastRecenterTick(), 0, "lastRecenterTick defaults to 0 before first recenter");
// ---- step 1: first recenter — positions deployed, no fees ----
vm.prank(RECENTER_CALLER);
lm.recenter();
// lastRecenterTick is now set to the actual pool tick (far from 0).
// _hasRecenterTick is true after this call.
int24 tickAfterFirstRecenter = lm.lastRecenterTick();
assertTrue(tickAfterFirstRecenter != 0, "pool tick should not be exactly 0 after init");
// ---- step 2: buy + recenter → bootstrap VWAP ----
buyRaw(25 ether);
vm.warp(block.timestamp + 301);
vm.prank(RECENTER_CALLER);
lm.recenter();
uint256 bootstrapVWAP = lm.getVWAP();
assertGt(bootstrapVWAP, 0, "VWAP must be bootstrapped");
assertGt(lm.cumulativeVolume(), 0, "cumulativeVolume must be positive after bootstrap");
// ---- step 3: buy-only attack cycles ----
uint256 successfulBuyCycles;
uint256 ts = block.timestamp;
for (uint256 i = 0; i < 8; i++) {
buyRaw(25 ether);
ts += 301;
vm.warp(ts);
vm.prank(RECENTER_CALLER);
try lm.recenter() {
successfulBuyCycles++;
} catch { }
}
assertGt(successfulBuyCycles, 0, "at least one buy-recenter cycle must succeed");
// ---- step 4: VWAP must remain frozen ----
assertEq(
lm.getVWAP(),
bootstrapVWAP,
"issue #609: VWAP must stay frozen during buy-only cycles (direction check + _hasRecenterTick guard)"
);
}
/**
* @notice After a sell-then-buy sequence where lastRecenterTick could land
* near zero, buy-only cycles must still not inflate VWAP.
*
* This test exercises the direction comparison in the else-branch after
* multiple recenters have occurred, verifying correctness regardless of
* the specific lastRecenterTick value.
*/
function test_vwapFrozenDuringBuyOnlyAfterSellRecenter() public {
// Bootstrap
vm.prank(RECENTER_CALLER);
lm.recenter();
buyRaw(25 ether);
vm.warp(block.timestamp + 301);
vm.prank(RECENTER_CALLER);
lm.recenter();
uint256 vwapAfterBootstrap = lm.getVWAP();
assertGt(vwapAfterBootstrap, 0, "VWAP bootstrapped");
// Sell back to move price down — triggers VWAP recording (sell direction)
uint256 harbBal = harberg.balanceOf(account);
if (harbBal > 0) {
sellRaw(harbBal);
}
vm.warp(block.timestamp + 301);
vm.prank(RECENTER_CALLER);
try lm.recenter() { } catch { }
// Snapshot VWAP after sell-direction update
uint256 vwapAfterSell = lm.getVWAP();
// Now run buy-only cycles — VWAP must not increase
uint256 ts = block.timestamp;
uint256 successfulBuyCycles;
for (uint256 i = 0; i < 6; i++) {
buyRaw(25 ether);
ts += 301;
vm.warp(ts);
vm.prank(RECENTER_CALLER);
try lm.recenter() {
successfulBuyCycles++;
} catch { }
}
if (successfulBuyCycles > 0) {
assertEq(
lm.getVWAP(),
vwapAfterSell,
"VWAP must stay frozen during buy-only cycles after sell-direction recenter"
);
}
}
// =========================================================================
// getLiquidityManager override for UniSwapHelper boundary helpers
// =========================================================================
function getLiquidityManager() external view override returns (ThreePositionStrategy) {
return ThreePositionStrategy(address(lm));
}
}