1318 lines
50 KiB
Solidity
1318 lines
50 KiB
Solidity
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
|
|
pragma solidity ^0.8.19;
|
||
|
|
|
||
|
|
import { SwapExecutor } from "../analysis/helpers/SwapExecutor.sol";
|
||
|
|
import { Kraiken } from "../src/Kraiken.sol";
|
||
|
|
import { LiquidityManager } from "../src/LiquidityManager.sol";
|
||
|
|
import { ThreePositionStrategy } from "../src/abstracts/ThreePositionStrategy.sol";
|
||
|
|
import { UniswapHelpers } from "../src/helpers/UniswapHelpers.sol";
|
||
|
|
import { IWETH9 } from "../src/interfaces/IWETH9.sol";
|
||
|
|
import { TestEnvironment } from "./helpers/TestBase.sol";
|
||
|
|
import { ConfigurableOptimizer } from "./mocks/ConfigurableOptimizer.sol";
|
||
|
|
import { IUniswapV3Factory } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
|
||
|
|
import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
|
||
|
|
import "forge-std/Test.sol";
|
||
|
|
import "forge-std/console2.sol";
|
||
|
|
|
||
|
|
/// @title 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");
|
||
|
|
}
|
||
|
|
}
|