// 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 FuzzingAnalyzerBugs /// @notice Tests verifying that the fuzzing analyzer measurements are correct contract FuzzingAnalyzerBugs 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"); function setUp() public { testEnv = new TestEnvironment(fees); factory = UniswapHelpers.deployUniswapFactory(); // Bear market params: CI=0.8e18, AS=0.1e18, AW=40, DD=0.2e18 optimizer = new ConfigurableOptimizer(8e17, 1e17, 40, 2e17); (factory, pool, weth, kraiken,, lm,, token0isWeth) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(optimizer)); swapExecutor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm, true); // Fund LM vm.deal(address(lm), 200 ether); vm.prank(address(lm)); weth.deposit{ value: 100 ether }(); // Initial recenter vm.prank(fees); lm.recenter(); } /// @notice Simple round trip: buy → recenter → sell. Trader should NOT profit. function test_simpleRoundTrip_bearMarket() public { // Fund trader vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); uint256 traderInitEth = weth.balanceOf(trader); console2.log("Trader initial WETH:", traderInitEth / 1e18); // Buy 30 ETH of KRAIKEN vm.startPrank(trader); weth.transfer(address(swapExecutor), 30 ether); vm.stopPrank(); uint256 bought = swapExecutor.executeBuy(30 ether, trader); console2.log("WETH consumed by buy:", bought / 1e18); // Recover any stuck WETH uint256 stuckWeth = weth.balanceOf(address(swapExecutor)); if (stuckWeth > 0) { vm.prank(address(swapExecutor)); weth.transfer(trader, stuckWeth); } uint256 krkBalance = kraiken.balanceOf(trader); console2.log("KRAIKEN received:", krkBalance / 1e18); // Recenter vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); lm.recenter(); // Sell all KRAIKEN vm.startPrank(trader); kraiken.transfer(address(swapExecutor), krkBalance); vm.stopPrank(); uint256 sold = swapExecutor.executeSell(krkBalance, trader); console2.log("KRAIKEN consumed by sell:", sold / 1e18); // Recover stuck tokens uint256 stuckKrk = kraiken.balanceOf(address(swapExecutor)); if (stuckKrk > 0) { vm.prank(address(swapExecutor)); kraiken.transfer(trader, stuckKrk); } stuckWeth = weth.balanceOf(address(swapExecutor)); if (stuckWeth > 0) { vm.prank(address(swapExecutor)); weth.transfer(trader, stuckWeth); } uint256 traderFinalEth = weth.balanceOf(trader); uint256 traderFinalKrk = kraiken.balanceOf(trader); int256 pnl = int256(traderFinalEth) - int256(traderInitEth); console2.log("Trader final WETH:", traderFinalEth / 1e18); console2.log("Trader unsold KRAIKEN:", traderFinalKrk / 1e18); console2.log("Trader PnL (wei):", pnl); // In a simple round trip, trader should lose (slippage + fees) assertLe(pnl, 0, "Trader should not profit from simple round trip"); } /// @notice Demonstrates the PnL leakage bug: leftover KRAIKEN inflates next run's profit function test_pnlLeakage_withoutCleanup() public { // === RUN 0: Buy a lot, partial liquidation === vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); // Buy aggressively vm.startPrank(trader); weth.transfer(address(swapExecutor), 80 ether); vm.stopPrank(); swapExecutor.executeBuy(80 ether, trader); // Recover stuck WETH from partial buy uint256 stuckW = weth.balanceOf(address(swapExecutor)); if (stuckW > 0) { vm.prank(address(swapExecutor)); weth.transfer(trader, stuckW); } uint256 krkAfterBuy = kraiken.balanceOf(trader); console2.log("Run 0 - KRAIKEN after buy:", krkAfterBuy / 1e18); // Recenter vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } // Partial liquidation: sell half uint256 halfKrk = krkAfterBuy / 2; if (halfKrk > 0) { vm.startPrank(trader); kraiken.transfer(address(swapExecutor), halfKrk); vm.stopPrank(); swapExecutor.executeSell(halfKrk, trader); } // Recover stuck tokens stuckW = weth.balanceOf(address(swapExecutor)); if (stuckW > 0) { vm.prank(address(swapExecutor)); weth.transfer(trader, stuckW); } uint256 stuckK = kraiken.balanceOf(address(swapExecutor)); if (stuckK > 0) { vm.prank(address(swapExecutor)); kraiken.transfer(trader, stuckK); } uint256 run0FinalWeth = weth.balanceOf(trader); uint256 run0LeftoverKrk = kraiken.balanceOf(trader); int256 run0Pnl = int256(run0FinalWeth) - 200 ether; console2.log("Run 0 - Final WETH:", run0FinalWeth / 1e18); console2.log("Run 0 - Leftover KRK:", run0LeftoverKrk / 1e18); console2.log("Run 0 - PnL (WETH only):", run0Pnl); // === RUN 1 (WITHOUT CLEANUP - the old buggy behavior) === // Don't clean up! Just add more WETH on top. vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); uint256 run1InitEth = weth.balanceOf(trader); // Includes old balance! console2.log("Run 1 - Init WETH (includes old):", run1InitEth / 1e18); console2.log("Run 1 - Carried KRK:", kraiken.balanceOf(trader) / 1e18); // Recenter to build new liquidity vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } // Now sell the leftover KRAIKEN from run 0 uint256 carryKrk = kraiken.balanceOf(trader); if (carryKrk > 0) { vm.startPrank(trader); kraiken.transfer(address(swapExecutor), carryKrk); vm.stopPrank(); swapExecutor.executeSell(carryKrk, trader); stuckW = weth.balanceOf(address(swapExecutor)); if (stuckW > 0) { vm.prank(address(swapExecutor)); weth.transfer(trader, stuckW); } stuckK = kraiken.balanceOf(address(swapExecutor)); if (stuckK > 0) { vm.prank(address(swapExecutor)); kraiken.transfer(trader, stuckK); } } uint256 run1FinalEth = weth.balanceOf(trader); int256 run1Pnl = int256(run1FinalEth) - int256(run1InitEth); console2.log("Run 1 - Final WETH:", run1FinalEth / 1e18); console2.log("Run 1 - PnL (without cleanup):", run1Pnl); // The key insight: run1Pnl can be POSITIVE because it includes WETH from // selling run 0's leftover KRAIKEN, which was never accounted for. // This is the false positive that made 251/252 combos appear "unsafe". if (run1Pnl > 0) { console2.log("BUG CONFIRMED: Run 1 shows false profit from leftover KRAIKEN"); } // True combined PnL across both runs should be negative int256 totalPnl = run0Pnl + run1Pnl; console2.log("Total PnL across both runs:", totalPnl); } /// @notice 10 cycles of buy→recenter→sell — does IL accumulation create profit? function test_multiCycle_bearMarket() public { vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); uint256 traderInitEth = weth.balanceOf(trader); console2.log("=== Multi-cycle test (10 cycles) ==="); console2.log("Trader initial WETH:", traderInitEth / 1e18); for (uint256 i = 0; i < 10; i++) { uint256 wethBefore = weth.balanceOf(trader); uint256 buyAmount = 20 ether; if (wethBefore < buyAmount) { console2.log("Cycle", i, "- skipped (insufficient WETH)"); break; } // Buy vm.startPrank(trader); weth.transfer(address(swapExecutor), buyAmount); vm.stopPrank(); swapExecutor.executeBuy(buyAmount, trader); // Recover stuck WETH from partial buy uint256 sw = weth.balanceOf(address(swapExecutor)); if (sw > 0) { vm.prank(address(swapExecutor)); weth.transfer(trader, sw); } uint256 krkBal = kraiken.balanceOf(trader); // Recenter vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } // Sell all KRAIKEN if (krkBal > 0) { vm.startPrank(trader); kraiken.transfer(address(swapExecutor), krkBal); vm.stopPrank(); try swapExecutor.executeSell(krkBal, trader) { } catch { } } // Recover stuck tokens uint256 sk = kraiken.balanceOf(address(swapExecutor)); if (sk > 0) { vm.prank(address(swapExecutor)); kraiken.transfer(trader, sk); } sw = weth.balanceOf(address(swapExecutor)); if (sw > 0) { vm.prank(address(swapExecutor)); weth.transfer(trader, sw); } uint256 wethAfter = weth.balanceOf(trader); uint256 krkLeft = kraiken.balanceOf(trader); int256 cyclePnl = int256(wethAfter) - int256(wethBefore); console2.log("Cycle", i); console2.log(" PnL (wei):", cyclePnl); console2.log(" KRK left:", krkLeft / 1e18); } // Final cleanup: sell any remaining KRAIKEN uint256 finalKrk = kraiken.balanceOf(trader); if (finalKrk > 0) { vm.startPrank(trader); kraiken.transfer(address(swapExecutor), finalKrk); vm.stopPrank(); try swapExecutor.executeSell(finalKrk, trader) { } catch { } 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); } } uint256 traderFinalEth = weth.balanceOf(trader); uint256 traderFinalKrk = kraiken.balanceOf(trader); int256 totalPnl = int256(traderFinalEth) - int256(traderInitEth); console2.log("=== Final ==="); console2.log("Final WETH:", traderFinalEth / 1e18); console2.log("Unsold KRK:", traderFinalKrk / 1e18); console2.log("Total PnL:", totalPnl); // Multi-cycle should also show loss with bear params assertLe(totalPnl, 0, "Trader should not profit even with multiple cycles"); } /// @notice Same as sweep-like test but with CAPPED swaps (production behavior) function test_sweepLikeRun_bearMarket_capped() public { // Redeploy SwapExecutor with capped=false (uses LiquidityBoundaryHelper) swapExecutor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm, false); vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); uint256 traderInitEth = weth.balanceOf(trader); console2.log("=== Sweep-like run CAPPED (30 trades, 80% buy bias) ==="); for (uint256 i = 0; i < 30; i++) { if (uint256(keccak256(abi.encodePacked(uint256(0), i, "recenter"))) % 3 == 0) { vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } } uint256 rand = uint256(keccak256(abi.encodePacked(uint256(0), i))) % 100; if (rand < 80) { uint256 amount = (20 * 1 ether) + (uint256(keccak256(abi.encodePacked(uint256(0), i, "buy"))) % (60 * 1 ether)); if (weth.balanceOf(trader) < amount) continue; vm.startPrank(trader); weth.transfer(address(swapExecutor), amount); try swapExecutor.executeBuy(amount, trader) { } catch { } vm.stopPrank(); _recoverStuck(); } else { uint256 krkBal = kraiken.balanceOf(trader); if (krkBal == 0) continue; uint256 pct = 20 + (uint256(keccak256(abi.encodePacked(uint256(0), i, "sell"))) % 60); uint256 amount = krkBal * pct / 100; if (amount == 0) continue; vm.startPrank(trader); kraiken.transfer(address(swapExecutor), amount); try swapExecutor.executeSell(amount, trader) { } catch { } vm.stopPrank(); _recoverStuck(); } } vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } // Liquidate uint256 remaining = kraiken.balanceOf(trader); uint256 attempts; while (remaining > 0 && attempts < 20) { uint256 prev = remaining; vm.startPrank(trader); kraiken.transfer(address(swapExecutor), remaining); vm.stopPrank(); try swapExecutor.executeSell(remaining, trader) { } catch { } _recoverStuck(); if (attempts % 3 == 2) { vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } } remaining = kraiken.balanceOf(trader); if (remaining >= prev) break; unchecked { attempts++; } } _recoverStuck(); uint256 traderFinalEth = weth.balanceOf(trader); int256 pnl = int256(traderFinalEth) - int256(traderInitEth); console2.log("Final WETH:", traderFinalEth / 1e18); console2.log("Unsold KRK:", kraiken.balanceOf(trader) / 1e18); console2.log("PnL (wei):", pnl); // With capped swaps + bear params, trader should NOT profit assertLe(pnl, 0, "Trader should not profit with capped swaps + bear params"); } /// @notice Conservation check: total system WETH is consistent function test_systemConservation() public { vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); // Track WETH total supply (all holders) uint256 wethSupplyBefore = weth.balanceOf(trader) + weth.balanceOf(address(lm)) + weth.balanceOf(address(pool)) + weth.balanceOf(address(swapExecutor)) + weth.balanceOf(address(0xdead)); uint256 lmEthBefore = address(lm).balance; console2.log("WETH supply before:", wethSupplyBefore / 1e18); console2.log("LM native ETH:", lmEthBefore / 1e18); // Do 10 trades with buys and recenters for (uint256 i = 0; i < 10; i++) { if (i % 3 == 0) { vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } } uint256 amount = 20 ether; if (weth.balanceOf(trader) >= amount) { vm.startPrank(trader); weth.transfer(address(swapExecutor), amount); try swapExecutor.executeBuy(amount, trader) { } catch { } vm.stopPrank(); } } vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } uint256 wethSupplyAfter = weth.balanceOf(trader) + weth.balanceOf(address(lm)) + weth.balanceOf(address(pool)) + weth.balanceOf(address(swapExecutor)) + weth.balanceOf(address(0xdead)) + weth.balanceOf(fees); uint256 lmEthAfter = address(lm).balance; console2.log("WETH supply after:", wethSupplyAfter / 1e18); console2.log("LM native ETH after:", lmEthAfter / 1e18); // Total WETH should equal initial + any ETH that was converted uint256 totalEthConverted = lmEthBefore - lmEthAfter; console2.log("ETH converted to WETH:", totalEthConverted / 1e18); console2.log("Expected WETH supply:", (wethSupplyBefore + totalEthConverted) / 1e18); assertEq(wethSupplyAfter, wethSupplyBefore + totalEthConverted, "WETH total supply should be conserved (accounting for ETH->WETH conversion)"); } /// @notice Mimics sweep run: 30 trades with 80% buy bias, bear params function test_sweepLikeRun_bearMarket() public { vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); uint256 traderInitEth = weth.balanceOf(trader); console2.log("=== Sweep-like run (30 trades, 80% buy bias) ==="); uint256 recenters; uint256 buysFailed; uint256 sellsFailed; for (uint256 i = 0; i < 30; i++) { // 1/3 chance of recenter if (uint256(keccak256(abi.encodePacked(uint256(0), i, "recenter"))) % 3 == 0) { vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { recenters++; } catch { } } uint256 rand = uint256(keccak256(abi.encodePacked(uint256(0), i))) % 100; if (rand < 80) { // Buy uint256 amount = (20 * 1 ether) + (uint256(keccak256(abi.encodePacked(uint256(0), i, "buy"))) % (60 * 1 ether)); if (weth.balanceOf(trader) < amount) { buysFailed++; continue; } vm.startPrank(trader); weth.transfer(address(swapExecutor), amount); try swapExecutor.executeBuy(amount, trader) returns (uint256 a) { vm.stopPrank(); if (a == 0) buysFailed++; } catch { vm.stopPrank(); buysFailed++; } } else { // Sell uint256 krkBal = kraiken.balanceOf(trader); if (krkBal == 0) { sellsFailed++; continue; } uint256 pct = 20 + (uint256(keccak256(abi.encodePacked(uint256(0), i, "sell"))) % 60); uint256 amount = krkBal * pct / 100; if (amount == 0) { sellsFailed++; continue; } vm.startPrank(trader); kraiken.transfer(address(swapExecutor), amount); try swapExecutor.executeSell(amount, trader) returns (uint256 a) { vm.stopPrank(); if (a == 0) sellsFailed++; } catch { vm.stopPrank(); sellsFailed++; } } } // Final recenter vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { recenters++; } catch { } console2.log("Trades done. Recenters:", recenters); console2.log("Buys failed:", buysFailed); console2.log("Sells failed:", sellsFailed); // Liquidate holdings (same as sweep) uint256 remaining = kraiken.balanceOf(trader); uint256 attempts; while (remaining > 0 && attempts < 20) { uint256 prev = remaining; uint256 wBefore = weth.balanceOf(trader); vm.startPrank(trader); kraiken.transfer(address(swapExecutor), remaining); vm.stopPrank(); try swapExecutor.executeSell(remaining, trader) { if (weth.balanceOf(trader) <= wBefore) { _recoverStuck(); break; } } catch { _recoverStuck(); break; } _recoverStuck(); if (attempts % 3 == 2) { vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } } remaining = kraiken.balanceOf(trader); if (remaining >= prev) break; unchecked { attempts++; } } // Also recover any stuck WETH from buys _recoverStuck(); uint256 traderFinalEth = weth.balanceOf(trader); uint256 traderFinalKrk = kraiken.balanceOf(trader); int256 pnl = int256(traderFinalEth) - int256(traderInitEth); console2.log("=== Results ==="); console2.log("Final WETH:", traderFinalEth / 1e18); console2.log("Unsold KRK:", traderFinalKrk / 1e18); console2.log("PnL (wei):", pnl); console2.log("SwapExec WETH:", weth.balanceOf(address(swapExecutor)) / 1e18); console2.log("SwapExec KRK:", kraiken.balanceOf(address(swapExecutor)) / 1e18); // Without ratchet, trader may profit from IL extraction via buy->recenter->sell cycles // This test documents the baseline behavior console2.log("Baseline sweep PnL (no ratchet):", pnl); } 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); } } /// @notice Total system WETH = all holders + LM native ETH (before wrapping) function _systemWeth() internal view returns (uint256) { return weth.balanceOf(trader) + weth.balanceOf(address(lm)) + weth.balanceOf(address(pool)) + weth.balanceOf(address(swapExecutor)) + weth.balanceOf(address(0xdead)) + weth.balanceOf(fees) + address(lm).balance; } /// @notice Trade-by-trade diagnostic: replicate sweep run 0 with verbose logging /// Uses exact same seeds and pattern as ParameterSweepFuzzing._executeRun(0) /// Bear params: CI=0.8e18, AS=0.1e18, AW=40, DD=0.2e18, buyBias=80 function test_tradeByTrade_diagnostic() public { vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); uint256 traderInitEth = weth.balanceOf(trader); uint256 systemStart = _systemWeth(); console2.log("=== TRADE-BY-TRADE DIAGNOSTIC ==="); console2.log("traderInitWETH:", traderInitEth); console2.log("systemWETH:", systemStart); console2.log("LM native ETH:", address(lm).balance); console2.log("LM WETH:", weth.balanceOf(address(lm))); console2.log("pool WETH:", weth.balanceOf(address(pool))); (, int24 startTick,,,,,) = pool.slot0(); console2.log("startTick:", startTick); bool crossedAbove200 = false; uint256 runIndex = 0; for (uint256 i = 0; i < 30; i++) { // 1/3 chance of recenter (same hash as sweep) if (uint256(keccak256(abi.encodePacked(runIndex, i, "recenter"))) % 3 == 0) { uint256 sysBeforeRec = _systemWeth(); vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { uint256 sysAfterRec = _systemWeth(); (, int24 tick,,,,,) = pool.slot0(); console2.log("---"); console2.log("RECENTER ok tick:", tick); console2.log(" tW:", weth.balanceOf(trader)); console2.log(" tK:", kraiken.balanceOf(trader)); if (sysAfterRec != sysBeforeRec) { int256 leak = int256(sysAfterRec) - int256(sysBeforeRec); console2.log(" !! SYS WETH CHANGED by:", leak); } } catch { console2.log("--- RECENTER failed at trade", i); } } uint256 rand = uint256(keccak256(abi.encodePacked(runIndex, i))) % 100; if (rand < 80) { // Buy (same amount calc as sweep) uint256 amount = (20 * 1 ether) + (uint256(keccak256(abi.encodePacked(runIndex, i, "buy"))) % (60 * 1 ether)); uint256 traderWethBefore = weth.balanceOf(trader); uint256 traderKrkBefore = kraiken.balanceOf(trader); if (traderWethBefore < amount) { console2.log("T", i); console2.log(" BUY_SKIP need:", amount / 1e18); console2.log(" have:", traderWethBefore / 1e18); continue; } uint256 sysBefore = _systemWeth(); vm.startPrank(trader); weth.transfer(address(swapExecutor), amount); try swapExecutor.executeBuy(amount, trader) returns (uint256 consumed) { vm.stopPrank(); _recoverStuck(); uint256 traderWethAfter = weth.balanceOf(trader); uint256 traderKrkAfter = kraiken.balanceOf(trader); uint256 sysAfter = _systemWeth(); (, int24 tick,,,,,) = pool.slot0(); console2.log("T", i); console2.log(" BUY amt:", amount / 1e18); console2.log(" consumed:", consumed / 1e18); console2.log(" tW:", traderWethAfter); console2.log(" tK:", traderKrkAfter); console2.log(" tick:", tick); int256 wethDelta = int256(traderWethAfter) - int256(traderWethBefore); int256 krkDelta = int256(traderKrkAfter) - int256(traderKrkBefore); console2.log(" dW:", wethDelta); console2.log(" dK:", krkDelta); if (sysAfter != sysBefore) { int256 leak = int256(sysAfter) - int256(sysBefore); console2.log(" !! SYS LEAK:", leak); } if (!crossedAbove200 && traderWethAfter > 200 ether) { crossedAbove200 = true; console2.log(" *** CROSSED 200 WETH ***"); } } catch { vm.stopPrank(); _recoverStuck(); console2.log("T", i); console2.log(" BUY_FAIL amt:", amount / 1e18); } } else { // Sell (same calc as sweep) uint256 krkBal = kraiken.balanceOf(trader); if (krkBal == 0) { console2.log("T", i); console2.log(" SELL_SKIP (0 KRK)"); continue; } uint256 pct = 20 + (uint256(keccak256(abi.encodePacked(runIndex, i, "sell"))) % 60); uint256 amount = krkBal * pct / 100; if (amount == 0) { console2.log("T", i); console2.log(" SELL_SKIP (0 amt)"); continue; } uint256 traderWethBefore = weth.balanceOf(trader); uint256 traderKrkBefore = kraiken.balanceOf(trader); uint256 sysBefore = _systemWeth(); vm.startPrank(trader); kraiken.transfer(address(swapExecutor), amount); try swapExecutor.executeSell(amount, trader) returns (uint256 consumed) { vm.stopPrank(); _recoverStuck(); uint256 traderWethAfter = weth.balanceOf(trader); uint256 traderKrkAfter = kraiken.balanceOf(trader); uint256 sysAfter = _systemWeth(); (, int24 tick,,,,,) = pool.slot0(); console2.log("T", i); console2.log(" SELL pct:", pct); console2.log(" amt:", amount / 1e18); console2.log(" consumed:", consumed / 1e18); console2.log(" tW:", traderWethAfter); console2.log(" tK:", traderKrkAfter); console2.log(" tick:", tick); int256 wethDelta = int256(traderWethAfter) - int256(traderWethBefore); console2.log(" dW:", wethDelta); if (sysAfter != sysBefore) { int256 leak = int256(sysAfter) - int256(sysBefore); console2.log(" !! SYS LEAK:", leak); } if (!crossedAbove200 && traderWethAfter > 200 ether) { crossedAbove200 = true; console2.log(" *** CROSSED 200 WETH ***"); } } catch { vm.stopPrank(); _recoverStuck(); console2.log("T", i); console2.log(" SELL_FAIL amt:", amount / 1e18); } } } console2.log("=== PRE-LIQUIDATION ==="); console2.log("tW:", weth.balanceOf(trader)); console2.log("tK:", kraiken.balanceOf(trader)); console2.log("seW:", weth.balanceOf(address(swapExecutor))); console2.log("seK:", kraiken.balanceOf(address(swapExecutor))); // Final recenter vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } // Liquidate (same as sweep) uint256 remaining = kraiken.balanceOf(trader); uint256 attempts; while (remaining > 0 && attempts < 20) { uint256 prev = remaining; uint256 wBefore = weth.balanceOf(trader); vm.startPrank(trader); kraiken.transfer(address(swapExecutor), remaining); vm.stopPrank(); try swapExecutor.executeSell(remaining, trader) { if (weth.balanceOf(trader) <= wBefore) { _recoverStuck(); break; } } catch { _recoverStuck(); break; } _recoverStuck(); if (attempts % 3 == 2) { vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } } remaining = kraiken.balanceOf(trader); if (remaining >= prev) break; unchecked { attempts++; } } _recoverStuck(); uint256 traderFinalEth = weth.balanceOf(trader); uint256 traderFinalKrk = kraiken.balanceOf(trader); int256 pnl = int256(traderFinalEth) - int256(traderInitEth); uint256 systemEnd = _systemWeth(); console2.log("=== FINAL ==="); console2.log("traderWETH:", traderFinalEth); console2.log("traderKRK:", traderFinalKrk); console2.log("PnL:", pnl); console2.log("systemStart:", systemStart); console2.log("systemEnd:", systemEnd); int256 sysChange = int256(systemEnd) - int256(systemStart); console2.log("sysChange:", sysChange); // System WETH should be conserved (no ETH created from nothing) // If sysChange != 0, ETH is leaking in or out if (sysChange != 0) { console2.log("!! SYSTEM WETH NOT CONSERVED !!"); } } /// @notice Compare: single big buy vs multi-buy with recenters /// If single big buy also profits, the issue is trade size. /// If only multi-buy profits, recenters are creating the opportunity. function test_singleBigBuy_vs_multiBuy() public { // === SCENARIO A: Single big buy (185 ETH) → recenter → sell all === vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); uint256 initA = weth.balanceOf(trader); // Buy 185 ETH in one shot vm.startPrank(trader); weth.transfer(address(swapExecutor), 185 ether); vm.stopPrank(); swapExecutor.executeBuy(185 ether, trader); _recoverStuck(); uint256 krkA = kraiken.balanceOf(trader); (, int24 tickAfterBuy,,,,,) = pool.slot0(); console2.log("=== SCENARIO A: Single 185 ETH buy ==="); console2.log("KRK received:", krkA); console2.log("tick after buy:", tickAfterBuy); console2.log("tW after buy:", weth.balanceOf(trader)); // Recenter vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } (, int24 tickAfterRec,,,,,) = pool.slot0(); console2.log("tick after recenter:", tickAfterRec); // Sell all KRK uint256 remaining = kraiken.balanceOf(trader); uint256 attempts; while (remaining > 0 && attempts < 20) { uint256 prev = remaining; vm.startPrank(trader); kraiken.transfer(address(swapExecutor), remaining); vm.stopPrank(); try swapExecutor.executeSell(remaining, trader) { } catch { } _recoverStuck(); if (attempts % 3 == 2) { vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } } remaining = kraiken.balanceOf(trader); if (remaining >= prev) break; unchecked { attempts++; } } _recoverStuck(); int256 pnlA = int256(weth.balanceOf(trader)) - int256(initA); console2.log("PnL A (single buy):", pnlA); console2.log("Unsold KRK A:", kraiken.balanceOf(trader)); } /// @notice Isolate: 2 buys with recenter between them function test_twoBuys_withRecenter() public { vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); uint256 init = weth.balanceOf(trader); // Buy #1: 78 ETH vm.startPrank(trader); weth.transfer(address(swapExecutor), 78 ether); vm.stopPrank(); swapExecutor.executeBuy(78 ether, trader); _recoverStuck(); (, int24 t1,,,,,) = pool.slot0(); console2.log("After buy1: tick", t1); console2.log(" tW:", weth.balanceOf(trader)); console2.log(" tK:", kraiken.balanceOf(trader)); // Recenter vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { console2.log(" recenter1 FAILED"); } // Buy #2: 47 ETH vm.startPrank(trader); weth.transfer(address(swapExecutor), 47 ether); vm.stopPrank(); swapExecutor.executeBuy(47 ether, trader); _recoverStuck(); (, int24 t2,,,,,) = pool.slot0(); console2.log("After buy2: tick", t2); console2.log(" tW:", weth.balanceOf(trader)); console2.log(" tK:", kraiken.balanceOf(trader)); // Recenter again vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { console2.log(" recenter2 FAILED"); } // Sell ALL uint256 remaining = kraiken.balanceOf(trader); uint256 attempts; while (remaining > 0 && attempts < 20) { uint256 prev = remaining; vm.startPrank(trader); kraiken.transfer(address(swapExecutor), remaining); vm.stopPrank(); try swapExecutor.executeSell(remaining, trader) { } catch { } _recoverStuck(); if (attempts % 3 == 2) { vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } } remaining = kraiken.balanceOf(trader); if (remaining >= prev) break; unchecked { attempts++; } } _recoverStuck(); int256 pnl = int256(weth.balanceOf(trader)) - int256(init); console2.log("=== 2-buy result ==="); console2.log("PnL:", pnl); console2.log("Unsold KRK:", kraiken.balanceOf(trader)); } /// @notice 4 buys matching sweep T0-T5 exactly, with recenters function test_fourBuys_withRecenters() public { vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); uint256 init = weth.balanceOf(trader); uint256 sysStart = _systemWeth(); // Replicate the sweep's first 4 buys exactly uint256[4] memory buyAmounts = [uint256(78 ether), 47 ether, 40 ether, 20 ether]; // Recenters happen after T0 and T1 (from the diagnostic output) bool[4] memory recenterAfter = [true, true, false, false]; for (uint256 i = 0; i < 4; i++) { vm.startPrank(trader); weth.transfer(address(swapExecutor), buyAmounts[i]); vm.stopPrank(); swapExecutor.executeBuy(buyAmounts[i], trader); _recoverStuck(); (, int24 tick,,,,,) = pool.slot0(); console2.log("Buy", i); console2.log(" amt:", buyAmounts[i] / 1e18); console2.log(" tick:", tick); console2.log(" tW:", weth.balanceOf(trader)); console2.log(" tK:", kraiken.balanceOf(trader)); if (recenterAfter[i]) { vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { console2.log(" recenter OK"); } catch { console2.log(" recenter FAILED"); } } } // Final recenter before selling vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } // Sell ALL uint256 remaining = kraiken.balanceOf(trader); uint256 attempts; while (remaining > 0 && attempts < 20) { uint256 prev = remaining; vm.startPrank(trader); kraiken.transfer(address(swapExecutor), remaining); vm.stopPrank(); try swapExecutor.executeSell(remaining, trader) { } catch { } _recoverStuck(); if (attempts % 3 == 2) { vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } } remaining = kraiken.balanceOf(trader); if (remaining >= prev) break; unchecked { attempts++; } } _recoverStuck(); int256 pnl = int256(weth.balanceOf(trader)) - int256(init); uint256 sysEnd = _systemWeth(); console2.log("=== 4-buy result ==="); console2.log("PnL:", pnl); console2.log("Unsold KRK:", kraiken.balanceOf(trader)); console2.log("sysStart:", sysStart); console2.log("sysEnd:", sysEnd); } /// @notice Deep diagnostic: track position layout, VWAP tick, and ETH distribution /// around each recenter to understand WHY recenters between buys create profit. function test_recenterMechanism_diagnostic() public { vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); console2.log("=== INITIAL STATE (after setUp recenter) ==="); _logPositions("INIT"); // --- Buy #1: 78 ETH --- vm.startPrank(trader); weth.transfer(address(swapExecutor), 78 ether); vm.stopPrank(); swapExecutor.executeBuy(78 ether, trader); _recoverStuck(); console2.log("=== AFTER BUY1 (78 ETH) ==="); _logPositions("BUY1"); console2.log(" tW:", weth.balanceOf(trader)); console2.log(" tK:", kraiken.balanceOf(trader) / 1e18); // --- Recenter #1 --- vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.recordLogs(); vm.prank(fees); lm.recenter(); _logRecenterEvents(); console2.log("=== AFTER RECENTER1 ==="); _logPositions("REC1"); // --- Buy #2: 47 ETH --- vm.startPrank(trader); weth.transfer(address(swapExecutor), 47 ether); vm.stopPrank(); swapExecutor.executeBuy(47 ether, trader); _recoverStuck(); console2.log("=== AFTER BUY2 (47 ETH) ==="); _logPositions("BUY2"); console2.log(" tW:", weth.balanceOf(trader)); console2.log(" tK:", kraiken.balanceOf(trader) / 1e18); // --- Recenter #2 --- vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.recordLogs(); vm.prank(fees); lm.recenter(); _logRecenterEvents(); console2.log("=== AFTER RECENTER2 ==="); _logPositions("REC2"); // --- Buy #3: 40 ETH --- vm.startPrank(trader); weth.transfer(address(swapExecutor), 40 ether); vm.stopPrank(); swapExecutor.executeBuy(40 ether, trader); _recoverStuck(); console2.log("=== AFTER BUY3 (40 ETH) ==="); _logPositions("BUY3"); console2.log(" tW:", weth.balanceOf(trader)); console2.log(" tK:", kraiken.balanceOf(trader) / 1e18); // --- Recenter #3 --- vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.recordLogs(); vm.prank(fees); lm.recenter(); _logRecenterEvents(); console2.log("=== AFTER RECENTER3 ==="); _logPositions("REC3"); // --- Now sell everything --- console2.log("=== SELLING ALL KRK ==="); uint256 totalKrk = kraiken.balanceOf(trader); console2.log(" KRK to sell:", totalKrk / 1e18); console2.log(" WETH before sell:", weth.balanceOf(trader)); uint256 remaining = totalKrk; uint256 attempts; while (remaining > 0 && attempts < 20) { uint256 prev = remaining; vm.startPrank(trader); kraiken.transfer(address(swapExecutor), remaining); vm.stopPrank(); try swapExecutor.executeSell(remaining, trader) { } catch { } _recoverStuck(); if (attempts % 3 == 2) { vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } } remaining = kraiken.balanceOf(trader); if (remaining >= prev) break; unchecked { attempts++; } } _recoverStuck(); console2.log("=== AFTER SELL ALL ==="); _logPositions("SOLD"); int256 pnl = int256(weth.balanceOf(trader)) - 200 ether; console2.log(" PnL:", pnl); console2.log(" tW:", weth.balanceOf(trader)); console2.log(" unsold KRK:", kraiken.balanceOf(trader)); console2.log(" sysWeth:", _systemWeth()); } function _logPositions(string memory label) internal view { (, int24 currentTick,,,,,) = pool.slot0(); console2.log(" currentTick:", currentTick); // Floor position (uint128 floorLiq, int24 floorLower, int24 floorUpper) = lm.positions(ThreePositionStrategy.Stage.FLOOR); console2.log(" FLOOR: [", floorLower); console2.log(" ,", floorUpper); console2.log(" ] liq:", uint256(floorLiq)); // Anchor position (uint128 anchorLiq, int24 anchorLower, int24 anchorUpper) = lm.positions(ThreePositionStrategy.Stage.ANCHOR); console2.log(" ANCHOR: [", anchorLower); console2.log(" ,", anchorUpper); console2.log(" ] liq:", uint256(anchorLiq)); // Discovery position (uint128 discLiq, int24 discLower, int24 discUpper) = lm.positions(ThreePositionStrategy.Stage.DISCOVERY); console2.log(" DISC: [", discLower); console2.log(" ,", discUpper); console2.log(" ] liq:", uint256(discLiq)); // ETH distribution uint256 lmEth = address(lm).balance + weth.balanceOf(address(lm)); uint256 poolWeth = weth.balanceOf(address(pool)); console2.log(" lmEth:", lmEth); console2.log(" poolWeth:", poolWeth); // VWAP uint256 vwapX96 = lm.getVWAP(); uint256 adjVwapX96 = lm.getAdjustedVWAP(8e17); // CI=0.8e18 console2.log(" vwapX96:", vwapX96); console2.log(" adjVwapX96:", adjVwapX96); console2.log(" cumVolume:", lm.cumulativeVolume()); } function _logRecenterEvents() internal { Vm.Log[] memory logs = vm.getRecordedLogs(); bytes32 scarcitySig = keccak256("EthScarcity(int24,uint256,uint256,uint256,int24)"); bytes32 abundanceSig = keccak256("EthAbundance(int24,uint256,uint256,uint256,int24)"); for (uint256 i = 0; i < logs.length; i++) { if (logs[i].topics.length > 0) { if (logs[i].topics[0] == scarcitySig) { (int24 tick, uint256 ethBal, uint256 supply, uint256 vwap, int24 vwapTick) = abi.decode(logs[i].data, (int24, uint256, uint256, uint256, int24)); console2.log(" EVENT EthScarcity:"); console2.log(" tick:", tick); console2.log(" ethBal:", ethBal); console2.log(" supply:", supply); console2.log(" vwapTick:", vwapTick); } else if (logs[i].topics[0] == abundanceSig) { (int24 tick, uint256 ethBal, uint256 supply, uint256 vwap, int24 vwapTick) = abi.decode(logs[i].data, (int24, uint256, uint256, uint256, int24)); console2.log(" EVENT EthAbundance:"); console2.log(" tick:", tick); console2.log(" ethBal:", ethBal); console2.log(" supply:", supply); console2.log(" vwapTick:", vwapTick); } } } } /// @notice Verifies that with proper cleanup, no false profits appear function test_noFalseProfit_withCleanup() public { // === RUN 0 === vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); vm.startPrank(trader); weth.transfer(address(swapExecutor), 80 ether); vm.stopPrank(); swapExecutor.executeBuy(80 ether, trader); uint256 stuckW = weth.balanceOf(address(swapExecutor)); if (stuckW > 0) { vm.prank(address(swapExecutor)); weth.transfer(trader, stuckW); } vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter() { } catch { } // Partial liquidation uint256 halfKrk = kraiken.balanceOf(trader) / 2; if (halfKrk > 0) { vm.startPrank(trader); kraiken.transfer(address(swapExecutor), halfKrk); vm.stopPrank(); swapExecutor.executeSell(halfKrk, trader); } stuckW = weth.balanceOf(address(swapExecutor)); if (stuckW > 0) { vm.prank(address(swapExecutor)); weth.transfer(trader, stuckW); } uint256 stuckK = kraiken.balanceOf(address(swapExecutor)); if (stuckK > 0) { vm.prank(address(swapExecutor)); kraiken.transfer(trader, stuckK); } // === CLEANUP (the fix) === uint256 leftoverWeth = weth.balanceOf(trader); if (leftoverWeth > 0) { vm.prank(trader); weth.transfer(address(0xdead), leftoverWeth); } uint256 leftoverKraiken = kraiken.balanceOf(trader); if (leftoverKraiken > 0) { vm.prank(trader); kraiken.transfer(address(0xdead), leftoverKraiken); } // === RUN 1 (WITH CLEANUP) === vm.deal(trader, 200 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); uint256 run1InitEth = weth.balanceOf(trader); uint256 run1InitKrk = kraiken.balanceOf(trader); assertEq(run1InitEth, 200 ether, "Should start with exactly 200 WETH"); assertEq(run1InitKrk, 0, "Should start with 0 KRAIKEN"); console2.log("Run 1 (clean) - Init WETH:", run1InitEth / 1e18); console2.log("Run 1 (clean) - Init KRK:", run1InitKrk); // No trades in run 1 — just check starting state is clean int256 pnl = int256(weth.balanceOf(trader)) - int256(run1InitEth); assertEq(pnl, 0, "PnL should be 0 with no trades and clean state"); } }