// 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 is far below the anti-overlap boundary // because VWAP price converts to a negative tick (token0isWeth sign flip) assertTrue(abundanceVwapTick < antiOverlapBoundary, "VWAP tick 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 _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) { (int24 tick, uint256 ethBal, uint256 supply,, int24 vwapTick) = abi.decode(logs[i].data, (int24, uint256, uint256, uint256, int24)); sawScarcity = true; eventVwapTick = vwapTick; console2.log(" EthScarcity:", label); console2.log(" tick:", tick); console2.log(" ethBal:", ethBal / 1e18); console2.log(" supply:", supply / 1e18); console2.log(" vwapTick:", vwapTick); } else if (logs[i].topics[0] == ABUNDANCE_SIG) { (int24 tick, uint256 ethBal, uint256 supply,, int24 vwapTick) = abi.decode(logs[i].data, (int24, uint256, uint256, uint256, int24)); sawAbundance = true; eventVwapTick = vwapTick; console2.log(" EthAbundance:", label); console2.log(" tick:", tick); console2.log(" ethBal:", ethBal / 1e18); console2.log(" supply:", supply / 1e18); console2.log(" vwapTick:", vwapTick); } } 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); } } }