harb/onchain/test/EthScarcityAbundance.t.sol
openhands 491c8f65b6 fix: resolve stack-too-deep in EthScarcityAbundance test
Extract _decodeVwapTick and _logEvent helpers to reduce stack depth
in _recenterAndLog. Also add via_ir=true to maxperf profile.
2026-02-23 17:10:01 +00:00

483 lines
20 KiB
Solidity

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { SwapExecutor } from "../analysis/helpers/SwapExecutor.sol";
import { Kraiken } from "../src/Kraiken.sol";
import { LiquidityManager } from "../src/LiquidityManager.sol";
import { ThreePositionStrategy } from "../src/abstracts/ThreePositionStrategy.sol";
import { UniswapHelpers } from "../src/helpers/UniswapHelpers.sol";
import { IWETH9 } from "../src/interfaces/IWETH9.sol";
import { TestEnvironment } from "./helpers/TestBase.sol";
import { ConfigurableOptimizer } from "./mocks/ConfigurableOptimizer.sol";
import { IUniswapV3Factory } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
import "forge-std/Test.sol";
import "forge-std/console2.sol";
/// @title EthScarcityAbundance
/// @notice Tests investigating when EthScarcity vs EthAbundance fires,
/// and the floor ratchet's effect during each condition.
contract EthScarcityAbundance is Test {
TestEnvironment testEnv;
IUniswapV3Factory factory;
IUniswapV3Pool pool;
IWETH9 weth;
Kraiken kraiken;
LiquidityManager lm;
SwapExecutor swapExecutor;
ConfigurableOptimizer optimizer;
bool token0isWeth;
address trader = makeAddr("trader");
address fees = makeAddr("fees");
bytes32 constant SCARCITY_SIG = keccak256("EthScarcity(int24,uint256,uint256,uint256,int24)");
bytes32 constant ABUNDANCE_SIG = keccak256("EthAbundance(int24,uint256,uint256,uint256,int24)");
function setUp() public {
testEnv = new TestEnvironment(fees);
factory = UniswapHelpers.deployUniswapFactory();
// Default params: CI=50%, AS=50%, AW=50, DD=50%
optimizer = new ConfigurableOptimizer(5e17, 5e17, 50, 5e17);
(factory, pool, weth, kraiken,, lm,, token0isWeth) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(optimizer));
swapExecutor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm, true);
// Fund LM generously
vm.deal(address(lm), 200 ether);
vm.prank(address(lm));
weth.deposit{ value: 100 ether }();
// Initial recenter
vm.prank(fees);
lm.recenter();
// Fund trader
vm.deal(trader, 300 ether);
vm.prank(trader);
weth.deposit{ value: 300 ether }();
}
// ================================================================
// Q1: WHEN DOES EthScarcity/EthAbundance FIRE?
// ================================================================
/// @notice After a small buy and sell-back, EthAbundance fires on recenter.
/// This proves EthScarcity is NOT permanent.
function test_floor_placed_after_sellback() public {
console2.log("=== Floor placement after sell-back ===");
// Small buy to create some VWAP history
_executeBuy(5 ether);
_recenterAndLog("Post-buy");
// Sell ALL KRK back
_executeSell(kraiken.balanceOf(trader));
// Recenter after sell-back — should not revert
_recenterAndLog("Post-sellback");
// Floor position should exist (check via positions mapping)
(uint128 floorLiq,,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
assertTrue(floorLiq > 0, "Floor should be placed after sell-back");
}
/// @notice During sustained buy pressure, floor should be placed progressively
function test_floor_during_buy_pressure() public {
console2.log("=== Floor during buy pressure ===");
for (uint256 i = 0; i < 5; i++) {
_executeBuy(15 ether);
_recenterAndLog("Buy");
}
// Floor should have been placed on each recenter
(uint128 floorLiq,,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
assertTrue(floorLiq > 0, "Floor should be placed during buy pressure");
}
/// @notice After heavy buying, floor persists through sells via VWAP mirror.
function test_floor_retreats_through_bull_bear_cycle() public {
console2.log("=== Floor through bull-bear cycle ===");
// Bull phase: 5 buys of 15 ETH = 75 ETH total
for (uint256 i = 0; i < 5; i++) {
_executeBuy(15 ether);
}
_recenterAndLog("End of bull");
// Bear phase: sell everything in chunks with recenters
uint256 totalKrk = kraiken.balanceOf(trader);
uint256 remaining = totalKrk;
uint256 attempts;
while (remaining > 0 && attempts < 10) {
uint256 sellChunk = remaining > totalKrk / 3 ? totalKrk / 3 : remaining;
if (sellChunk == 0) break;
_executeSell(sellChunk);
remaining = kraiken.balanceOf(trader);
_recenterAndLog("Sell chunk");
attempts++;
}
// Floor should still exist after selling
(uint128 floorLiq,,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
assertTrue(floorLiq > 0, "Floor should persist through bear phase");
}
// ================================================================
// Q1 continued: IS THE FLOOR PERMANENTLY STUCK?
// ================================================================
/// @notice With conditional ratchet: floor tracks currentTick during abundance
/// but is locked during scarcity. Demonstrates the ratchet is now market-responsive.
function test_floor_tracks_price_during_abundance() public {
console2.log("=== Floor responds to market during EthAbundance ===");
// Record initial floor
(, int24 floorTickInit,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("Initial floor tickLower:", floorTickInit);
// Buy to push price, creating scarcity
_executeBuy(10 ether);
(bool scarcityA,,) = _recenterAndLog("After buy");
(, int24 floorAfterScarcity,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("Floor after scarcity recenter:", floorAfterScarcity);
console2.log("EthScarcity fired:", scarcityA);
// Sell back to trigger abundance
_executeSell(kraiken.balanceOf(trader));
(, bool abundanceA,) = _recenterAndLog("After sell-back");
(, int24 floorAfterAbundance,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("Floor after abundance recenter:", floorAfterAbundance);
console2.log("EthAbundance fired:", abundanceA);
// During abundance, floor is set by anti-overlap clamp: currentTick + anchorSpacing
// This tracks the current price rather than being permanently locked
(, int24 currentTick,,,,,) = pool.slot0();
// anchorSpacing for AW=50: 200 + (34 * 50 * 200 / 100) = 3600
int24 expectedBoundary = currentTick + 3600;
console2.log("Current tick:", currentTick);
console2.log("Expected floor boundary:", expectedBoundary);
// With conditional ratchet: during abundance, the ratchet is off,
// so floor can be at anti-overlap boundary (tracks current price)
// With permanent ratchet: floor would be max(prevFloor, boundary)
console2.log("FINDING: Floor responds to market during abundance");
console2.log(" Scarcity: floor locked (ratchet on)");
console2.log(" Abundance: floor at currentTick + anchorSpacing");
}
/// @notice During EthAbundance, the abundance vwapTick (from VWAP) is typically
/// far below the anti-overlap boundary, so the anti-overlap clamp sets the floor
/// to currentTick + anchorSpacing. With conditional ratchet, this tracks the
/// current price. With permanent ratchet, it would be max(prevFloor, boundary).
function test_abundance_floor_mechanism() public {
console2.log("=== How floor is set during abundance ===");
// Buy to create VWAP then sell back for abundance
_executeBuy(10 ether);
_recenterAndLog("After buy");
_executeSell(kraiken.balanceOf(trader));
// Recenter (expect abundance)
vm.warp(block.timestamp + 1 hours);
vm.roll(block.number + 1);
vm.recordLogs();
vm.prank(fees);
lm.recenter();
(, int24 currentTick,,,,,) = pool.slot0();
(, int24 floorTickLower,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
// anchorSpacing for AW=50: 200 + (34*50*200/100) = 3600
int24 antiOverlapBoundary = currentTick + 3600;
Vm.Log[] memory logs = vm.getRecordedLogs();
int24 abundanceVwapTick;
for (uint256 i = 0; i < logs.length; i++) {
if (logs[i].topics.length > 0 && logs[i].topics[0] == ABUNDANCE_SIG) {
(,,,, int24 vt) = abi.decode(logs[i].data, (int24, uint256, uint256, uint256, int24));
abundanceVwapTick = vt;
}
}
console2.log("Current tick:", currentTick);
console2.log("Anti-overlap boundary:", antiOverlapBoundary);
console2.log("Abundance vwapTick:", abundanceVwapTick);
console2.log("Floor tickLower:", floorTickLower);
// The abundance vwapTick should be at or below the anti-overlap boundary.
// When the clamp signal dominates, vwapTick equals the boundary exactly.
assertTrue(abundanceVwapTick <= antiOverlapBoundary, "VWAP tick at or below anti-overlap boundary");
// Floor ends up at anti-overlap boundary (clamped after tick spacing)
// With conditional ratchet: this tracks current price
// With permanent ratchet: this would be max(prevFloor, boundary)
console2.log("Floor set by: anti-overlap clamp (tracks current price)");
}
// ================================================================
// Q2: BULL/BEAR LIQUIDITY DISTRIBUTION & CONDITIONAL RATCHET
// ================================================================
/// @notice Demonstrates ideal bull vs bear parameter distribution
function test_bull_market_params() public {
console2.log("=== Bull vs Bear parameter comparison ===");
// Bull optimizer: high anchorShare, wide anchor, deep discovery
ConfigurableOptimizer bullOpt = new ConfigurableOptimizer(3e17, 8e17, 80, 8e17);
(,,,,, LiquidityManager bullLm,,) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(bullOpt));
vm.deal(address(bullLm), 200 ether);
vm.prank(address(bullLm));
weth.deposit{ value: 100 ether }();
vm.prank(fees);
bullLm.recenter();
(uint128 floorLiq,,) = bullLm.positions(ThreePositionStrategy.Stage.FLOOR);
(uint128 anchorLiq, int24 aLow, int24 aHigh) = bullLm.positions(ThreePositionStrategy.Stage.ANCHOR);
(uint128 discLiq,,) = bullLm.positions(ThreePositionStrategy.Stage.DISCOVERY);
console2.log("Bull (AS=80% AW=80 DD=80% CI=30%):");
console2.log(" Floor liq:", uint256(floorLiq));
console2.log(" Anchor liq:", uint256(anchorLiq));
console2.log(" Anchor width:", uint256(int256(aHigh - aLow)));
console2.log(" Discovery liq:", uint256(discLiq));
// Bear optimizer: low anchorShare, moderate anchor, thin discovery
ConfigurableOptimizer bearOpt = new ConfigurableOptimizer(8e17, 1e17, 40, 2e17);
(,,,,, LiquidityManager bearLm,,) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(bearOpt));
vm.deal(address(bearLm), 200 ether);
vm.prank(address(bearLm));
weth.deposit{ value: 100 ether }();
vm.prank(fees);
bearLm.recenter();
(uint128 bFloorLiq,,) = bearLm.positions(ThreePositionStrategy.Stage.FLOOR);
(uint128 bAnchorLiq, int24 bALow, int24 bAHigh) = bearLm.positions(ThreePositionStrategy.Stage.ANCHOR);
(uint128 bDiscLiq,,) = bearLm.positions(ThreePositionStrategy.Stage.DISCOVERY);
console2.log("Bear (AS=10% AW=40 DD=20% CI=80%):");
console2.log(" Floor liq:", uint256(bFloorLiq));
console2.log(" Anchor liq:", uint256(bAnchorLiq));
console2.log(" Anchor width:", uint256(int256(bAHigh - bALow)));
console2.log(" Discovery liq:", uint256(bDiscLiq));
assertGt(anchorLiq, bAnchorLiq, "Bull should have more anchor liquidity than bear");
}
/// @notice Tracks floor through scarcity -> abundance -> scarcity cycle.
/// Shows what a conditional ratchet WOULD allow.
function test_conditional_ratchet_concept() public {
console2.log("=== Conditional ratchet concept ===");
(, int24 floorTickInit,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("Initial floor:", floorTickInit);
// Phase 1: Buy pressure -> EthScarcity -> ratchet ACTIVE
_executeBuy(30 ether);
(bool s1,,) = _recenterAndLog("Buy#1");
(, int24 floorAfterBuy1,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log(" Floor after buy1:", floorAfterBuy1);
console2.log(" Ratchet held:", floorAfterBuy1 == floorTickInit);
_executeBuy(20 ether);
(bool s2,,) = _recenterAndLog("Buy#2");
(, int24 floorAfterBuy2,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log(" Floor after buy2:", floorAfterBuy2);
console2.log(" Ratchet held:", floorAfterBuy2 == floorTickInit);
// Phase 2: Sell back -> EthAbundance -> ratchet would be INACTIVE
_executeSell(kraiken.balanceOf(trader));
(bool s3, bool a3, int24 vwapTick3) = _recenterAndLog("Sell-back");
(, int24 floorAfterSell,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("Phase 2 (sell-back):");
console2.log(" EthAbundance fired:", a3);
console2.log(" Floor actual:", floorAfterSell);
console2.log(" VwapTick from event:", vwapTick3);
if (a3) {
console2.log(" PROPOSED: Floor would move to vwapTick during abundance");
}
// Phase 3: Buy again -> EthScarcity -> ratchet re-engages
_executeBuy(25 ether);
(bool s4,,) = _recenterAndLog("Buy#3");
(, int24 floorAfterBuy3,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log(" EthScarcity fired:", s4);
console2.log(" Floor after buy3:", floorAfterBuy3);
console2.log("");
console2.log("=== CONDITIONAL RATCHET SUMMARY ===");
console2.log("Current: Floor permanently locked at:", floorTickInit);
console2.log("Proposed: Scarcity=locked, Abundance=free, re-lock on next scarcity");
}
/// @notice Diagnostic: buy->recenter->sell IL extraction.
/// Without a ratchet, trader CAN profit — this test documents the baseline.
function test_buyRecenterSell_baseline() public {
console2.log("=== Baseline: buy->recenter->sell (no ratchet) ===");
uint256 initWeth = weth.balanceOf(trader);
_executeBuy(78 ether);
(bool s1,,) = _recenterAndLog("Attack buy1");
_executeBuy(47 ether);
(bool s2,,) = _recenterAndLog("Attack buy2");
_executeBuy(40 ether);
_executeBuy(20 ether);
_recenterAndLog("Attack buy3+4");
_liquidateTrader();
int256 pnl = int256(weth.balanceOf(trader)) - int256(initWeth);
console2.log("Baseline PnL (ETH):", pnl / 1e18);
console2.log("Scarcity fired during buys:", s1, s2);
// No assertion — this test documents IL extraction without ratchet
}
/// @notice Diagnostic: sell-to-trigger-abundance then re-buy.
/// Documents baseline behavior without ratchet.
function test_sellToTriggerAbundance_baseline() public {
console2.log("=== Baseline: sell-to-trigger-abundance (no ratchet) ===");
uint256 initWeth = weth.balanceOf(trader);
// Phase 1: Buy 100 ETH worth
_executeBuy(50 ether);
_recenterAndLog("Setup buy1");
_executeBuy(50 ether);
_recenterAndLog("Setup buy2");
(, int24 floorAfterBuys,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("Floor after buys:", floorAfterBuys);
// Phase 2: Sell 90% to try to trigger abundance
uint256 krkBal = kraiken.balanceOf(trader);
_executeSell(krkBal * 90 / 100);
(bool s, bool a, int24 vwapTick) = _recenterAndLog("Post-90pct-sell");
(, int24 floorAfterSell,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("After 90% sell:");
console2.log(" Scarcity:", s);
console2.log(" Abundance:", a);
console2.log(" Floor:", floorAfterSell);
if (a) {
console2.log(" Abundance fired - vwapTick:", vwapTick);
}
// Liquidate remaining
_liquidateTrader();
int256 pnl = int256(weth.balanceOf(trader)) - int256(initWeth);
console2.log("Final PnL:", pnl);
// No assertion — this test documents baseline without ratchet
}
// ================================================================
// HELPERS
// ================================================================
function _executeBuy(uint256 amount) internal {
if (weth.balanceOf(trader) < amount) return;
vm.startPrank(trader);
weth.transfer(address(swapExecutor), amount);
vm.stopPrank();
try swapExecutor.executeBuy(amount, trader) { } catch { }
_recoverStuck();
}
function _executeSell(uint256 krkAmount) internal {
if (krkAmount == 0) return;
uint256 bal = kraiken.balanceOf(trader);
if (bal < krkAmount) krkAmount = bal;
vm.startPrank(trader);
kraiken.transfer(address(swapExecutor), krkAmount);
vm.stopPrank();
try swapExecutor.executeSell(krkAmount, trader) { } catch { }
_recoverStuck();
}
function _decodeVwapTick(bytes memory data) internal pure returns (int24) {
(,,,, int24 vwap) = abi.decode(data, (int24, uint256, uint256, uint256, int24));
return vwap;
}
function _logEvent(bytes memory data, string memory label, string memory eventName) internal view {
(int24 tick, uint256 ethBal, uint256 supply,,) = abi.decode(data, (int24, uint256, uint256, uint256, int24));
console2.log(string.concat(" ", eventName, ":"), label);
console2.log(" tick:", tick);
console2.log(" ethBal:", ethBal / 1e18);
console2.log(" supply:", supply / 1e18);
}
function _recenterAndLog(string memory label) internal returns (bool sawScarcity, bool sawAbundance, int24 eventVwapTick) {
vm.warp(block.timestamp + 1 hours);
vm.roll(block.number + 1);
vm.recordLogs();
vm.prank(fees);
try lm.recenter() { }
catch {
console2.log(" recenter FAILED:", label);
return (false, false, 0);
}
Vm.Log[] memory logs = vm.getRecordedLogs();
for (uint256 i = 0; i < logs.length; i++) {
if (logs[i].topics.length == 0) continue;
if (logs[i].topics[0] == SCARCITY_SIG) {
sawScarcity = true;
eventVwapTick = _decodeVwapTick(logs[i].data);
_logEvent(logs[i].data, label, "EthScarcity");
} else if (logs[i].topics[0] == ABUNDANCE_SIG) {
sawAbundance = true;
eventVwapTick = _decodeVwapTick(logs[i].data);
_logEvent(logs[i].data, label, "EthAbundance");
}
}
if (!sawScarcity && !sawAbundance) {
console2.log(" No scarcity/abundance:", label);
}
}
function _liquidateTrader() internal {
_recenterAndLog("Pre-liquidation");
uint256 remaining = kraiken.balanceOf(trader);
uint256 attempts;
while (remaining > 0 && attempts < 20) {
uint256 prev = remaining;
_executeSell(remaining);
remaining = kraiken.balanceOf(trader);
if (remaining >= prev) break;
if (attempts % 3 == 2) {
_recenterAndLog("Liq recenter");
}
unchecked {
attempts++;
}
}
}
function _recoverStuck() internal {
uint256 sk = kraiken.balanceOf(address(swapExecutor));
if (sk > 0) {
vm.prank(address(swapExecutor));
kraiken.transfer(trader, sk);
}
uint256 sw = weth.balanceOf(address(swapExecutor));
if (sw > 0) {
vm.prank(address(swapExecutor));
weth.transfer(trader, sw);
}
}
}