// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import { Kraiken } from "../src/Kraiken.sol"; import { LiquidityManager } from "../src/LiquidityManager.sol"; import { Stake } from "../src/Stake.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 "../test/helpers/TestBase.sol"; import { ConfigurableOptimizer } from "../test/mocks/ConfigurableOptimizer.sol"; import { SwapExecutor } from "./helpers/SwapExecutor.sol"; import { IUniswapV3Factory } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol"; import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; import "forge-std/Script.sol"; import "forge-std/console2.sol"; /// @title ParameterSweepFuzzing /// @notice Runs multiple parameter combinations in a single forge execution. /// @dev Key design: environment is deployed ONCE per combo and reused across runs /// so that VWAP accumulates naturally across recenters. /// Swaps are uncapped by default (no LiquidityBoundaryHelper limits). /// LM ETH is tracked as direct balance + pool WETH (since LM is sole LP, /// pool WETH represents LM's deployed position ETH). contract ParameterSweepFuzzing is Script { TestEnvironment testEnv; IUniswapV3Factory factory; IUniswapV3Pool pool; IWETH9 weth; Kraiken kraiken; Stake stake; LiquidityManager lm; SwapExecutor swapExecutor; ConfigurableOptimizer optimizer; bool token0isWeth; address trader = makeAddr("trader"); address fees = makeAddr("fees"); string summaryFile; uint256 cfgBuyBias; uint256 cfgTradesPerRun; uint256 cfgRunsPerCombo; uint256 cfgMinBuy; uint256 cfgMaxBuy; bool cfgUncapped; struct RunResult { uint256 lmEthInit; uint256 lmEthFinal; uint256 traderInitEth; uint256 traderFinalEth; uint256 numRecenters; uint256 numBuysFailed; } function run() public { cfgTradesPerRun = vm.envOr("TRADES_PER_RUN", uint256(30)); cfgRunsPerCombo = vm.envOr("RUNS_PER_COMBO", uint256(5)); cfgUncapped = vm.envOr("UNCAPPED_SWAPS", true); cfgMinBuy = vm.envOr("MIN_BUY_ETH", uint256(20)); cfgMaxBuy = vm.envOr("MAX_BUY_ETH", uint256(80)); string memory tag = vm.envOr("SWEEP_TAG", string("SWEEP")); // Parse parameter arrays from comma-separated env vars uint256[] memory ciValues = _parseUintArray(vm.envOr("CI_VALUES", string("0,500000000000000000,1000000000000000000"))); uint256[] memory asValues = _parseUintArray(vm.envOr("AS_VALUES", string("100000000000000000,500000000000000000,1000000000000000000"))); uint256[] memory awValues = _parseUintArray(vm.envOr("AW_VALUES", string("30,50,80"))); uint256[] memory ddValues = _parseUintArray(vm.envOr("DD_VALUES", string("200000000000000000,1000000000000000000"))); uint256[] memory bbValues = _parseUintArray(vm.envOr("BB_VALUES", string("60,80,100"))); uint256 totalCombos = ciValues.length * asValues.length * awValues.length * ddValues.length * bbValues.length; console2.log("=== Parameter Sweep ==="); console2.log("Combinations:", totalCombos); console2.log("Runs/combo:", cfgRunsPerCombo); console2.log("Trades/run:", cfgTradesPerRun); console2.log("Uncapped:", cfgUncapped); console2.log("Trade range (ETH):", cfgMinBuy, cfgMaxBuy); testEnv = new TestEnvironment(fees); factory = UniswapHelpers.deployUniswapFactory(); summaryFile = string(abi.encodePacked("analysis/sweep-", tag, "-summary.csv")); vm.writeFile(summaryFile, "ci,anchor_share,anchor_width,discovery_depth,buy_bias,max_trader_pnl,min_trader_pnl,any_lm_loss,lm_eth_delta\n"); uint256 comboIndex = 0; for (uint256 ci_i = 0; ci_i < ciValues.length; ci_i++) { for (uint256 as_i = 0; as_i < asValues.length; as_i++) { for (uint256 aw_i = 0; aw_i < awValues.length; aw_i++) { for (uint256 dd_i = 0; dd_i < ddValues.length; dd_i++) { for (uint256 bb_i = 0; bb_i < bbValues.length; bb_i++) { comboIndex++; _runCombo( ciValues[ci_i], asValues[as_i], uint24(awValues[aw_i] > 100 ? 100 : awValues[aw_i]), ddValues[dd_i], bbValues[bb_i], comboIndex ); } } } } } console2.log("=== Sweep Complete ==="); console2.log("Results:", summaryFile); } function _runCombo(uint256 ci, uint256 as_, uint24 aw, uint256 dd, uint256 bb, uint256 comboIdx) internal { // Deploy optimizer and environment ONCE per combo optimizer = new ConfigurableOptimizer(ci, as_, aw, dd); cfgBuyBias = bb; // Setup environment once — VWAP accumulates across all runs _setupEnvironment(comboIdx % 2 == 0); int256 maxPnl = type(int256).min; int256 minPnl = type(int256).max; bool anyLoss = false; // Track total LM ETH = direct balance + pool WETH (LM's deployed positions) // address(lm).balance + weth.balanceOf(lm) is near-zero after recenter since ETH is in pool uint256 lmSystemStart = _getLmSystemEth(); for (uint256 runIdx = 0; runIdx < cfgRunsPerCombo; runIdx++) { RunResult memory result = _executeRun(runIdx); int256 pnl = int256(result.traderFinalEth) - int256(result.traderInitEth); if (pnl > maxPnl) maxPnl = pnl; if (pnl < minPnl) minPnl = pnl; if (pnl > 0) anyLoss = true; } uint256 lmSystemEnd = _getLmSystemEth(); int256 lmDelta = int256(lmSystemEnd) - int256(lmSystemStart); // Write summary string memory row = string(abi.encodePacked(vm.toString(ci), ",", vm.toString(as_), ",", vm.toString(uint256(aw)), ",")); row = string(abi.encodePacked(row, vm.toString(dd), ",", vm.toString(bb), ",", vm.toString(maxPnl), ",")); row = string(abi.encodePacked(row, vm.toString(minPnl), ",", anyLoss ? "true" : "false", ",", vm.toString(lmDelta))); vm.writeLine(summaryFile, row); // Log progress if (anyLoss) { console2.log("UNSAFE combo", comboIdx); console2.log(" maxPnl:", maxPnl); console2.log(" lmDelta:", lmDelta); } else if (comboIdx % 5 == 0) { console2.log("Progress:", comboIdx); } } /// @notice Approximate total ETH attributable to the LM (direct balance + pool WETH) /// @dev Pool WETH includes both LM position ETH and trader buy ETH in transit. /// This is a rough proxy — trader PnL is the reliable safety metric. function _getLmSystemEth() internal view returns (uint256) { return address(lm).balance + weth.balanceOf(address(lm)) + weth.balanceOf(address(pool)); } function _executeRun(uint256 runIndex) internal returns (RunResult memory result) { // DON'T re-setup environment — reuse existing so VWAP accumulates result.lmEthInit = _getLmSystemEth(); // Fund trader fresh each run vm.deal(trader, 400 ether); vm.prank(trader); weth.deposit{ value: 200 ether }(); result.traderInitEth = weth.balanceOf(trader); for (uint256 i = 0; i < cfgTradesPerRun; i++) { if (uint256(keccak256(abi.encodePacked(runIndex, i, "recenter"))) % 3 == 0) { if (_tryRecenter()) result.numRecenters++; } uint256 rand = uint256(keccak256(abi.encodePacked(runIndex, i))) % 100; if (rand < cfgBuyBias) { if (!_executeBuy(runIndex, i)) result.numBuysFailed++; } else { _executeSell(runIndex, i); } } _tryRecenter(); _liquidateTraderHoldings(); result.lmEthFinal = _getLmSystemEth(); result.traderFinalEth = weth.balanceOf(trader); } function _setupEnvironment(bool wethIsToken0) internal { (factory, pool, weth, kraiken, stake, lm,, token0isWeth) = testEnv.setupEnvironmentWithExistingFactory(factory, wethIsToken0, fees, address(optimizer)); swapExecutor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm, cfgUncapped); vm.deal(address(lm), 200 ether); vm.prank(address(lm)); weth.deposit{ value: 100 ether }(); vm.prank(fees); try lm.recenter() { } catch { } } function _executeBuy(uint256 runIndex, uint256 tradeIndex) internal returns (bool) { uint256 range = cfgMaxBuy - cfgMinBuy; uint256 amount = (cfgMinBuy * 1 ether) + (uint256(keccak256(abi.encodePacked(runIndex, tradeIndex, "buy"))) % (range * 1 ether)); if (weth.balanceOf(trader) < amount) return false; vm.startPrank(trader); weth.transfer(address(swapExecutor), amount); try swapExecutor.executeBuy(amount, trader) returns (uint256 actualAmount) { vm.stopPrank(); return actualAmount > 0; } catch { vm.stopPrank(); return false; } } function _executeSell(uint256 runIndex, uint256 tradeIndex) internal returns (bool) { uint256 kraikenBal = kraiken.balanceOf(trader); if (kraikenBal == 0) return false; // Sell 20-80% of holdings uint256 pct = 20 + (uint256(keccak256(abi.encodePacked(runIndex, tradeIndex, "sell"))) % 60); uint256 amount = kraikenBal * pct / 100; if (amount == 0) return false; vm.startPrank(trader); kraiken.transfer(address(swapExecutor), amount); try swapExecutor.executeSell(amount, trader) returns (uint256 actualAmount) { vm.stopPrank(); return actualAmount > 0; } catch { vm.stopPrank(); return false; } } function _tryRecenter() internal returns (bool) { vm.warp(block.timestamp + 1 hours); vm.roll(block.number + 1); vm.prank(fees); try lm.recenter{ gas: 50_000_000 }() { return true; } catch { return false; } } function _liquidateTraderHoldings() internal { uint256 remaining = kraiken.balanceOf(trader); uint256 attempts; while (remaining > 0 && attempts < 20) { uint256 prev = remaining; vm.startPrank(trader); kraiken.transfer(address(swapExecutor), remaining); try swapExecutor.executeSell(remaining, trader) returns (uint256 a) { vm.stopPrank(); if (a == 0) break; } catch { vm.stopPrank(); break; } // Recenter between attempts to unlock more sell liquidity if (attempts % 3 == 2) { _tryRecenter(); } remaining = kraiken.balanceOf(trader); if (remaining >= prev) break; unchecked { attempts++; } } } /// @notice Parse comma-separated uint string into array function _parseUintArray(string memory csv) internal pure returns (uint256[] memory) { bytes memory b = bytes(csv); uint256 count = 1; for (uint256 i = 0; i < b.length; i++) { if (b[i] == ",") count++; } uint256[] memory result = new uint256[](count); uint256 idx = 0; uint256 start = 0; for (uint256 i = 0; i <= b.length; i++) { if (i == b.length || b[i] == ",") { uint256 num = 0; for (uint256 j = start; j < i; j++) { require(b[j] >= "0" && b[j] <= "9", "Invalid number"); num = num * 10 + (uint256(uint8(b[j])) - 48); } result[idx] = num; idx++; start = i + 1; } } return result; } }