harb/onchain/test/FuzzingAnalyzerBugs.t.sol

1318 lines
50 KiB
Solidity
Raw Normal View History

// 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");
}
}